diff --git a/.github/actions/azure-artifact-sign/action.yml b/.github/actions/azure-artifact-sign/action.yml new file mode 100644 index 000000000..3beee9447 --- /dev/null +++ b/.github/actions/azure-artifact-sign/action.yml @@ -0,0 +1,47 @@ +name: Azure Artifact Sign +description: Sign PE/MSI files with Azure Artifact Signing (OIDC via azure/login in the caller) + +inputs: + files: + description: Comma or newline-separated absolute paths to sign + required: true + endpoint: + description: Artifact Signing account endpoint + required: true + signing-account-name: + description: Artifact Signing account name + required: true + certificate-profile-name: + description: Certificate profile name + required: true + description: + description: Authenticode description + required: false + default: Titanium Web Proxy + +runs: + using: composite + steps: + - name: Sign files + uses: azure/artifact-signing-action@v2 + with: + endpoint: ${{ inputs.endpoint }} + signing-account-name: ${{ inputs.signing-account-name }} + certificate-profile-name: ${{ inputs.certificate-profile-name }} + files: ${{ inputs.files }} + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + description: ${{ inputs.description }} + description-url: https://github.com/justcoding121/titanium-web-proxy + # azure/login already established Azure CLI; skip slower credential types. + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true diff --git a/.github/workflows/chocolatey-publish.yml b/.github/workflows/chocolatey-publish.yml new file mode 100644 index 000000000..b7edad08d --- /dev/null +++ b/.github/workflows/chocolatey-publish.yml @@ -0,0 +1,51 @@ +name: chocolatey-publish + +on: + workflow_dispatch: + inputs: + release_tag: + description: 'GitHub release tag (e.g. v7.0.5 or v7.0.5-beta)' + required: true + type: string + +permissions: + contents: read + +jobs: + publish: + runs-on: windows-latest + steps: + - name: Require CHOCOLATEY_API_KEY + env: + CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }} + shell: pwsh + run: | + if ([string]::IsNullOrWhiteSpace($env:CHOCOLATEY_API_KEY)) { + throw 'CHOCOLATEY_API_KEY repository secret is not set.' + } + + - uses: actions/checkout@v6 + + - name: Pack and push chocolatey packages + env: + CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }} + RELEASE_TAG: ${{ inputs.release_tag }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $tag = $env:RELEASE_TAG.Trim() + if ($tag -notmatch '^v') { $tag = "v$tag" } + pwsh ./tools/packaging/chocolatey/bump-packages.ps1 -Tag $tag + $cliAsset = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$tag/Titanium.Cli-win-x64.zip" + $msiAsset = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$tag/TitaniumInspector-win-x64.msi" + Invoke-WebRequest -Method Head -Uri $cliAsset -UseBasicParsing | Out-Null + Invoke-WebRequest -Method Head -Uri $msiAsset -UseBasicParsing | Out-Null + Push-Location tools/packaging/chocolatey/titanium-cli + choco pack --output-directory . + Pop-Location + Push-Location tools/packaging/chocolatey/titanium-inspector + choco pack --output-directory . + Pop-Location + Get-ChildItem tools/packaging/chocolatey -Recurse -Filter *.nupkg | ForEach-Object { + choco push $_.FullName --source https://push.chocolatey.org/ --api-key $env:CHOCOLATEY_API_KEY + } diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index 5db312b47..7f77d39b9 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -34,7 +34,7 @@ jobs: runs-on: windows-latest timeout-minutes: 60 permissions: - # write: Publish Documentation (EndBug/add-and-commit) pushes DocFX output to develop. + # write: Publish Documentation pushes DocFX output to develop. # GITHUB_TOKEN with contents:read cannot push (403 github-actions[bot]). contents: write # SonarCloud / PR decoration when token is present @@ -134,22 +134,42 @@ jobs: shell: pwsh run: .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="$env:SONAR_TOKEN" - - name: Update Documentation - if: github.ref == 'refs/heads/develop' - run: docfx .github/docfx.json - + # DocFX output races other develop pushes (incl. concurrent "Update documentation"). + # Sync to origin/develop, regenerate, commit docs-only, retry push — never fail the + # job on stash/rebase conflicts from EndBug/add-and-commit autostash. - name: Publish Documentation if: github.ref == 'refs/heads/develop' - uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9 - with: - default_author: github_actions - message: Update documentation - committer_name: GitHub Actions - committer_email: actions@github.com - # Only commit DocFX output -- never Sonar/coverage/tool install artifacts - add: 'docs' - # DocFX often races another develop push; rebase instead of failing the job. - pull: '--rebase --autostash' + shell: pwsh + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + for ($attempt = 1; $attempt -le 5; $attempt++) { + Write-Host "Publish documentation attempt $attempt" + git fetch origin develop + if ($LASTEXITCODE -ne 0) { throw "git fetch failed" } + git reset --hard origin/develop + if ($LASTEXITCODE -ne 0) { throw "git reset failed" } + docfx .github/docfx.json + if ($LASTEXITCODE -ne 0) { throw "docfx failed" } + git add -- docs + if (-not (git status --porcelain -- docs)) { + Write-Host "No documentation changes to publish." + exit 0 + } + git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" ` + commit --author="github-actions <41898282+github-actions[bot]@users.noreply.github.com>" ` + -m "Update documentation" + if ($LASTEXITCODE -ne 0) { throw "git commit failed" } + git push origin HEAD:develop + if ($LASTEXITCODE -eq 0) { + Write-Host "Documentation published." + exit 0 + } + Write-Host "Push rejected (develop moved); retrying..." + Start-Sleep -Seconds (2 * $attempt) + } + throw "Failed to publish documentation after retries" # Cross-OS Inspector + Plus dashboard UI gates (Headless / Visual / Playwright). # Inspector unit suite stays on Windows `build` only except Inspector-Stress (below). @@ -426,6 +446,8 @@ jobs: # Product zips stay on release.yml (v* tags). After beta/stable merge, create/move the # version tag and dispatch release.yml (GITHUB_TOKEN tag pushes do not re-trigger workflows). + # release.yml also packs/pushes Chocolatey (titanium-cli / titanium-inspector) after the + # GitHub Release exists — no separate chocolatey step here. cut-product-tag: if: github.event_name == 'push' && (github.ref == 'refs/heads/beta' || github.ref == 'refs/heads/stable') needs: [build, ui-portable, rps-publish-gate, rps-peer-gate] diff --git a/.github/workflows/packaging-catalog-smoke.yml b/.github/workflows/packaging-catalog-smoke.yml new file mode 100644 index 000000000..718dc3dd3 --- /dev/null +++ b/.github/workflows/packaging-catalog-smoke.yml @@ -0,0 +1,50 @@ +# Validate Homebrew formula against a published release tag. +# Used for beta dry-run before stable catalog submit. +name: packaging-catalog-smoke + +on: + workflow_dispatch: + inputs: + release_tag: + description: 'GitHub release tag (e.g. v7.0.4-beta or v7.0.5)' + required: true + type: string + run_homebrew: + description: 'Smoke brew install from in-repo formula' + required: true + type: boolean + default: true + +permissions: + contents: read + +jobs: + homebrew: + if: inputs.run_homebrew + runs-on: macos-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + + - name: Smoke brew install from tap + env: + RELEASE_TAG: ${{ inputs.release_tag }} + run: | + set -euo pipefail + FORMULA=tools/packaging/homebrew/titanium.rb + test -f "$FORMULA" + # Formula version must match tag without leading v (e.g. 7.0.4-beta). + EXPECTED="${RELEASE_TAG#v}" + grep -q "version \"$EXPECTED\"" "$FORMULA" + # Homebrew rejects bare paths; install via the published custom tap (kept in sync). + export HOMEBREW_NO_REQUIRE_TAP_TRUST=1 + brew tap justcoding121/titanium + brew update || true + # Prefer in-repo formula content for the version under test by copying into a local tap. + LOCAL_TAP="$(brew --repository)/Library/Taps/justcoding121/homebrew-titanium" + mkdir -p "${LOCAL_TAP}/Formula" + cp "$FORMULA" "${LOCAL_TAP}/Formula/titanium.rb" + brew install --formula justcoding121/titanium/titanium + titanium version + brew test justcoding121/titanium/titanium + brew uninstall --formula justcoding121/titanium/titanium || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e710d5800..662ad59d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,8 +109,11 @@ jobs: build-cli: needs: [resolve-version, test] runs-on: ${{ matrix.os }} + # win-x64 uses environment "signing" for Azure OIDC (federated cred). + environment: ${{ matrix.rid == 'win-x64' && 'signing' || '' }} permissions: contents: read + id-token: write strategy: fail-fast: false # Cap concurrency — many self-contained publishes on shared ubuntu runners run out of disk. @@ -127,7 +130,7 @@ jobs: - rid: linux-musl-arm64 os: ubuntu-latest - rid: win-x64 - os: ubuntu-latest + os: windows-latest - rid: osx-x64 os: macos-latest - rid: osx-arm64 @@ -161,17 +164,95 @@ jobs: Copy-Item "$out/titanium" "$out/twp" } ./tools/packaging/bundle-http3-native.ps1 -Rid $rid -PublishDir $out + - name: Azure login (OIDC) + if: matrix.rid == 'win-x64' + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Sign CLI Windows binaries + if: matrix.rid == 'win-x64' + uses: ./.github/actions/azure-artifact-sign + with: + endpoint: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ secrets.AZURE_SIGNING_PROFILE }} + description: Titanium Web Proxy CLI + files: | + ${{ github.workspace }}\artifacts\cli\win-x64\titanium.exe + ${{ github.workspace }}\artifacts\cli\win-x64\twp.exe + - name: Zip Cli + shell: pwsh + run: | + $rid = "${{ matrix.rid }}" + $out = "artifacts/cli/$rid" if ($IsWindows) { Compress-Archive -Path "$out/*" -DestinationPath "Titanium.Cli-$rid.zip" } else { Push-Location $out try { zip -r "../../../Titanium.Cli-$rid.zip" . } finally { Pop-Location } } + - name: Sign and notarize CLI (macOS) + if: startsWith(matrix.rid, 'osx-') + env: + APPLE_DEVELOPER_ID: ${{ secrets.APPLE_DEVELOPER_ID }} + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + NOTARY_KEY: ${{ secrets.NOTARY_KEY }} + NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }} + NOTARY_ISSUER: ${{ secrets.NOTARY_ISSUER }} + run: | + set -euo pipefail + if [[ -z "${APPLE_CERTIFICATE_P12:-}" ]]; then + echo "Apple secrets not set — skipping macOS CLI notarize" + exit 0 + fi + chmod +x tools/packaging/osx/sign-and-notarize.sh + # Sign main binaries inside the publish dir, then re-zip + rid="${{ matrix.rid }}" + out="artifacts/cli/$rid" + chmod +x "$out/titanium" "$out/twp" 2>/dev/null || true + ./tools/packaging/osx/sign-and-notarize.sh "$out/titanium" tools/packaging/osx/TitaniumCli.entitlements + ./tools/packaging/osx/sign-and-notarize.sh "$out/twp" tools/packaging/osx/TitaniumCli.entitlements || true + rm -f "Titanium.Cli-$rid.zip" + (cd "$out" && zip -r "../../../Titanium.Cli-$rid.zip" .) + ./tools/packaging/osx/sign-and-notarize.sh "Titanium.Cli-$rid.zip" || true + - name: Linux packages (AppImage + deb/rpm) + if: matrix.rid == 'linux-x64' || matrix.rid == 'linux-arm64' + env: + RELEASE_VERSION: ${{ needs.resolve-version.outputs.version }} + run: | + set -euo pipefail + rid="${{ matrix.rid }}" + ver="${RELEASE_VERSION%%-*}" + arch=x86_64 + [[ "$rid" == *arm64* ]] && arch=aarch64 + sudo apt-get update -qq + sudo apt-get install -y -qq rsync imagemagick ruby ruby-dev build-essential rpm squashfs-tools file || true + sudo gem install --no-document fpm || true + chmod +x tools/packaging/linux/build-appimage.sh tools/packaging/linux/build-deb-rpm.sh + # AppImage is required for glibc RIDs (fail hard — no soft-fail). + ./tools/packaging/linux/build-appimage.sh cli "artifacts/cli/$rid" \ + "Titanium.Cli-$rid.AppImage" "$ver" "$arch" + ./tools/packaging/linux/build-deb-rpm.sh cli "artifacts/cli/$rid" . "$ver" "$rid" || echo "deb/rpm soft-failed" + - name: Stage CLI artifacts + shell: bash + run: | + set -euo pipefail + rid="${{ matrix.rid }}" + mkdir -p "staged-cli-$rid" + cp "Titanium.Cli-$rid.zip" "staged-cli-$rid/" + for ext in AppImage deb rpm; do + f="Titanium.Cli-$rid.$ext" + [[ -f "$f" ]] && cp "$f" "staged-cli-$rid/" || true + done + ls -la "staged-cli-$rid" - uses: actions/upload-artifact@v4 with: name: cli-${{ matrix.rid }} - path: Titanium.Cli-${{ matrix.rid }}.zip - + path: staged-cli-${{ matrix.rid }}/ + if-no-files-found: error build-plus: needs: [resolve-version, test] runs-on: ubuntu-latest @@ -196,8 +277,10 @@ jobs: build-inspector: needs: [resolve-version, test] runs-on: ${{ matrix.os }} + environment: ${{ matrix.rid == 'win-x64' && 'signing' || '' }} permissions: contents: read + id-token: write strategy: fail-fast: false max-parallel: 2 @@ -250,12 +333,37 @@ jobs: ./tools/packaging/bundle-http3-native.ps1 -Rid $rid -PublishDir $out Copy-Item -Force tools/packaging/THIRD-PARTY-INSPECTOR.txt (Join-Path $out "THIRD-PARTY-INSPECTOR.txt") Copy-Item -Force src/Titanium.Inspector/Assets/app.ico (Join-Path $out "app.ico") + Copy-Item -Force tools/packaging/desktop-icons.sh $out if ($rid -like 'linux*') { Copy-Item -Force tools/packaging/linux/install.sh, tools/packaging/linux/uninstall.sh, tools/packaging/linux/TitaniumInspector.desktop.in $out + Copy-Item -Force tools/packaging/icons/titanium-inspector.png $out + Copy-Item -Force (Get-ChildItem tools/packaging/icons/titanium-inspector-*.png) $out } if ($rid -like 'osx*') { Copy-Item -Force tools/packaging/osx/install-app.sh, tools/packaging/osx/uninstall-app.sh $out + Copy-Item -Force tools/packaging/icons/AppIcon.icns $out } + - name: Azure login (OIDC) + if: matrix.rid == 'win-x64' + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Sign Inspector Windows binary + if: matrix.rid == 'win-x64' + uses: ./.github/actions/azure-artifact-sign + with: + endpoint: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ secrets.AZURE_SIGNING_PROFILE }} + description: Titanium Inspector + files: ${{ github.workspace }}\artifacts\inspector\win-x64\TitaniumInspector.exe + - name: Zip Inspector + shell: pwsh + run: | + $rid = "${{ matrix.rid }}" + $out = "artifacts/inspector/$rid" if ($IsWindows) { Compress-Archive -Path "$out/*" -DestinationPath "TitaniumInspector-$rid.zip" } else { @@ -275,17 +383,81 @@ jobs: -PayloadDir artifacts/inspector/win-x64 ` -OutputMsi TitaniumInspector-win-x64.msi ` -Version $ver + - name: Sign Inspector MSI + if: matrix.msi + uses: ./.github/actions/azure-artifact-sign + with: + endpoint: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ secrets.AZURE_SIGNING_PROFILE }} + description: Titanium Inspector + files: ${{ github.workspace }}\TitaniumInspector-win-x64.msi + - name: Build / sign / notarize Inspector DMG (macOS) + if: startsWith(matrix.rid, 'osx-') + env: + RELEASE_VERSION: ${{ needs.resolve-version.outputs.version }} + APPLE_DEVELOPER_ID: ${{ secrets.APPLE_DEVELOPER_ID }} + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + NOTARY_KEY: ${{ secrets.NOTARY_KEY }} + NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }} + NOTARY_ISSUER: ${{ secrets.NOTARY_ISSUER }} + run: | + set -euo pipefail + rid="${{ matrix.rid }}" + ver="${RELEASE_VERSION%%-*}" + chmod +x tools/packaging/osx/build-app-bundle.sh \ + tools/packaging/osx/build-dmg.sh \ + tools/packaging/osx/sign-and-notarize.sh + ./tools/packaging/osx/build-app-bundle.sh \ + "artifacts/inspector/$rid" "TitaniumInspector.app" "$ver" + if [[ -n "${APPLE_CERTIFICATE_P12:-}" ]]; then + ./tools/packaging/osx/sign-and-notarize.sh \ + TitaniumInspector.app tools/packaging/osx/TitaniumInspector.entitlements + else + echo "Apple secrets not set — building unsigned DMG for CI artifact shape" + fi + ./tools/packaging/osx/build-dmg.sh \ + TitaniumInspector.app "TitaniumInspector-$rid.dmg" "Titanium Inspector" + if [[ -n "${APPLE_CERTIFICATE_P12:-}" ]]; then + ./tools/packaging/osx/sign-and-notarize.sh "TitaniumInspector-$rid.dmg" || true + fi + - name: Linux packages (AppImage + deb/rpm) + if: matrix.rid == 'linux-x64' || matrix.rid == 'linux-arm64' + env: + RELEASE_VERSION: ${{ needs.resolve-version.outputs.version }} + run: | + set -euo pipefail + rid="${{ matrix.rid }}" + ver="${RELEASE_VERSION%%-*}" + arch=x86_64 + [[ "$rid" == *arm64* ]] && arch=aarch64 + sudo apt-get update -qq + sudo apt-get install -y -qq rsync imagemagick ruby ruby-dev build-essential rpm squashfs-tools file || true + sudo gem install --no-document fpm || true + chmod +x tools/packaging/linux/build-appimage.sh tools/packaging/linux/build-deb-rpm.sh + # AppImage is required for glibc RIDs (fail hard — no soft-fail). + ./tools/packaging/linux/build-appimage.sh inspector "artifacts/inspector/$rid" \ + "TitaniumInspector-$rid.AppImage" "$ver" "$arch" + ./tools/packaging/linux/build-deb-rpm.sh inspector "artifacts/inspector/$rid" . "$ver" "$rid" \ + || echo "deb/rpm soft-failed" + - name: Stage Inspector artifacts + shell: bash + run: | + set -euo pipefail + rid="${{ matrix.rid }}" + mkdir -p "staged-inspector-$rid" + cp "TitaniumInspector-$rid.zip" "staged-inspector-$rid/" + for ext in msi AppImage deb rpm dmg; do + f="TitaniumInspector-$rid.$ext" + [[ -f "$f" ]] && cp "$f" "staged-inspector-$rid/" || true + done + ls -la "staged-inspector-$rid" - uses: actions/upload-artifact@v4 with: name: inspector-${{ matrix.rid }} if-no-files-found: error - path: | - TitaniumInspector-${{ matrix.rid }}.zip - - uses: actions/upload-artifact@v4 - if: matrix.msi - with: - name: inspector-${{ matrix.rid }}-msi - path: TitaniumInspector-${{ matrix.rid }}.msi + path: staged-inspector-${{ matrix.rid }}/ smoke-http3: needs: build-cli @@ -351,6 +523,8 @@ jobs: RELEASE_TAG: ${{ needs.resolve-version.outputs.release_tag }} RELEASE_VERSION: ${{ needs.resolve-version.outputs.version }} RELEASE_CHANNEL: ${{ needs.resolve-version.outputs.channel }} + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} run: | set -euo pipefail TAG="$RELEASE_TAG" @@ -358,7 +532,10 @@ jobs: CHANNEL="$RELEASE_CHANNEL" export RELEASE_TAG VERSION mkdir -p dist - find artifacts -type f \( -name '*.zip' -o -name '*.dll' -o -name '*.msi' \) -exec cp {} dist/ \; + find artifacts -type f \( \ + -name '*.zip' -o -name '*.dll' -o -name '*.msi' \ + -o -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' -o -name '*.dmg' \ + \) -exec cp {} dist/ \; python3 - <<'PY' import hashlib, json, os, pathlib, datetime tag = os.environ["RELEASE_TAG"] @@ -371,7 +548,8 @@ jobs: return h.hexdigest() assets = {} for p in dist.iterdir(): - assets[p.name] = {"path": str(p), "sha256": sha(p)} + if p.is_file(): + assets[p.name] = {"path": str(p), "sha256": sha(p)} manifest = { "version": version, "channel": channel, @@ -379,7 +557,7 @@ jobs: "abstractionsVersion": version.split("-")[0], "products": { "cli": {"assets": {}}, - "plus": {"requiredAbstractions": "7.0.0", "asset": None}, + "plus": {"version": version, "requiredAbstractions": "7.0.0", "asset": None}, "inspector": {"assets": {}}, }, "nuget": { @@ -388,22 +566,38 @@ jobs: "Titanium.Web.Proxy.Configuration": version.split("-")[0], }, } + def put(product, key, entry): + manifest["products"][product]["assets"][key] = entry for name, meta in assets.items(): url = f"https://github.com/{os.environ['GITHUB_REPOSITORY']}/releases/download/{tag}/{name}" entry = {"url": url, "sha256": meta["sha256"]} if name.startswith("Titanium.Cli-"): - rid = name.removeprefix("Titanium.Cli-").removesuffix(".zip") - manifest["products"]["cli"]["assets"][rid] = entry - elif name.startswith("TitaniumInspector-") and name.endswith(".msi"): - manifest["products"]["inspector"]["assets"]["win-x64-msi"] = entry + stem = name.removeprefix("Titanium.Cli-") + for ext in (".zip", ".AppImage", ".deb", ".rpm"): + if stem.endswith(ext): + rid = stem[: -len(ext)] + key = rid if ext == ".zip" else f"{rid}{ext}" + put("cli", key, entry) + break elif name.startswith("TitaniumInspector-"): - rid = name.removeprefix("TitaniumInspector-").removesuffix(".zip") - manifest["products"]["inspector"]["assets"][rid] = entry + stem = name.removeprefix("TitaniumInspector-") + if stem.endswith(".msi"): + put("inspector", "win-x64-msi", entry) + else: + for ext in (".zip", ".AppImage", ".deb", ".rpm", ".dmg"): + if stem.endswith(ext): + rid = stem[: -len(ext)] + key = rid if ext == ".zip" else f"{rid}{ext}" + put("inspector", key, entry) + break elif name.startswith("Titanium.Plus"): manifest["products"]["plus"]["asset"] = entry + manifest["products"]["plus"]["version"] = version pathlib.Path("dist/release-manifest.json").write_text(json.dumps(manifest, indent=2)) print(json.dumps(manifest, indent=2)) PY + chmod +x tools/packaging/sign-checksums.sh + ./tools/packaging/sign-checksums.sh dist git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git tag -f -a "$TAG" -m "$TAG" "$GITHUB_SHA" @@ -465,3 +659,69 @@ jobs: # blocked by environment protection rules). echo "Dispatching deploy-website.yml --ref develop (channel=$RELEASE_CHANNEL)" gh workflow run deploy-website.yml --ref develop + + publish-chocolatey: + needs: [resolve-version, publish-release] + if: needs.publish-release.result == 'success' + runs-on: windows-latest + permissions: + contents: read + steps: + - name: Skip when CHOCOLATEY_API_KEY unset + id: gate + env: + CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }} + shell: pwsh + run: | + if ([string]::IsNullOrWhiteSpace($env:CHOCOLATEY_API_KEY)) { + Write-Host "CHOCOLATEY_API_KEY not set; skipping chocolatey push." + "skip=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + } else { + "skip=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + } + + - uses: actions/checkout@v6 + if: steps.gate.outputs.skip != 'true' + + # Runs on every product release cut from beta/stable (via cut-product-tag → this workflow). + - name: Pack and push chocolatey packages + if: steps.gate.outputs.skip != 'true' + env: + CHOCOLATEY_API_KEY: ${{ secrets.CHOCOLATEY_API_KEY }} + RELEASE_TAG: ${{ needs.resolve-version.outputs.release_tag }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $tag = $env:RELEASE_TAG + if (-not (Test-Path ./tools/packaging/chocolatey/bump-packages.ps1)) { + Write-Host "Chocolatey stubs not on this ref; skipping push." + exit 0 + } + $cliAsset = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$tag/Titanium.Cli-win-x64.zip" + $msiAsset = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$tag/TitaniumInspector-win-x64.msi" + $ready = $false + foreach ($i in 1..12) { + try { + Invoke-WebRequest -Method Head -Uri $cliAsset -UseBasicParsing | Out-Null + Invoke-WebRequest -Method Head -Uri $msiAsset -UseBasicParsing | Out-Null + $ready = $true + break + } catch { + Write-Host "Waiting for release assets ($i/12)..." + Start-Sleep -Seconds 10 + } + } + if (-not $ready) { + Write-Host "Win-x64 CLI zip or Inspector MSI missing for $tag; skipping chocolatey push." + exit 0 + } + pwsh ./tools/packaging/chocolatey/bump-packages.ps1 -Tag $tag + Push-Location tools/packaging/chocolatey/titanium-cli + choco pack --output-directory . + Pop-Location + Push-Location tools/packaging/chocolatey/titanium-inspector + choco pack --output-directory . + Pop-Location + Get-ChildItem tools/packaging/chocolatey -Recurse -Filter *.nupkg | ForEach-Object { + choco push $_.FullName --source https://push.chocolatey.org/ --api-key $env:CHOCOLATEY_API_KEY + } diff --git a/.github/workflows/rps-saturation.yml b/.github/workflows/rps-saturation.yml index d3f9e3b2a..4c5b40667 100644 --- a/.github/workflows/rps-saturation.yml +++ b/.github/workflows/rps-saturation.yml @@ -29,10 +29,65 @@ on: - compare-same - compare-bridges - compare-http3-cleartext + - compare-nginx-https + - compare-haproxy-smoke + - compare-envoy-smoke - compare-mitm - compare-matrix - compare-product - compare-product-smoke + - haproxy-reverse-http1 + - haproxy-reverse-http1-tls + - haproxy-reverse-http1-to-https + - haproxy-reverse-http1-tls-to-https + - haproxy-reverse-http2 + - haproxy-reverse-http2-to-https-http1 + - haproxy-reverse-http3-cleartext + - haproxy-reverse-http3-to-https-http1 + - envoy-reverse-http1 + - envoy-reverse-http1-tls + - envoy-reverse-http1-to-https + - envoy-reverse-http1-tls-to-https + - envoy-reverse-http2 + - envoy-reverse-http2-to-https-http1 + - envoy-reverse-http3-cleartext + - envoy-reverse-http3-to-https-http1 + - nginx-reverse-h2c-to-h1 + - nginx-reverse-h2c-to-https + - haproxy-reverse-http1-plain-to-h2c + - haproxy-reverse-http1-plain-to-http2 + - haproxy-reverse-http1-plain-to-http3 + - haproxy-reverse-http1-to-h2c + - haproxy-reverse-http11-to-http2 + - haproxy-reverse-http1-to-http3 + - haproxy-reverse-h2c-to-h1 + - haproxy-reverse-h2c-to-https + - haproxy-reverse-h2c-to-h2c + - haproxy-reverse-h2c + - haproxy-reverse-h2c-to-h3 + - haproxy-reverse-http2-to-h2c + - haproxy-reverse-http2-to-https + - haproxy-reverse-http2-to-http3 + - haproxy-reverse-http3-to-h2c + - haproxy-reverse-http3-to-http2 + - haproxy-reverse-http3-to-http3 + - envoy-reverse-http1-plain-to-h2c + - envoy-reverse-http1-plain-to-http2 + - envoy-reverse-http1-plain-to-http3 + - envoy-reverse-http1-to-h2c + - envoy-reverse-http11-to-http2 + - envoy-reverse-http1-to-http3 + - envoy-reverse-h2c-to-h1 + - envoy-reverse-h2c-to-https + - envoy-reverse-h2c-to-h2c + - envoy-reverse-h2c + - envoy-reverse-h2c-to-h3 + - envoy-reverse-http2-to-h2c + - envoy-reverse-http2-to-https + - envoy-reverse-http2-to-http3 + - envoy-reverse-http3-to-h2c + - envoy-reverse-http3-to-http2 + - envoy-reverse-http3-to-http3 - compare-editions - compare-cross-version - compare-ceiling @@ -41,6 +96,7 @@ on: - compare-lossy - compare-tls-cost - compare-arch + - compare-grpc - compare-saturation - compare-spot - origin-direct @@ -53,6 +109,8 @@ on: - reverse-http1-tls - bare-reverse-http1-tls - nginx-reverse-http1-tls + - nginx-reverse-http1-to-https + - nginx-reverse-http1-tls-to-https - yarp-reverse-http1-tls - https-mitm - mitm-http2-to-http1 @@ -96,7 +154,9 @@ on: - yarp-reverse-http2-to-https-http1 - yarp-reverse-http3-to-https-http1 - nginx-reverse-http2 + - nginx-reverse-http2-to-https-http1 - nginx-reverse-http3-cleartext + - nginx-reverse-http3-to-https-http1 - yarp-reverse-http2 - yarp-reverse-http2-to-https - twp-cli-reverse-http1 @@ -131,6 +191,10 @@ on: - ubuntu-latest - windows-latest - macos-15-intel + arm_shard: + description: 'Comparison-group partition (all = no split; i/n = exclusive 1-based shard of wiki rows / Client×Origin wires, not individual arms)' + required: true + default: all permissions: contents: read @@ -146,6 +210,7 @@ jobs: RPS_WARMUP_SEC: ${{ github.event_name == 'workflow_dispatch' && inputs.warmup_sec || 2 }} RPS_DURATION_SEC: ${{ github.event_name == 'workflow_dispatch' && inputs.duration_sec || 8 }} RPS_REPEATS: ${{ github.event_name == 'workflow_dispatch' && inputs.repeats || 1 }} + RPS_ARM_SHARD: ${{ github.event_name == 'workflow_dispatch' && inputs.arm_shard || 'all' }} strategy: fail-fast: false matrix: @@ -153,10 +218,10 @@ jobs: os: ${{ fromJSON(inputs.runner_os == 'macos-15-intel' && '["macos-15-intel"]' || inputs.runner_os == 'ubuntu-latest' && '["ubuntu-latest"]' || inputs.runner_os == 'windows-latest' && '["windows-latest"]' || '["ubuntu-latest","windows-latest","macos-15-intel"]') }} runs-on: ${{ matrix.os }} # Intentionally no jobs.*.container — saturation RPS on a container network measures the wrong thing. - # compare-product with MITM Lite+Full can exceed 3.5h per OS on hosted runners. - # compare-editions (expanded Plus/CLI stress arms) needs ~60m; other modes keep the long ceiling. - # compare-product-smoke is a short gate-arm subset (Mac quick check). - timeout-minutes: ${{ github.event_name == 'pull_request' && 45 || (github.event_name == 'push' && 90 || (inputs.mode == 'compare-editions' && 90 || (inputs.mode == 'compare-product-smoke' && 60 || 420))) }} + # GitHub-hosted runners hard-cap at 360 minutes — do not set timeout above that. + # Wiki-grade compare-product / bodies / arch use arm_shard i/n so each job stays under 345m ramp. + # compare-product-smoke / haproxy-smoke / envoy-smoke: 120m; compare-editions: 90m. + timeout-minutes: ${{ github.event_name == 'pull_request' && 45 || (github.event_name == 'push' && 90 || (inputs.mode == 'compare-editions' && 90 || ((inputs.mode == 'compare-product-smoke' || inputs.mode == 'compare-haproxy-smoke' || inputs.mode == 'compare-envoy-smoke') && 120 || 360))) }} steps: - uses: actions/checkout@v6 @@ -219,7 +284,14 @@ jobs: run: | set -euo pipefail . /etc/os-release - sudo apt-get update + # Hosted runners sometimes ship a broken Google Chrome apt list (hash mismatch). + # Remove any Chrome/Google Chrome apt entries so apt-get update cannot fail the RPS job. + sudo find /etc/apt -type f \( -iname '*chrome*' -o -iname '*google*chrome*' \) -print -delete || true + if [ -f /etc/apt/sources.list ]; then + sudo sed -i '/dl\.google\.com\/linux\/chrome/d' /etc/apt/sources.list || true + fi + # Tolerate residual third-party mirror failures; nginx.org install below is the real gate. + sudo apt-get update || true sudo apt-get install -y ca-certificates curl gnupg curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ https://nginx.org/keys/nginx_signing.key \ @@ -229,7 +301,9 @@ jobs: | sudo tee /etc/apt/sources.list.d/nginx.list printf 'Package: *\nPin: origin nginx.org\nPin-Priority: 900\n' \ | sudo tee /etc/apt/preferences.d/99nginx - sudo apt-get update + # Re-disable Chrome before the second update (some images re-add it). + sudo find /etc/apt -type f \( -iname '*chrome*' -o -iname '*google*chrome*' \) -print -delete || true + sudo apt-get update || true sudo apt-get install -y nginx # Stop the distro/package default site so our temp-prefix nginx owns the ports we pick. sudo systemctl stop nginx || true @@ -298,6 +372,312 @@ jobs: fi nginx -V 2>&1 | tr ' ' '\n' | grep http_v3 || true + # Ubuntu distro HAProxy is not USE_QUIC. GHA Linux builds 3.2 against QuicTLS + # (openssl-3.1.7+quic) so MsQuic clients can complete the handshake — OpenSSL + # USE_QUIC_OPENSSL_COMPAT + limited-quic starts but fails QUIC_STATUS_TLS_ERROR. + # macOS uses Homebrew (typically native USE_QUIC) with a 3.2 osx source fallback. + - name: Cache HAProxy QUIC prefix + if: runner.os == 'Linux' + uses: actions/cache@v4 + with: + path: | + ${{ runner.temp }}/haproxy-quic + ${{ runner.temp }}/quictls + key: haproxy-3.2.23-quictls-3.1.7-linux-x64 + + - name: Install HAProxy with QUIC (Linux) + if: runner.os == 'Linux' + env: + HAPROXY_VERSION: '3.2.23' + HAPROXY_SHA256: '82d14ef33571e4edeb9197516c0d058a3775fb80541e46afe4377428e461fef0' + QUICTLS_REF: 'openssl-3.1.7+quic' + run: | + set -euo pipefail + sudo find /etc/apt -type f \( -iname '*chrome*' -o -iname '*google*chrome*' \) -print -delete || true + sudo apt-get update || true + sudo apt-get install -y gcc make perl libpcre2-dev zlib1g-dev git + # Stop any distro service so our temp-prefix HAProxy owns the ports we pick. + sudo apt-get install -y haproxy || true + sudo systemctl stop haproxy || true + dest="${RUNNER_TEMP}/haproxy-quic" + qtls="${RUNNER_TEMP}/quictls" + mkdir -p "$dest" "$qtls" + # Do not use grep -q under pipefail: early close SIGPIPEs haproxy -vv. + have_quic() { case "$("$1" -vv 2>&1 || true)" in *USE_QUIC*) return 0 ;; *) return 1 ;; esac; } + have_native_quic() { + # OpenSSL-compat builds list USE_QUIC_OPENSSL_COMPAT and need limited-quic; + # MsQuic fails TLS against that stack. Prefer a QuicTLS-linked binary. + local vv + vv="$("$1" -vv 2>&1 || true)" + case "$vv" in *USE_QUIC_OPENSSL_COMPAT*) return 1 ;; esac + case "$vv" in *USE_QUIC*) return 0 ;; *) return 1 ;; esac + } + if [ -x "$dest/sbin/haproxy" ] && have_native_quic "$dest/sbin/haproxy"; then + echo "Using existing QuicTLS-linked HAProxy at $dest" + else + if [ ! -x "$qtls/bin/openssl" ]; then + echo "Building QuicTLS ${QUICTLS_REF}…" + src_qtls="${RUNNER_TEMP}/quictls-src" + rm -rf "$src_qtls" + git clone --depth 1 --branch "$QUICTLS_REF" https://github.com/quictls/openssl.git "$src_qtls" + ( + cd "$src_qtls" + ./config --prefix="$qtls" --libdir=lib + make -j "$(nproc)" + make install_sw + ) + else + echo "Using cached QuicTLS at $qtls" + fi + ver="$HAPROXY_VERSION" + src="${RUNNER_TEMP}/haproxy-src" + rm -rf "$src" + mkdir -p "$src" + tarball="${RUNNER_TEMP}/haproxy-${ver}.tar.gz" + curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + "https://www.haproxy.org/download/3.2/src/haproxy-${ver}.tar.gz" \ + -o "$tarball" + echo "${HAPROXY_SHA256} ${tarball}" | sha256sum -c - + tar -xzf "$tarball" -C "$src" --strip-components=1 + make -C "$src" -j "$(nproc)" \ + TARGET=linux-glibc \ + USE_OPENSSL=1 \ + USE_QUIC=1 \ + USE_PCRE2=1 \ + USE_PCRE2_JIT=1 \ + USE_ZLIB=1 \ + SSL_INC="${qtls}/include" \ + SSL_LIB="${qtls}/lib" \ + LDFLAGS="-Wl,-rpath,${qtls}/lib" + mkdir -p "$dest/sbin" + cp -f "$src/haproxy" "$dest/sbin/haproxy" + chmod +x "$dest/sbin/haproxy" + fi + echo "$dest/sbin" >> "$GITHUB_PATH" + export PATH="$dest/sbin:$PATH" + # QuicTLS libs for runtime if rpath is missing on older caches. + echo "LD_LIBRARY_PATH=${qtls}/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" >> "$GITHUB_ENV" + export LD_LIBRARY_PATH="${qtls}/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + haproxy -v + haproxy -vv 2>&1 | head -n 40 || true + if ! have_native_quic haproxy; then + echo "HAProxy lacks native USE_QUIC (QuicTLS); HTTP/3 HAProxy arms cannot run reliably with MsQuic." >&2 + exit 1 + fi + echo "HAProxy native USE_QUIC (QuicTLS) present" + + - name: Cache HAProxy QUIC prefix (macOS) + if: runner.os == 'macOS' + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/haproxy-quic + key: haproxy-3.2.23-quic-osx-x64 + + - name: Install HAProxy with QUIC (macOS) + if: runner.os == 'macOS' + env: + HOMEBREW_NO_AUTO_UPDATE: '1' + HOMEBREW_NO_INSTALL_UPGRADE: '1' + HAPROXY_VERSION: '3.2.23' + HAPROXY_SHA256: '82d14ef33571e4edeb9197516c0d058a3775fb80541e46afe4377428e461fef0' + run: | + set -euo pipefail + if ! brew list --versions haproxy >/dev/null 2>&1; then + brew install haproxy || echo "brew install haproxy failed; will build ${HAPROXY_VERSION} from source with USE_QUIC" >&2 + fi + brew services stop haproxy 2>/dev/null || true + pkill -x haproxy 2>/dev/null || true + if brew list --versions haproxy >/dev/null 2>&1; then + HAP_BIN="$(brew --prefix haproxy)/bin" + echo "$HAP_BIN" >> "$GITHUB_PATH" + export PATH="$HAP_BIN:$PATH" + fi + + have_quic() { + command -v haproxy >/dev/null 2>&1 || return 1 + # Do not use grep -q under pipefail: BSD grep -q closes the pipe early (SIGPIPE). + case "$(haproxy -vv 2>&1 || true)" in + *USE_QUIC*) return 0 ;; + *) return 1 ;; + esac + } + + if ! have_quic; then + echo "Homebrew HAProxy lacks USE_QUIC; building ${HAPROXY_VERSION} with USE_QUIC (TARGET=osx)…" >&2 + dest="${RUNNER_TEMP}/haproxy-quic" + mkdir -p "$dest/sbin" + if [ -x "$dest/sbin/haproxy" ] && case "$("$dest/sbin/haproxy" -vv 2>&1 || true)" in *USE_QUIC*) true ;; *) false ;; esac; then + echo "Using cached HAProxy QUIC build at $dest" + else + brew list --versions openssl@3 || brew install openssl@3 + brew list --versions pcre2 || brew install pcre2 + ssl="$(brew --prefix openssl@3)" + pcre="$(brew --prefix pcre2)" + src="${RUNNER_TEMP}/haproxy-src" + mkdir -p "$src" + tarball="${RUNNER_TEMP}/haproxy-${HAPROXY_VERSION}.tar.gz" + curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + "https://www.haproxy.org/download/3.2/src/haproxy-${HAPROXY_VERSION}.tar.gz" \ + -o "$tarball" + echo "${HAPROXY_SHA256} ${tarball}" | shasum -a 256 -c - + tar -xzf "$tarball" -C "$src" --strip-components=1 + make -C "$src" -j "$(sysctl -n hw.ncpu)" \ + TARGET=osx \ + USE_OPENSSL=1 \ + USE_QUIC=1 \ + USE_PCRE2=1 \ + USE_PCRE2_JIT=1 \ + USE_ZLIB=1 \ + SSL_INC="${ssl}/include" \ + SSL_LIB="${ssl}/lib" \ + PCRE2_INC="${pcre}/include" \ + PCRE2_LIB="${pcre}/lib" \ + LDFLAGS="-Wl,-rpath,${ssl}/lib" \ + || make -C "$src" -j "$(sysctl -n hw.ncpu)" \ + TARGET=osx \ + USE_OPENSSL=1 \ + USE_QUIC=1 \ + USE_QUIC_OPENSSL_COMPAT=1 \ + USE_PCRE2=1 \ + USE_PCRE2_JIT=1 \ + USE_ZLIB=1 \ + SSL_INC="${ssl}/include" \ + SSL_LIB="${ssl}/lib" \ + PCRE2_INC="${pcre}/include" \ + PCRE2_LIB="${pcre}/lib" \ + LDFLAGS="-Wl,-rpath,${ssl}/lib" + cp -f "$src/haproxy" "$dest/sbin/haproxy" + chmod +x "$dest/sbin/haproxy" + fi + echo "$dest/sbin" >> "$GITHUB_PATH" + export PATH="$dest/sbin:$PATH" + fi + + haproxy -v + if ! have_quic; then + echo "HAProxy was installed but lacks USE_QUIC; HTTP/3 HAProxy arms cannot run." >&2 + haproxy -vv 2>&1 | head -n 40 || true + exit 1 + fi + echo "HAProxy USE_QUIC present" + + # Envoy Apache 2.0 — Linux static binary from GitHub releases; macOS via Homebrew + # (HTTP/3 compiled in). Official GitHub assets are Linux-only; Intel Mac has no + # current Homebrew bottle, so fall back to the pinned Kuma/CNCF darwin-amd64 build + # of the same 1.36.7 tag (do not bazel-build Envoy on the runner). + # Windows support discontinued — skip (cells are Not possible). + - name: Install Envoy (Linux) + if: runner.os == 'Linux' + env: + ENVOY_VERSION: '1.36.7' + run: | + set -euo pipefail + ver="$ENVOY_VERSION" + dest="${RUNNER_TEMP}/envoy" + mkdir -p "$dest" + curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + "https://github.com/envoyproxy/envoy/releases/download/v${ver}/envoy-${ver}-linux-x86_64" \ + -o "$dest/envoy" + chmod +x "$dest/envoy" + echo "$dest" >> "$GITHUB_PATH" + export PATH="$dest:$PATH" + envoy --version + echo "Envoy official binaries include HTTP/3; H3 arms run when QuicListener is supported." + + - name: Install Envoy (macOS) + if: runner.os == 'macOS' + env: + HOMEBREW_NO_AUTO_UPDATE: '1' + HOMEBREW_NO_INSTALL_UPGRADE: '1' + ENVOY_VERSION: '1.36.7' + run: | + set -euo pipefail + brew services stop envoy 2>/dev/null || true + pkill -x envoy 2>/dev/null || true + + # Pour a bottle only. Intel Homebrew has no envoy bottle; bazel-from-source + # would burn the job timeout. ARM bottles (macos-latest) still pour here. + brew_has_envoy_bottle() { + python3 -c ' + import json, platform, subprocess, sys + formula = sys.argv[1] + try: + raw = subprocess.check_output(["brew", "info", "--json=v2", formula], stderr=subprocess.DEVNULL) + except subprocess.CalledProcessError: + raise SystemExit(1) + formulae = json.loads(raw).get("formulae") or [] + if not formulae: + raise SystemExit(1) + files = ((formulae[0].get("bottle") or {}).get("stable") or {}).get("files") or {} + intel = platform.machine().lower() in ("x86_64", "amd64", "i386") + for tag in files: + t = tag.lower() + if "linux" in t: + continue + if intel and t.startswith("arm64"): + continue + if (not intel) and not t.startswith("arm64"): + continue + raise SystemExit(0) + raise SystemExit(1) + ' "$1" + } + + if ! brew list --versions envoy >/dev/null 2>&1 && ! brew list --versions envoyproxy/tap/envoy >/dev/null 2>&1; then + if brew_has_envoy_bottle envoy; then + brew install envoy + elif brew_has_envoy_bottle envoyproxy/tap/envoy; then + brew install envoyproxy/tap/envoy + else + echo "No Homebrew Envoy bottle for this Mac; skipping brew install (would compile from source)." >&2 + fi + fi + + if command -v brew >/dev/null 2>&1; then + for formula in envoy envoyproxy/tap/envoy; do + prefix="$(brew --prefix "$formula" 2>/dev/null || true)" + if [ -n "$prefix" ] && [ -x "$prefix/bin/envoy" ]; then + echo "$prefix/bin" >> "$GITHUB_PATH" + export PATH="$prefix/bin:$PATH" + break + fi + done + fi + + if ! command -v envoy >/dev/null 2>&1; then + echo "Downloading pinned darwin-amd64 Envoy ${ENVOY_VERSION} (Kuma/CNCF builds; official GitHub assets are Linux-only)…" >&2 + dest="${RUNNER_TEMP}/envoy" + mkdir -p "$dest" + tarball="${RUNNER_TEMP}/envoy-darwin-amd64.tar.gz" + if ! curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \ + "https://github.com/kumahq/envoy-builds/releases/download/v${ENVOY_VERSION}/envoy-darwin-amd64-v${ENVOY_VERSION}.tar.gz" \ + -o "$tarball"; then + echo "darwin-amd64 Envoy download failed — Envoy arms will be skipped." >&2 + exit 0 + fi + tar -xzf "$tarball" -C "$dest" + bin="" + if [ -x "$dest/envoy" ]; then + bin="$dest/envoy" + else + bin="$(find "$dest" -type f -name envoy | awk 'NR==1 { print; exit }')" + fi + if [ -z "${bin}" ] || [ ! -f "$bin" ]; then + echo "darwin-amd64 Envoy tarball had no envoy binary — Envoy arms will be skipped." >&2 + exit 0 + fi + chmod +x "$bin" + echo "$(dirname "$bin")" >> "$GITHUB_PATH" + export PATH="$(dirname "$bin"):$PATH" + fi + + if ! command -v envoy >/dev/null 2>&1; then + echo "envoy not on PATH after install attempt — Envoy arms will be skipped." >&2 + exit 0 + fi + envoy --version + echo "Envoy present (Homebrew or pinned darwin-amd64); H3 arms run when QuicListener is supported." + # .NET System.Net.Quic on Linux requires native libmsquic (shipped in-box on Windows). - name: Install libmsquic (HTTP/3) if: runner.os == 'Linux' @@ -309,7 +689,8 @@ jobs: -o packages-microsoft-prod.deb sudo dpkg -i packages-microsoft-prod.deb rm -f packages-microsoft-prod.deb - sudo apt-get update + sudo find /etc/apt -type f \( -iname '*chrome*' -o -iname '*google*chrome*' \) -print -delete || true + sudo apt-get update || true sudo apt-get install -y libmsquic pwsh -NoProfile -Command 'if (-not [System.Net.Quic.QuicListener]::IsSupported) { throw "QuicListener.IsSupported is false after libmsquic install" }; Write-Host "QuicListener.IsSupported=$([System.Net.Quic.QuicListener]::IsSupported)"' @@ -438,7 +819,7 @@ jobs: # Pass workflow_dispatch inputs via env (not ${{ }} in the script) to avoid # githubactions:S7630 script-injection findings on user-controlled values. - name: Run saturation ramp - timeout-minutes: ${{ github.event_name == 'pull_request' && 40 || (github.event_name == 'push' && 60 || (inputs.mode == 'compare-editions' && 60 || (inputs.mode == 'compare-product-smoke' && 50 || 400))) }} + timeout-minutes: ${{ github.event_name == 'pull_request' && 40 || (github.event_name == 'push' && 60 || (inputs.mode == 'compare-editions' && 60 || ((inputs.mode == 'compare-product-smoke' || inputs.mode == 'compare-haproxy-smoke' || inputs.mode == 'compare-envoy-smoke') && 110 || 345))) }} shell: pwsh env: RPS_MODE: ${{ env.RPS_MODE }} @@ -446,16 +827,36 @@ jobs: RPS_WARMUP_SEC: ${{ env.RPS_WARMUP_SEC }} RPS_DURATION_SEC: ${{ env.RPS_DURATION_SEC }} RPS_REPEATS: ${{ env.RPS_REPEATS }} + RPS_ARM_SHARD: ${{ env.RPS_ARM_SHARD }} run: | + $hap = Join-Path $env:RUNNER_TEMP 'haproxy-quic/sbin/haproxy' + $hapArgs = @() + if ((Test-Path -LiteralPath $hap)) { + Write-Host "Pinning HAProxy QUIC binary: $hap" + $hapArgs = @('-HaproxyPath', $hap) + } pwsh tools/RpsLoadProbe/run-rps.ps1 ` -Mode $env:RPS_MODE ` -Concurrency $env:RPS_CONCURRENCY ` -WarmupSec ([int]$env:RPS_WARMUP_SEC) ` -DurationSec ([int]$env:RPS_DURATION_SEC) ` -Repeats ([int]$env:RPS_REPEATS) ` + -ArmShard $env:RPS_ARM_SHARD ` -ResultsDir tools/RpsLoadProbe/results ` - -BombardierCheck + -BombardierCheck ` + @hapArgs + - name: Sanitize artifact shard label + if: always() + shell: bash + run: | + # macOS runners ship Bash 3.2; ${var//'/'/-} treats quotes as literal and leaves '/'. + raw="${RPS_ARM_SHARD:-all}" + echo "RPS_ARTIFACT_SHARD=$(printf '%s' "$raw" | tr '/' '-')" >> "$GITHUB_ENV" + + # Post-ramp gates: hard-fail on every event (including workflow_dispatch). + # fail-fast: false keeps sibling OS jobs running; Upload CSV uses if: always(). + # Sharded CSVs skip pairs whose arms are not in this artifact (union paste validates completeness). - name: Validate edition gates if: env.RPS_MODE == 'compare-editions' shell: pwsh @@ -472,17 +873,8 @@ jobs: $csv = Get-ChildItem tools/RpsLoadProbe/results -Filter 'rps-ramp-*.csv' | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if (-not $csv) { throw 'No CSV found for compare-product gate validation' } - # macos-15-intel first-baseline floors (PERF-GATES.md) — Win/Linux keep defaults. - $macFloors = @() - if ('${{ matrix.os }}' -eq 'macos-15-intel') { - $macFloors = @( - '-MitmHttp3TlsFullGate', '0.65', - '-MitmHttp1PlainFullGate', '0.55', - '-ReverseYarpHttp3ToHttp1Gate', '0.55', - '-ReverseYarpHttp3Gate', '0.74' - ) - } - pwsh tools/RpsLoadProbe/validate-compare-product-gates.ps1 -CsvPath $csv.FullName @macFloors + # Same floors on all OS: MITM Lite÷Reverse >= 0.50, Full÷Reverse >= 0.50, reverse TWP÷YARP >= 0.70. No nginx gate. + pwsh tools/RpsLoadProbe/validate-compare-product-gates.ps1 -CsvPath $csv.FullName - name: Validate cross-version gates if: env.RPS_MODE == 'compare-cross-version' @@ -505,6 +897,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: rps-csv-${{ matrix.os }} + # Include arm_shard so parallel shard dispatches do not collide (slash → dash via env sanitize). + name: rps-csv-${{ matrix.os }}-shard-${{ env.RPS_ARTIFACT_SHARD }} path: tools/RpsLoadProbe/results/rps-ramp-*.csv if-no-files-found: error diff --git a/.gitignore b/.gitignore index 44a04bfb6..f59d4ca55 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. +# Local Cursor agent rules / config (not shared) +.cursor/ + # User-specific files *.suo *.user @@ -220,11 +223,27 @@ BenchmarkDotNet.Artifacts/ # Cold-start A/B run output (these harnesses are run manually, not in CI) tools/ColdStartProbe/results/ tools/ChromeLoadProbe/results/ +tools/InspectorDesktopProbe/results/ +tools/CliQaProbe/results/ # Local RPS / protocol probe scratch (never commit) .tmp-rps/ tmp/ +# Python bytecode (RpsLoadProbe chart / matrix scripts) +__pycache__/ +*.py[cod] + +# Local trust/MITM validation screenshots (manual / agent runs) +.validation-screenshots/ + +# Local one-off probes (not part of the product tree) +tools/_H3UpgradeProbeLocal/ + +# Regenerated on macOS build with absolute DYLD_FALLBACK_LIBRARY_PATH for local HTTP/3 +src/Titanium.Inspector/Properties/launchSettings.json +src/Titanium.Cli/Properties/launchSettings.json + # VitePress site (website/) website/.vitepress/dist/ website/.vitepress/cache/ @@ -234,3 +253,6 @@ tools/packaging/.cache/ # WiX extension cache from `dotnet wix extension add` tools/packaging/wix/.wix/ + +# Local Apple signing material (never commit) +tools/packaging/osx/.local-signing/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c315976a2..83c50f78e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,15 @@ See [CLA.md](CLA.md) and [README.md](README.md) Editions section. 4. Do not introduce third-party product names of other proxies or traffic debuggers into source, tests, CLI help, or docs. 5. Keep hot-path changes minimal; preserve `ForwardHost` terminate-lite eligibility when routes are unset or equivalent to a single sticky destination. +### Local QA tooling (maintainers) + +Not for end users. Per-OS checklists and on-demand probes live under `tools/`: + +- [LOCAL-QA.md](tools/LOCAL-QA.md) — solo checklist (E2E + probes) +- [CliQaProbe](tools/CliQaProbe) — CLI help / dialects / live `run` / optional OS service +- [InspectorDesktopProbe](tools/InspectorDesktopProbe) — system proxy / CA / browser UX +- [RpsLoadProbe](tools/RpsLoadProbe) — concurrent breaking-point load in requests per second (RPS) + ## PR checklist Use the pull request template. Include tests for behavior changes. Do not weaken performance gates or skip PublicAPI analyzer updates for intentional API surface changes. diff --git a/README.md b/README.md index 3cb442dc2..f8586664f 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,47 @@ # Titanium Web Proxy -A lightweight, high-performance HTTP(S) proxy — reverse / edge CLI, desktop Inspector, and optional Plus ops on Windows, Linux, and macOS. Embed the same engine in .NET via NuGet when you need a library. +A lightweight, high-performance HTTP(S) proxy for Windows, Linux, and macOS. **[Website](https://titaniumproxy.com)** · [Download](https://titaniumproxy.com/download) · [Docs](https://titaniumproxy.com/docs/getting-started) · [Releases](https://titaniumproxy.com/releases) +| Product | Best for | +|---------|----------| +| **Titanium.Cli** (`titanium` / `twp`)
[Download](https://titaniumproxy.com/download#cli) | Standalone reverse / edge proxy from the command line | +| **Titanium Inspector**
[Download](https://titaniumproxy.com/download#inspector) | Desktop MITM debugger — session grid, inspectors, AutoResponder, breakpoints, HAR export | +| **Titanium.Plus**
`titanium update --plus` (after installing CLI) | Optional ops: control plane, dashboard, observability | +| **Titanium.Web.Proxy**
[NuGet](https://www.nuget.org/packages/Titanium.Web.Proxy) | Embed a proxy (MITM and/or reverse) in a .NET app | + +Requires .NET 10 or later for the library. CLI and Inspector downloads are self-contained (no SDK needed to run them). + [![Build](https://github.com/justcoding121/titanium-web-proxy/actions/workflows/dotnetcore.yml/badge.svg?branch=develop)](https://github.com/justcoding121/titanium-web-proxy/actions/workflows/dotnetcore.yml) [![NuGet](https://img.shields.io/nuget/v/Titanium.Web.Proxy.svg)](https://www.nuget.org/packages/Titanium.Web.Proxy) [![NuGet downloads](https://img.shields.io/nuget/dt/Titanium.Web.Proxy.svg)](https://www.nuget.org/packages/Titanium.Web.Proxy) -## Code Quality - -[![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=alert_status)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) -[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=coverage)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) -[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=ncloc)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) -[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=bugs)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) -[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) -[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=code_smells)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) -[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=security_rating)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) -[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=reliability_rating)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) -[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=sqale_rating)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) -[![Duplicated Lines](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=duplicated_lines_density)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) -[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=sqale_index)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) - -## Features +## What you can do -- Intercept, inspect, modify, redirect, or block HTTP and HTTPS traffic -- Explicit, transparent, and SOCKS4/5 proxy endpoints -- Request and response body streaming across HTTP/1.x (plain and TLS), HTTP/2, and HTTP/3 (see the [protocol support matrix](https://github.com/justcoding121/titanium-web-proxy/wiki/Protocol-Support)) -- HTTP/2 support, on by default, opt-out via `ProxyServer.EnableHttp2` (see the [protocol support matrix](https://github.com/justcoding121/titanium-web-proxy/wiki/Protocol-Support) for exact coverage) -- HTTP/3 (QUIC) support, opt-in via `ProxyServer.EnableHttp3 = true` (requires MsQuic; CLI/Inspector Release zips bundle natives per RID — see the [HTTP/3 wiki](https://github.com/justcoding121/titanium-web-proxy/wiki/HTTP-3) for packaging and the [protocol bridge matrix](https://github.com/justcoding121/titanium-web-proxy/wiki/Protocol-Support#protocol-bridges) for every client→origin direction) -- Upstream HTTP, HTTPS, and SOCKS proxies with automatic system proxy detection -- Proxy authentication, mutual TLS, Kerberos, and NTLM support -- Connection, certificate, and buffer pooling -- Built-in, zero-overhead-when-disabled logging (every caught exception, optionally to console/file or your own `ILoggerFactory`) and opt-in structured request/connection timing. See [Logging and diagnostics](https://github.com/justcoding121/titanium-web-proxy/wiki/Home#logging-and-diagnostics) in the wiki. +- Run a reverse / edge proxy in front of any backend, or inspect and modify HTTP(S) traffic in the desktop Inspector +- Explicit, transparent, and SOCKS4/5 endpoints; decrypt HTTPS when you trust a local root certificate +- Stream bodies across HTTP/1.x, HTTP/2, and HTTP/3; upstream proxies, auth, and mutual TLS -## Editions +Protocol coverage: [protocol support matrix](https://github.com/justcoding121/titanium-web-proxy/wiki/Protocol-Support). HTTP/3 packaging: [HTTP/3 wiki](https://github.com/justcoding121/titanium-web-proxy/wiki/HTTP-3). -| Product | What it is | How you get it | -|---------|------------|----------------| -| **Titanium.Cli** (`titanium` / `twp`) | Standalone reverse / edge proxy for any stack: `run`, `test`, `version`, `update` | [Download (Windows, Linux & Mac)](https://titaniumproxy.com/download#cli) | -| **Titanium Inspector** | Desktop MITM debugger (session grid, inspectors, AutoResponder, breakpoints, HAR) | [Download (Windows, Linux & Mac)](https://titaniumproxy.com/download#inspector) | -| **Titanium.Plus** | Optional advanced features: control plane, ops, observability, and dashboard | After installing CLI, run `titanium update --plus` | -| **Titanium.Web.Proxy** | Core library. Embed a MITM and/or reverse proxy in a .NET app | [NuGet](https://www.nuget.org/packages/Titanium.Web.Proxy/7.0.4-beta) (`dotnet add package Titanium.Web.Proxy --prerelease`) | +## Performance -CLI and Plus target reverse-proxy / edge workloads (routing, load balancing, health, discovery) on Windows, Linux, and macOS. Inspector is the MITM debugging product. The Core library is the embed path for .NET. Requires .NET 10 or later. +Tiny keep-alive GET (~56 B) on common reverse wires, plus WebSocket and unary gRPC: -## Installation +![Practical reverse proxy throughput on Linux (tiny requests)](wiki/images/rps-practical-linux.png) -Install the stable package from [NuGet](https://www.nuget.org/packages/Titanium.Web.Proxy): +Heavier reverse load (64 KB GET/POST on typical wires): -```shell -dotnet add package Titanium.Web.Proxy -``` +![Practical reverse proxy throughput on Linux (64 KB)](wiki/images/rps-practical-heavier-linux.png) -To use the latest prerelease: +**RPS** is requests per second (gRPC bars are **RPC/s**). Compared on the same harness vs **YARP**, **nginx**, **HAProxy**, and **Envoy**. See [Performance](https://github.com/justcoding121/titanium-web-proxy/wiki/Performance). -```shell -dotnet add package Titanium.Web.Proxy --prerelease -``` +## Installation -### CLI (`titanium` / `twp`) +**Inspector and CLI** (Windows, Linux, macOS): use [Download](https://titaniumproxy.com/download) — MSI / DMG / AppImage / `.deb` / `.rpm` / zip. Short walkthrough: [Install](https://titaniumproxy.com/docs/install). -On Windows, **winget is stable-only**: - -```shell -winget install justcoding121.TitaniumCli -``` - -For **beta**, download self-contained zips from [Download](https://titaniumproxy.com/download) / [GitHub Releases](https://github.com/justcoding121/titanium-web-proxy/releases) when a product release includes `Titanium.Cli-*.zip` assets (e.g. `v7.0.4-beta`). Extract and run: +After the CLI is on your PATH (or in the current folder): ```shell titanium run -c twp.yaml @@ -76,17 +50,23 @@ titanium version --check titanium update ``` -Each CLI zip also includes a `twp` alias binary. +`titanium run` is foreground (Ctrl+C to stop). Boot persistence: `titanium service install -c twp.yaml` — [CLI — service](https://titaniumproxy.com/docs/cli#service). Each CLI zip also includes a `twp` alias. -Optional Plus: run `titanium update --plus` (add `--channel beta` for prereleases), then enable Plus in config (`plus.enabled: true` with `plus.controlPlane.sharedSecret`). Check with `titanium version --check --plus`. +**Optional Plus:** `titanium update --plus`, then `plus.enabled: true` — [Plus](https://titaniumproxy.com/docs/plus). -### Titanium Inspector +Titanium Inspector screenshot -Prefer [Download](https://titaniumproxy.com/download). On Windows, winget id `justcoding121.TitaniumInspector` is **stable-only**; MSI / portable zip for beta come from the product `v*` release (e.g. `v7.0.4-beta`). Start interception from the Capture menu, install the root CA, then toggle system proxy. +### Library (.NET) -## Quick start +```shell +dotnet add package Titanium.Web.Proxy +# Prerelease / beta: +dotnet add package Titanium.Web.Proxy --prerelease +``` + +## Quick start (library) -The following example starts an explicit HTTP(S) proxy on `127.0.0.1:8000` and logs each requested URL: +Explicit HTTP(S) proxy on `127.0.0.1:8000` that logs each requested URL: ```csharp using System; @@ -99,10 +79,7 @@ using Titanium.Web.Proxy.Models; using var proxyServer = new ProxyServer(); -// Built-in console sink is a bounded channel + background writer, so LogInformation -// never blocks a session thread on Console I/O. proxyServer.Logging.MinimumLevel = LogLevel.Information; - proxyServer.BeforeRequest += OnRequest; var endPoint = new ExplicitProxyEndPoint(IPAddress.Loopback, 8000, decryptSsl: true); @@ -125,49 +102,43 @@ Task OnRequest(object sender, SessionEventArgs e) } ``` -Configure your client to use `127.0.0.1:8000` as its HTTP and HTTPS proxy. Trusting a generated root certificate changes the current user's certificate store; only do this on a machine you control. - -## Performance +Basic console proxy screenshot -Typically at or above **YARP**; ahead of **nginx** on H2/H3→H1 reverse, near parity for the rest (nginx still edges tiny keep-alive). Details: [Performance](https://github.com/justcoding121/titanium-web-proxy/wiki/Performance). +Point your client at `127.0.0.1:8000` as its HTTP and HTTPS proxy. Trusting a generated root certificate changes the current user's certificate store — only do this on a machine you control. ## Examples and documentation -- **[Website](https://titaniumproxy.com)**: product docs, [download](https://titaniumproxy.com/download), and [release notes](https://titaniumproxy.com/releases) -- [Wiki](https://github.com/justcoding121/titanium-web-proxy/wiki): additional feature guides (also mirrored on the website), including [performance measurements](https://github.com/justcoding121/titanium-web-proxy/wiki/Performance), [streaming request/response bodies](https://github.com/justcoding121/titanium-web-proxy/wiki/Streaming-Bodies), the [HTTP/3 setup guide](https://github.com/justcoding121/titanium-web-proxy/wiki/HTTP-3), and a [protocol feature support matrix](https://github.com/justcoding121/titanium-web-proxy/wiki/Protocol-Support) (what's supported for HTTP/1.x, HTTP/2, and HTTP/3, including [protocol bridges](https://github.com/justcoding121/titanium-web-proxy/wiki/Protocol-Support#protocol-bridges)) +- **[Website](https://titaniumproxy.com)** — product docs, [download](https://titaniumproxy.com/download), [getting started](https://titaniumproxy.com/docs/getting-started), [release notes](https://titaniumproxy.com/releases) +- **[Wiki](https://github.com/justcoding121/titanium-web-proxy/wiki)** — deeper guides (performance, streaming bodies, HTTP/3, protocol support) - [Basic console proxy](examples/Titanium.Web.Proxy.Examples.Basic) -- [WPF proxy application](examples/Titanium.Web.Proxy.Examples.Wpf) -- [Windows service](examples/Titanium.Web.Proxy.Examples.WindowsService) -- [Benchmarks](benchmarks/Titanium.Web.Proxy.Benchmarks): loopback throughput and allocation (BenchmarkDotNet) -- [RPS saturation probe](tools/RpsLoadProbe): concurrent breaking-point RPS -- [API documentation](https://titaniumproxy.com/api/Titanium.Web.Proxy.ProxyServer.html) - -### Screenshots - -**Titanium Inspector:** session grid with request/response details: - -Titanium Inspector screenshot - -**Basic console example:** compact per-request traffic tape: - -Basic console proxy screenshot +- [WPF desktop example](examples/Titanium.Web.Proxy.Examples.Wpf) +- [Windows service example](examples/Titanium.Web.Proxy.Examples.WindowsService) +- [API reference](https://titaniumproxy.com/api/Titanium.Web.Proxy.ProxyServer.html) ## Support and contributing -- Report reproducible bugs and feature requests through [GitHub Issues](https://github.com/justcoding121/Titanium-Web-Proxy/issues). -- Ask programming questions on [Stack Overflow](https://stackoverflow.com/questions/tagged/titanium-web-proxy) using the `titanium-web-proxy` tag. -- Pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md). +- Bugs and feature requests: [GitHub Issues](https://github.com/justcoding121/Titanium-Web-Proxy/issues) +- Programming questions: [Stack Overflow](https://stackoverflow.com/questions/tagged/titanium-web-proxy) (`titanium-web-proxy` tag) +- Pull requests welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) (includes local QA tooling for maintainers) -## Maintainers - -This project is actively maintained by: +## Code quality -- [justcoding121](https://github.com/justcoding121) +[![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=alert_status)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=coverage)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) +[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=ncloc)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) +[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=bugs)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=vulnerabilities)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=code_smells)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=security_rating)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) +[![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=reliability_rating)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=sqale_rating)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) +[![Duplicated Lines](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=duplicated_lines_density)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) +[![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=justcoding121_titanium-web-proxy&metric=sqale_index)](https://sonarcloud.io/summary/overall?id=justcoding121_titanium-web-proxy&branch=develop) -Past contributors: +## Maintainers -- [honfika](https://github.com/honfika) +Actively maintained by [justcoding121](https://github.com/justcoding121). Past contributor: [honfika](https://github.com/honfika). ## License -Titanium.Web.Proxy and Titanium.Cli are available under the [MIT License](LICENSE). Titanium Inspector and Titanium.Plus are licensed under [PolyForm Noncommercial 1.0.0](licenses/PolyForm-Noncommercial-1.0.0.txt). +Titanium.Web.Proxy and Titanium.Cli are [MIT](LICENSE). Titanium Inspector and Titanium.Plus are [PolyForm Noncommercial 1.0.0](licenses/PolyForm-Noncommercial-1.0.0.txt). diff --git a/benchmarks/Titanium.Web.Proxy.Benchmarks/README.md b/benchmarks/Titanium.Web.Proxy.Benchmarks/README.md index 60688f6cf..1c7236f06 100644 --- a/benchmarks/Titanium.Web.Proxy.Benchmarks/README.md +++ b/benchmarks/Titanium.Web.Proxy.Benchmarks/README.md @@ -1,5 +1,7 @@ # Titanium.Web.Proxy.Benchmarks +> **For maintainers / contributors** — BenchmarkDotNet harness for throughput and allocation measurements (not run in CI). + Measurement harness required by the hardening plan's "Measurement prerequisite": nothing in the repository measured throughput or allocation before this project existed, yet several later plan items (the HTTP/2 proxy-owned concurrency default, the graduation gates) depend on real numbers diff --git a/docs/api/Titanium.Web.Proxy.ClientProcessId.html b/docs/api/Titanium.Web.Proxy.ClientProcessId.html new file mode 100644 index 000000000..e8a784b83 --- /dev/null +++ b/docs/api/Titanium.Web.Proxy.ClientProcessId.html @@ -0,0 +1,199 @@ + + + + + + + + Class ClientProcessId | Titanium Web Proxy + + + + + + + + + + + + + + + +
+
+ + + + +
+
+ +
+
Search Results for
+
+

+
+
    +
    +
    + + + +
    + + + + + + diff --git a/docs/api/Titanium.Web.Proxy.EventArguments.SessionEventArgs.html b/docs/api/Titanium.Web.Proxy.EventArguments.SessionEventArgs.html index 683bfe7db..c97ab8c36 100644 --- a/docs/api/Titanium.Web.Proxy.EventArguments.SessionEventArgs.html +++ b/docs/api/Titanium.Web.Proxy.EventArguments.SessionEventArgs.html @@ -154,6 +154,9 @@
    Inherited Members
    SessionEventArgsBase.CustomUpStreamProxyUsed
    +
    + SessionEventArgsBase.UpstreamDestinationId +
    SessionEventArgsBase.ProxyEndPoint
    @@ -747,7 +750,7 @@

    Methods Edit this page - View Source + View Source

    Dispose(bool)

    @@ -781,7 +784,7 @@
    Overrides
    Edit this page - View Source + View Source

    DrainClientBodyAsync(CancellationToken)

    @@ -840,7 +843,7 @@
    Edit this page - View Source + View Source

    DrainServerBodyAsync(CancellationToken)

    @@ -896,7 +899,7 @@
    Edit this page - View Source + View Source

    GenericResponse(byte[], HttpStatusCode, IDictionary<string, HttpHeader>, bool)

    @@ -949,7 +952,7 @@
    Parameters
    Edit this page - View Source + View Source

    GenericResponse(byte[], HttpStatusCode, IEnumerable<HttpHeader>?, bool)

    @@ -1002,7 +1005,7 @@
    Parameters
    Edit this page - View Source + View Source

    GenericResponse(string, HttpStatusCode, IDictionary<string, HttpHeader>?, bool)

    @@ -1056,7 +1059,7 @@
    Parameters
    Edit this page - View Source + View Source

    GenericResponse(string, HttpStatusCode, IEnumerable<HttpHeader>?, bool)

    @@ -1210,7 +1213,7 @@
    Returns
    Edit this page - View Source + View Source

    GetResponseBody(CancellationToken)

    @@ -1260,7 +1263,7 @@
    Returns
    Edit this page - View Source + View Source

    GetResponseBodyAsString(CancellationToken)

    @@ -1310,7 +1313,7 @@
    Returns
    Edit this page - View Source + View Source

    Ok(byte[], IDictionary<string, HttpHeader>?, bool)

    @@ -1357,7 +1360,7 @@
    Parameters
    Edit this page - View Source + View Source

    Ok(byte[], IEnumerable<HttpHeader>?, bool)

    @@ -1404,7 +1407,7 @@
    Parameters
    Edit this page - View Source + View Source

    Ok(string, IDictionary<string, HttpHeader>?, bool)

    @@ -1451,7 +1454,7 @@
    Parameters
    Edit this page - View Source + View Source

    Ok(string, IEnumerable<HttpHeader>?, bool)

    @@ -1498,7 +1501,7 @@
    Parameters
    Edit this page - View Source + View Source

    Redirect(string, bool)

    @@ -1538,7 +1541,7 @@
    Parameters
    Edit this page - View Source + View Source

    Respond(Response, bool)

    @@ -1585,7 +1588,7 @@
    Edit this page - View Source + View Source

    RespondStreaming(Response, Func<Stream, CancellationToken, Task>, bool)

    @@ -1641,7 +1644,7 @@
    Edit this page - View Source + View Source

    RespondStreaming(StreamingProxyResult, bool)

    @@ -1716,7 +1719,7 @@
    Parameters
    Edit this page - View Source + View Source

    SetRequestBodyString(string)

    @@ -1750,7 +1753,7 @@
    Parameters
    Edit this page - View Source + View Source

    SetResponseBody(byte[])

    @@ -1784,7 +1787,7 @@
    Parameters
    Edit this page - View Source + View Source

    SetResponseBodyString(string)

    @@ -1818,7 +1821,7 @@
    Parameters
    Edit this page - View Source + View Source

    TerminateServerConnection()

    diff --git a/docs/api/Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.html b/docs/api/Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.html index 44978b9d1..5f1f73e1e 100644 --- a/docs/api/Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.html +++ b/docs/api/Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.html @@ -142,7 +142,7 @@

    Fields Edit this page - View Source + View Source

    BufferPool

    @@ -173,7 +173,7 @@

    Properties Edit this page - View Source + View Source

    ClientConnectionId

    @@ -206,7 +206,7 @@
    Property Value
    Edit this page - View Source + View Source

    ClientEndPoint

    @@ -237,7 +237,7 @@
    Property Value
    Edit this page - View Source + View Source

    ClientLocalEndPoint

    @@ -268,7 +268,7 @@
    Property Value
    Edit this page - View Source + View Source

    ClientRemoteEndPoint

    @@ -299,7 +299,7 @@
    Property Value
    Edit this page - View Source + View Source

    ConnectTimeout

    @@ -333,7 +333,7 @@
    Property Value
    Edit this page - View Source + View Source

    CustomUpStreamProxy

    @@ -365,7 +365,7 @@
    Property Value
    Edit this page - View Source + View Source

    CustomUpStreamProxyUsed

    @@ -396,7 +396,7 @@
    Property Value
    Edit this page - View Source + View Source

    EnableWinAuth

    @@ -427,7 +427,7 @@
    Property Value
    Edit this page - View Source + View Source

    Exception

    @@ -458,7 +458,7 @@
    Property Value
    Edit this page - View Source + View Source

    HttpClient

    @@ -489,7 +489,7 @@
    Property Value
    Edit this page - View Source + View Source

    IsHttps

    @@ -520,7 +520,7 @@
    Property Value
    Edit this page - View Source + View Source

    IsSocks

    @@ -551,7 +551,7 @@
    Property Value
    Edit this page - View Source + View Source

    IsTransparent

    @@ -582,7 +582,7 @@
    Property Value
    Edit this page - View Source + View Source

    LocalEndPoint

    @@ -613,7 +613,7 @@
    Property Value
    Edit this page - View Source + View Source

    Logger

    @@ -646,7 +646,7 @@
    Property Value
    Edit this page - View Source + View Source

    ProxyEndPoint

    @@ -677,7 +677,7 @@
    Property Value
    Edit this page - View Source + View Source

    ServerConnectionId

    @@ -712,7 +712,7 @@
    Property Value
    Edit this page - View Source + View Source

    ServerIpAddress

    @@ -743,7 +743,7 @@
    Property Value
    Edit this page - View Source + View Source

    ServerRemoteEndPoint

    @@ -780,7 +780,7 @@
    Property Value
    Edit this page - View Source + View Source

    Timing

    @@ -813,7 +813,7 @@
    Property Value
    Edit this page - View Source + View Source

    UpstreamConnectionTiming

    @@ -844,12 +844,44 @@
    Property Value
    + + | + Edit this page + + + View Source + + +

    UpstreamDestinationId

    +

    Selected cluster destination id when reverse-proxy routing applied this session; +otherwise null. Used by Plus circuit breaker / health bookkeeping.

    +
    +
    +
    Declaration
    +
    +
    public string? UpstreamDestinationId { get; }
    +
    +
    Property Value
    + + + + + + + + + + + + + +
    TypeDescription
    string
    | Edit this page - View Source + View Source

    UserData

    @@ -881,7 +913,7 @@
    Property Value
    Edit this page - View Source + View Source

    WebSession

    @@ -914,7 +946,7 @@

    Methods Edit this page - View Source + View Source

    Dispose()

    @@ -930,7 +962,7 @@
    Declaration
    Edit this page - View Source + View Source

    Dispose(bool)

    @@ -962,7 +994,7 @@
    Parameters
    Edit this page - View Source + View Source

    OnException(Exception)

    @@ -994,7 +1026,7 @@
    Parameters
    Edit this page - View Source + View Source

    TerminateSession()

    @@ -1012,7 +1044,7 @@

    Events Edit this page - View Source + View Source

    DataReceived

    Fired when data is received within this session from client/server.

    @@ -1042,7 +1074,7 @@
    Event Type
    Edit this page - View Source + View Source

    DataSent

    Fired when data is sent within this session to server/client.

    diff --git a/docs/api/Titanium.Web.Proxy.EventArguments.TunnelConnectSessionEventArgs.html b/docs/api/Titanium.Web.Proxy.EventArguments.TunnelConnectSessionEventArgs.html index caf658d49..cacb72b99 100644 --- a/docs/api/Titanium.Web.Proxy.EventArguments.TunnelConnectSessionEventArgs.html +++ b/docs/api/Titanium.Web.Proxy.EventArguments.TunnelConnectSessionEventArgs.html @@ -152,6 +152,9 @@
    Inherited Members
    SessionEventArgsBase.CustomUpStreamProxyUsed
    +
    + SessionEventArgsBase.UpstreamDestinationId +
    SessionEventArgsBase.ProxyEndPoint
    diff --git a/docs/api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html b/docs/api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html new file mode 100644 index 000000000..c50f9faa1 --- /dev/null +++ b/docs/api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html @@ -0,0 +1,456 @@ + + + + + + + + Class UnixProxyBypassMapper | Titanium Web Proxy + + + + + + + + + + + + + + + +
    +
    + + + + +
    +
    + +
    +
    Search Results for
    +
    +

    +
    +
      +
      +
      + + + +
      + + + + + + diff --git a/docs/api/Titanium.Web.Proxy.Helpers.html b/docs/api/Titanium.Web.Proxy.Helpers.html index 738a53c56..289d71e0f 100644 --- a/docs/api/Titanium.Web.Proxy.Helpers.html +++ b/docs/api/Titanium.Web.Proxy.Helpers.html @@ -1,23 +1,21 @@ - + - + - - Namespace Titanium.Web.Proxy.Helpers - | Titanium Web Proxy - - - + + Namespace Titanium.Web.Proxy.Helpers | Titanium Web Proxy + + - - - - - - + + + + + + + @@ -25,7 +23,7 @@
      - + - +
      - +
      Search Results for

      -
        +
          - - + + diff --git a/docs/api/Titanium.Web.Proxy.Http.HeaderCollection.Enumerator.html b/docs/api/Titanium.Web.Proxy.Http.HeaderCollection.Enumerator.html index 1e84057b0..f6658fb82 100644 --- a/docs/api/Titanium.Web.Proxy.Http.HeaderCollection.Enumerator.html +++ b/docs/api/Titanium.Web.Proxy.Http.HeaderCollection.Enumerator.html @@ -128,7 +128,7 @@

          Properties Edit this page - View Source + View Source

          Current

          @@ -162,7 +162,7 @@

          Methods Edit this page - View Source + View Source

          Dispose()

          @@ -178,7 +178,7 @@
          Declaration
          Edit this page - View Source + View Source

          MoveNext()

          @@ -226,7 +226,7 @@
          Exceptions
          Edit this page - View Source + View Source

          Reset()

          @@ -280,7 +280,7 @@

          Implements

          Edit this page
        • - View Source + View Source
        • diff --git a/docs/api/Titanium.Web.Proxy.Http.HeaderCollection.html b/docs/api/Titanium.Web.Proxy.Http.HeaderCollection.html index 7b593ff27..aad35cb2b 100644 --- a/docs/api/Titanium.Web.Proxy.Http.HeaderCollection.html +++ b/docs/api/Titanium.Web.Proxy.Http.HeaderCollection.html @@ -132,7 +132,7 @@

          Constructors Edit this page - View Source + View Source

          HeaderCollection()

          @@ -150,7 +150,7 @@

          Properties Edit this page - View Source + View Source

          Headers

          @@ -181,7 +181,7 @@
          Property Value
          Edit this page - View Source + View Source

          NonUniqueHeaders

          @@ -215,7 +215,7 @@

          Methods Edit this page - View Source + View Source

          AddHeader(string, string)

          @@ -253,7 +253,7 @@
          Parameters
          Edit this page - View Source + View Source

          AddHeader(HttpHeader)

          @@ -286,7 +286,7 @@
          Parameters
          Edit this page - View Source + View Source

          AddHeaders(IEnumerable<KeyValuePair<string, string>>?)

          @@ -319,7 +319,7 @@
          Parameters
          Edit this page - View Source + View Source

          AddHeaders(IEnumerable<KeyValuePair<string, HttpHeader>>?)

          @@ -352,7 +352,7 @@
          Parameters
          Edit this page - View Source + View Source

          AddHeaders(IEnumerable<HttpHeader>?)

          @@ -385,7 +385,7 @@
          Parameters
          Edit this page - View Source + View Source

          Clear()

          @@ -401,7 +401,7 @@
          Declaration
          Edit this page - View Source + View Source

          GetAllHeaders()

          @@ -432,7 +432,7 @@
          Returns
          Edit this page - View Source + View Source

          GetEnumerator()

          @@ -478,7 +478,7 @@
          Remarks< Edit this page - View Source + View Source

          GetFirstHeader(string)

          @@ -525,7 +525,7 @@
          Returns
          Edit this page - View Source + View Source

          GetHeaders(string)

          @@ -574,7 +574,7 @@
          Returns
          Edit this page - View Source + View Source

          HeaderExists(string)

          @@ -622,7 +622,7 @@
          Returns
          Edit this page - View Source + View Source

          RemoveHeader(string)

          @@ -672,7 +672,7 @@
          Returns
          Edit this page - View Source + View Source

          RemoveHeader(KnownHeader)

          @@ -722,7 +722,7 @@
          Returns
          Edit this page - View Source + View Source

          RemoveHeader(HttpHeader)

          diff --git a/docs/api/Titanium.Web.Proxy.Http.HttpWebClient.html b/docs/api/Titanium.Web.Proxy.Http.HttpWebClient.html index 2b3a0f1fc..9d9d2e33e 100644 --- a/docs/api/Titanium.Web.Proxy.Http.HttpWebClient.html +++ b/docs/api/Titanium.Web.Proxy.Http.HttpWebClient.html @@ -126,7 +126,7 @@

          Properties Edit this page - View Source + View Source

          ConnectRequest

          @@ -157,7 +157,7 @@
          Property Value
          Edit this page - View Source + View Source

          IsHttps

          @@ -188,12 +188,13 @@
          Property Value
          Edit this page - View Source + View Source

          ProcessId

          -

          PID of the process that is created the current session when client is running in this machine -If client is remote then this will return

          +

          PID of the local client process for this session (Windows, Linux, and macOS). +Remote clients, unsupported platforms, and unresolved sockets yield a non-positive value. +See IsSupported.

          Declaration
          @@ -220,7 +221,7 @@
          Property Value
          Edit this page - View Source + View Source

          Request

          @@ -251,11 +252,13 @@
          Property Value
          Edit this page - View Source + View Source

          Response

          -

          Web Response.

          +

          Web Response. Created on first access so H2/H3 MITM Lite request-only work +does not allocate a Response + HeaderCollection graph per stream up front. +CompareExchange: H2 request/response legs can race the first access.

          Declaration
          @@ -282,7 +285,7 @@
          Property Value
          Edit this page - View Source + View Source

          UpStreamEndPoint

          @@ -315,7 +318,7 @@
          Property Value
          Edit this page - View Source + View Source

          UpStreamEndPointIPv4

          @@ -347,7 +350,7 @@
          Property Value
          Edit this page - View Source + View Source

          UpStreamEndPointIPv6

          @@ -379,7 +382,7 @@
          Property Value
          Edit this page - View Source + View Source

          UserData

          diff --git a/docs/api/Titanium.Web.Proxy.Http.Request.html b/docs/api/Titanium.Web.Proxy.Http.Request.html index c52183772..c3b780634 100644 --- a/docs/api/Titanium.Web.Proxy.Http.Request.html +++ b/docs/api/Titanium.Web.Proxy.Http.Request.html @@ -168,7 +168,7 @@

          Properties Edit this page - View Source + View Source

          ExpectContinue

          @@ -199,7 +199,7 @@
          Property Value
          Edit this page - View Source + View Source

          ExpectationFailed

          @@ -230,7 +230,7 @@
          Property Value
          Edit this page - View Source + View Source

          ExpectationSucceeded

          @@ -261,7 +261,7 @@
          Property Value
          Edit this page - View Source + View Source

          ExtendedConnectProtocol

          @@ -328,7 +328,7 @@
          Overrides
          Edit this page - View Source + View Source

          HeaderText

          @@ -425,7 +425,7 @@
          Property Value
          Edit this page - View Source + View Source

          IsMultipartFormData

          @@ -549,7 +549,7 @@
          Property Value
          Edit this page - View Source + View Source

          UpgradeToWebSocket

          diff --git a/docs/api/Titanium.Web.Proxy.Http3.Http3NativeBootstrap.html b/docs/api/Titanium.Web.Proxy.Http3.Http3NativeBootstrap.html new file mode 100644 index 000000000..c55cefa81 --- /dev/null +++ b/docs/api/Titanium.Web.Proxy.Http3.Http3NativeBootstrap.html @@ -0,0 +1,218 @@ + + + + + + + + Class Http3NativeBootstrap | Titanium Web Proxy + + + + + + + + + + + + + + + +
          +
          + + + + +
          +
          + +
          +
          Search Results for
          +
          +

          +
          +
            +
            +
            + + + +
            + + + + + + diff --git a/docs/api/Titanium.Web.Proxy.Http3.html b/docs/api/Titanium.Web.Proxy.Http3.html new file mode 100644 index 000000000..b8614c060 --- /dev/null +++ b/docs/api/Titanium.Web.Proxy.Http3.html @@ -0,0 +1,130 @@ + + + + + + + + Namespace Titanium.Web.Proxy.Http3 | Titanium Web Proxy + + + + + + + + + + + + + + + +
            +
            + + + + +
            +
            + +
            +
            Search Results for
            +
            +

            +
            +
              +
              +
              + + + +
              + + + + + + diff --git a/docs/api/Titanium.Web.Proxy.MitmExclusionDefaults.html b/docs/api/Titanium.Web.Proxy.MitmExclusionDefaults.html new file mode 100644 index 000000000..b56b036b0 --- /dev/null +++ b/docs/api/Titanium.Web.Proxy.MitmExclusionDefaults.html @@ -0,0 +1,841 @@ + + + + + + + + Class MitmExclusionDefaults | Titanium Web Proxy + + + + + + + + + + + + + + + +
              +
              + + + + +
              +
              + +
              +
              Search Results for
              +
              +

              +
              +
                +
                +
                + + + +
                + + + + + + diff --git a/docs/api/Titanium.Web.Proxy.MitmExclusionMode.html b/docs/api/Titanium.Web.Proxy.MitmExclusionMode.html new file mode 100644 index 000000000..269bf28e9 --- /dev/null +++ b/docs/api/Titanium.Web.Proxy.MitmExclusionMode.html @@ -0,0 +1,160 @@ + + + + + + + + Enum MitmExclusionMode | Titanium Web Proxy + + + + + + + + + + + + + + + +
                +
                + + + + +
                +
                + +
                +
                Search Results for
                +
                +

                +
                +
                  +
                  +
                  + + + +
                  + + + + + + diff --git a/docs/api/Titanium.Web.Proxy.Models.HttpHeader.html b/docs/api/Titanium.Web.Proxy.Models.HttpHeader.html index 665856eff..ae835f3d4 100644 --- a/docs/api/Titanium.Web.Proxy.Models.HttpHeader.html +++ b/docs/api/Titanium.Web.Proxy.Models.HttpHeader.html @@ -229,7 +229,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  Name

                  @@ -260,7 +260,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  Size

                  @@ -290,7 +290,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  Value

                  @@ -323,7 +323,7 @@

                  Methods Edit this page - View Source + View Source

                  ToString()

                  diff --git a/docs/api/Titanium.Web.Proxy.Models.HttpInterceptionContext.html b/docs/api/Titanium.Web.Proxy.Models.HttpInterceptionContext.html index 73ffb4448..357b97106 100644 --- a/docs/api/Titanium.Web.Proxy.Models.HttpInterceptionContext.html +++ b/docs/api/Titanium.Web.Proxy.Models.HttpInterceptionContext.html @@ -120,11 +120,12 @@

                  Properties Edit this page - View Source + View Source

                  ClientProcessId

                  -

                  Process ID of the local client (explicit proxy / Windows only; null otherwise).

                  +

                  Process ID of the local client when available (Windows/Linux/macOS explicit-proxy paths). +Null when unset on the fast path or when the client is remote / unresolved.

                  Declaration
                  diff --git a/docs/api/Titanium.Web.Proxy.Models.TransparentBaseProxyEndPoint.html b/docs/api/Titanium.Web.Proxy.Models.TransparentBaseProxyEndPoint.html index b5a93621f..7e9659a13 100644 --- a/docs/api/Titanium.Web.Proxy.Models.TransparentBaseProxyEndPoint.html +++ b/docs/api/Titanium.Web.Proxy.Models.TransparentBaseProxyEndPoint.html @@ -197,7 +197,7 @@

                  Properties Edit this page - View Source + View Source

                  ForwardCleartext

                  @@ -231,13 +231,15 @@
                  Property Value
                  Edit this page - View Source + View Source

                  ForwardHost

                  Optional fixed upstream server to forward all traffic on this endpoint to. -Only the TCP connection target is changed; the original host is still used -for TLS SNI/certificate validation and the HTTP Host header.

                  +TCP connects to this host. For re-encrypt (ForwardCleartext false), +TLS SNI and HTTP Host stay on the client authority. For TLS terminate +(ForwardCleartext true), Host is rewritten to this host and +ForwardPort so HTTP origins see their own bind identity.

                  Declaration
                  @@ -264,7 +266,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  ForwardPort

                  diff --git a/docs/api/Titanium.Web.Proxy.Network.CertificateManager.html b/docs/api/Titanium.Web.Proxy.Network.CertificateManager.html index fe35d99da..b88c5e6f8 100644 --- a/docs/api/Titanium.Web.Proxy.Network.CertificateManager.html +++ b/docs/api/Titanium.Web.Proxy.Network.CertificateManager.html @@ -122,12 +122,44 @@
                  Syntax

                  Properties

                  + + | + Edit this page + + + View Source + + +

                  AreInteractiveRootStoreMutationsSuppressed

                  +

                  True when Root-store Add/Remove should be skipped to avoid modal CryptUI prompts +(static flag, CI env, or TITANIUM_SKIP_ROOT_STORE_UI=1).

                  +
                  +
                  +
                  Declaration
                  +
                  +
                  public static bool AreInteractiveRootStoreMutationsSuppressed { get; }
                  +
                  +
                  Property Value
                  + + + + + + + + + + + + + +
                  TypeDescription
                  bool
                  | Edit this page - View Source + View Source

                  CertificateCacheTimeOutMinutes

                  @@ -158,7 +190,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  CertificateEngine

                  @@ -191,7 +223,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  CertificateGraceDays

                  @@ -228,7 +260,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  CertificateStorage

                  @@ -262,7 +294,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  CertificateValidDays

                  @@ -301,7 +333,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  DisableWildCardCertificates

                  @@ -333,7 +365,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  IntermediateCertificates

                  @@ -365,12 +397,43 @@
                  Property Value
                  + + | + Edit this page + + + View Source + + +

                  LastOsTrustResult

                  +

                  Last OS/browser trust outcome from TrustRootCertificate(bool) / related helpers.

                  +
                  +
                  +
                  Declaration
                  +
                  +
                  public CertificateOsTrustResult? LastOsTrustResult { get; }
                  +
                  +
                  Property Value
                  + + + + + + + + + + + + + +
                  TypeDescription
                  CertificateOsTrustResult
                  | Edit this page - View Source + View Source

                  LeafCertificateKeyAlgorithm

                  @@ -408,7 +471,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  LeafRsaKeyPairBufferSize

                  @@ -447,7 +510,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  OverwritePfxFile

                  @@ -479,7 +542,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  PfxFilePath

                  @@ -515,7 +578,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  PfxPassword

                  @@ -547,7 +610,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  RootCertificate

                  @@ -578,7 +641,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  RootCertificateIssuerName

                  @@ -610,7 +673,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  RootCertificateName

                  @@ -645,7 +708,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  SaveFakeCertificates

                  @@ -677,7 +740,7 @@
                  Property Value
                  Edit this page - View Source + View Source

                  StorageFlag

                  @@ -747,7 +810,7 @@

                  Methods Edit this page - View Source + View Source

                  ApplyFastColdStartLeafSettings()

                  @@ -771,7 +834,7 @@
                  Declaration
                  Edit this page - View Source + View Source

                  ClearRootCertificate()

                  @@ -787,7 +850,7 @@
                  Declaration
                  Edit this page - View Source + View Source

                  CreateRootCertificate(bool)

                  @@ -837,7 +900,7 @@
                  Returns
                  Edit this page - View Source + View Source

                  CreateServerCertificate(string)

                  @@ -885,7 +948,7 @@
                  Returns
                  Edit this page - View Source + View Source

                  Dispose()

                  @@ -901,7 +964,7 @@
                  Declaration
                  Edit this page - View Source + View Source

                  EnsureRootCertificate()

                  @@ -918,7 +981,7 @@
                  Declaration
                  Edit this page - View Source + View Source

                  EnsureRootCertificate(bool, bool, bool)

                  @@ -961,12 +1024,74 @@
                  Parameters
                  + + | + Edit this page + + + View Source + + +

                  InstallNssCertutilAndRetryUserTrust()

                  +

                  Installs NSS certutil (Linux package or macOS Homebrew) after user consent, then retries user SSL trust.

                  +
                  +
                  +
                  Declaration
                  +
                  +
                  public CertificateOsTrustResult InstallNssCertutilAndRetryUserTrust()
                  +
                  +
                  Returns
                  + + + + + + + + + + + + + +
                  TypeDescription
                  CertificateOsTrustResult
                  + + | + Edit this page + + + View Source + + +

                  IsOsRootStillPresent()

                  +

                  True when a Titanium root (current hash or known CN) remains in login or System keychain.

                  +
                  +
                  +
                  Declaration
                  +
                  +
                  public bool IsOsRootStillPresent()
                  +
                  +
                  Returns
                  + + + + + + + + + + + + + +
                  TypeDescription
                  bool
                  | Edit this page - View Source + View Source

                  IsRootCertificateMachineTrusted()

                  @@ -997,7 +1122,7 @@
                  Returns
                  Edit this page - View Source + View Source

                  IsRootCertificateUserTrusted()

                  @@ -1023,12 +1148,44 @@
                  Returns
                  + + | + Edit this page + + + View Source + + +

                  IsRootInLoginKeychain()

                  +

                  Best-effort: true when the current root appears in the macOS login keychain. +Does not imply SSL Always Trust — use VerifyOsUserSslTrust().

                  +
                  +
                  +
                  Declaration
                  +
                  +
                  public bool IsRootInLoginKeychain()
                  +
                  +
                  Returns
                  + + + + + + + + + + + + + +
                  TypeDescription
                  bool
                  | Edit this page - View Source + View Source

                  LoadRootCertificate()

                  @@ -1060,7 +1217,7 @@
                  Returns
                  Edit this page - View Source + View Source

                  LoadRootCertificate(string, string, bool, X509KeyStorageFlags)

                  @@ -1124,12 +1281,43 @@
                  Returns
                  + + | + Edit this page + + + View Source + + +

                  OpenMacKeychainGuidance()

                  +

                  Opens Keychain Access (and a temp .cer) for manual Always Trust on macOS.

                  +
                  +
                  +
                  Declaration
                  +
                  +
                  public string? OpenMacKeychainGuidance()
                  +
                  +
                  Returns
                  + + + + + + + + + + + + + +
                  TypeDescription
                  string
                  | Edit this page - View Source + View Source

                  RemoveTrustedRootCertificate(bool)

                  @@ -1165,7 +1353,7 @@
                  Parameters
                  Edit this page - View Source + View Source

                  RemoveTrustedRootCertificateAsAdmin(bool)

                  @@ -1214,7 +1402,7 @@
                  Returns
                  Edit this page - View Source + View Source

                  TrustRootCertificate(bool)

                  @@ -1239,10 +1427,10 @@
                  Parameters
                  bool machineTrusted -

                  When true, also install into the local-machine stores. Defaults to -false — user-only trust is the recommended default for interactive -apps; machine trust needs elevation (or a privileged service account) and otherwise -fails silently.

                  +

                  When true, also install machine-wide trust (LocalMachine on Windows; +System.keychain / system CA store on macOS/Linux, with an admin prompt). Defaults to +false — user-only trust is the recommended default. Check +LastOsTrustResult and VerifyOsUserSslTrust() after calling.

                  @@ -1252,7 +1440,7 @@
                  Parameters
                  Edit this page - View Source + View Source

                  TrustRootCertificateAsAdmin(bool)

                  @@ -1299,6 +1487,37 @@
                  Returns
                  + + | + Edit this page + + + View Source + + +

                  VerifyOsUserSslTrust()

                  +

                  Re-checks macOS/Linux user SSL trust for the current root.

                  +
                  +
                  +
                  Declaration
                  +
                  +
                  public bool VerifyOsUserSslTrust()
                  +
                  +
                  Returns
                  + + + + + + + + + + + + + +
                  TypeDescription
                  bool

                  Implements

                  IDisposable diff --git a/docs/api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html b/docs/api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html new file mode 100644 index 000000000..0d36cf6a6 --- /dev/null +++ b/docs/api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html @@ -0,0 +1,184 @@ + + + + + + + + Enum CertificateOsTrustKind | Titanium Web Proxy + + + + + + + + + + + + + + + +
                  +
                  + + + + +
                  +
                  + +
                  +
                  Search Results for
                  +
                  +

                  +
                  +
                    +
                    +
                    + + + +
                    + + + + + + diff --git a/docs/api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html b/docs/api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html new file mode 100644 index 000000000..6b5adf0ed --- /dev/null +++ b/docs/api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html @@ -0,0 +1,474 @@ + + + + + + + + Class CertificateOsTrustResult | Titanium Web Proxy + + + + + + + + + + + + + + + +
                    +
                    + + + + +
                    +
                    + +
                    +
                    Search Results for
                    +
                    +

                    +
                    +
                      +
                      +
                      + + + +
                      + + + + + + diff --git a/docs/api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html b/docs/api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html new file mode 100644 index 000000000..e85f5bd37 --- /dev/null +++ b/docs/api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html @@ -0,0 +1,533 @@ + + + + + + + + Class FirefoxCertificateTrust | Titanium Web Proxy + + + + + + + + + + + + + + + +
                      +
                      + + + + +
                      +
                      + +
                      +
                      Search Results for
                      +
                      +

                      +
                      +
                        +
                        +
                        + + + +
                        + + + + + + diff --git a/docs/api/Titanium.Web.Proxy.Network.html b/docs/api/Titanium.Web.Proxy.Network.html index 92040095d..09549aa09 100644 --- a/docs/api/Titanium.Web.Proxy.Network.html +++ b/docs/api/Titanium.Web.Proxy.Network.html @@ -87,9 +87,18 @@

                        CertificateManager

                        A class to manage SSL certificates used by this proxy server.

                        +
                        +

                        CertificateOsTrustResult

                        +

                        Structured result from Unix OS trust or related helper operations.

                        DefaultCertificateDiskCache

                        +

                        FirefoxCertificateTrust

                        +

                        Firefox-specific CA trust: Windows ImportEnterpriseRoots policy / policies.json, +profile user.js (and prefs.js when Firefox is not running) so Firefox +uses OS roots, plus optional NSS import into the default profile (cert9.db). +Does not write into the Firefox.app bundle (that would invalidate the code signature).

                        +

                        Interfaces

                        @@ -104,6 +113,9 @@

                        Cer

                        CertificateKeyAlgorithm

                        Key algorithm used for the leaf ("fake") certificates the proxy generates per intercepted host.

                        +

                        CertificateOsTrustKind

                        +

                        Outcome kind for OS / browser SSL trust helpers (Keychain, NSS, package install).

                        +
                        diff --git a/docs/api/Titanium.Web.Proxy.Options.ProxyResourceLimits.html b/docs/api/Titanium.Web.Proxy.Options.ProxyResourceLimits.html index dd8ea07dc..c8f7d3276 100644 --- a/docs/api/Titanium.Web.Proxy.Options.ProxyResourceLimits.html +++ b/docs/api/Titanium.Web.Proxy.Options.ProxyResourceLimits.html @@ -854,7 +854,7 @@
                        Exceptions
                        Edit this page - View Source + View Source

                        WithCertificateCacheBounds(int?, int?)

                        @@ -929,7 +929,7 @@
                        Exceptions
                        Edit this page - View Source + View Source

                        WithMaxConcurrentStreamsPerConnection(int)

                        @@ -977,7 +977,7 @@
                        Returns
                        Edit this page - View Source + View Source

                        WithMaxOriginHttp2ConnectionsPerAuthority(int)

                        diff --git a/docs/api/Titanium.Web.Proxy.ProxyServer.html b/docs/api/Titanium.Web.Proxy.ProxyServer.html index b28743188..4dd0bf8c7 100644 --- a/docs/api/Titanium.Web.Proxy.ProxyServer.html +++ b/docs/api/Titanium.Web.Proxy.ProxyServer.html @@ -259,7 +259,7 @@

                        Properties Edit this page - View Source + View Source

                        AdmittedClientConnectionCount

                        @@ -293,7 +293,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        BlockPrivateNetworkDestinations

                        @@ -345,7 +345,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        BufferPool

                        @@ -379,7 +379,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        CertificateManager

                        @@ -442,7 +442,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ClientConnectionCount

                        @@ -474,7 +474,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ClientHeaderTimeoutSeconds

                        @@ -514,7 +514,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        CompatibilityMode100Continue

                        @@ -552,7 +552,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ConnectTimeOutSeconds

                        @@ -584,7 +584,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ConnectionTimeOutSeconds

                        @@ -617,7 +617,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        CustomUpStreamProxyFailureFunc

                        @@ -689,7 +689,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        Enable100ContinueBehaviour

                        @@ -722,7 +722,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        EnableConnectionPool

                        @@ -864,7 +864,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        EnableHttpInterception

                        @@ -939,7 +939,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        EnableIpv6UnreachableSoftSkip

                        @@ -1009,7 +1009,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        EnableRequestTimingCapture

                        @@ -1102,7 +1102,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        EnableTcpKeepAlive

                        @@ -1134,7 +1134,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        EnableTcpServerConnectionPrefetch

                        @@ -1205,7 +1205,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        EndpointAdmissionRejectionCount

                        @@ -1269,7 +1269,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        GetCustomUpStreamProxyFunc

                        @@ -1301,7 +1301,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        GlobalAdmissionRejectionCount

                        @@ -1333,7 +1333,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        Http3ClientConnectionCount

                        @@ -1364,7 +1364,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        Http3ServerConnectionCount

                        @@ -1396,7 +1396,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        IdleReadTimeoutSeconds

                        @@ -1429,7 +1429,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        IdleWriteTimeoutSeconds

                        @@ -1457,12 +1457,46 @@
                        Property Value
                        + + | + Edit this page + + + View Source + + +

                        IgnoreServerCertificateErrors

                        +

                        When true, origin TLS certificates that fail OS chain validation are +still accepted (MITM of loopback/self-signed/private CAs). Inspector's +"Ignore server certificate errors" maps here. Default false. +A subscribed ServerCertificateValidationCallback still wins.

                        +
                        +
                        +
                        Declaration
                        +
                        +
                        public bool IgnoreServerCertificateErrors { get; set; }
                        +
                        +
                        Property Value
                        + + + + + + + + + + + + + +
                        TypeDescription
                        bool
                        | Edit this page - View Source + View Source

                        ListenerBackLog

                        @@ -1493,7 +1527,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        Logger

                        @@ -1525,7 +1559,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        Logging

                        @@ -1566,7 +1600,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        MaxBufferedBodyBytes

                        @@ -1601,7 +1635,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        MaxCachedConnections

                        @@ -1655,7 +1689,7 @@
                        Exceptions
                        Edit this page - View Source + View Source

                        MaxConcurrentClientConnections

                        @@ -1695,7 +1729,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        MaxConcurrentHttp11HttpsOriginCreates

                        @@ -1746,7 +1780,7 @@
                        Exceptions
                        Edit this page - View Source + View Source

                        MaxDecodedHeaderListBytes

                        @@ -1781,7 +1815,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        MaxWebSocketFramePayloadBytes

                        @@ -1849,7 +1883,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        NoDelay

                        @@ -1881,7 +1915,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        OriginHttpVersionPolicy

                        @@ -1920,7 +1954,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        PolicyModes

                        @@ -1963,7 +1997,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        Profile

                        @@ -2009,7 +2043,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ProxyAuthenticationRealm

                        @@ -2040,7 +2074,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ProxyAuthenticationSchemes

                        @@ -2073,7 +2107,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ProxyBasicAuthenticateFunc

                        @@ -2106,7 +2140,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ProxyEndPoints

                        @@ -2168,7 +2202,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ProxySchemeAuthenticateFunc

                        @@ -2202,7 +2236,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        RequestTimeoutSeconds

                        @@ -2236,7 +2270,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ResourceLimits

                        @@ -2277,7 +2311,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ResponseHeaderTimeoutSeconds

                        @@ -2317,7 +2351,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ReuseSocket

                        @@ -2350,7 +2384,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ReverseProxy

                        @@ -2382,7 +2416,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ServerConnectionCount

                        @@ -2414,7 +2448,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ShouldInterceptHttp

                        @@ -2448,7 +2482,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        SupportedServerSslProtocols

                        @@ -2489,7 +2523,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        SupportedSslProtocols

                        @@ -2527,7 +2561,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        TcpTimeWaitSeconds

                        @@ -2563,7 +2597,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ThreadPoolWorkerThread

                        @@ -2596,7 +2630,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        UpStreamEndPoint

                        @@ -2631,7 +2665,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        UpStreamEndPointIPv4

                        @@ -2663,7 +2697,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        UpStreamEndPointIPv6

                        @@ -2695,7 +2729,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        UpStreamHttpProxy

                        @@ -2726,7 +2760,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        UpStreamHttpsProxy

                        @@ -2788,7 +2822,7 @@
                        Property Value
                        Edit this page - View Source + View Source

                        ViaHeaderPseudonym

                        @@ -2858,7 +2892,7 @@

                        Methods Edit this page - View Source + View Source

                        AddEndPoint(ProxyEndPoint)

                        @@ -2892,7 +2926,7 @@
                        Parameters
                        Edit this page - View Source + View Source

                        ApplyLoggingConfiguration()

                        @@ -2913,7 +2947,7 @@
                        Declaration
                        Edit this page - View Source + View Source

                        DisableAllSystemProxies()

                        @@ -2929,7 +2963,7 @@
                        Declaration
                        Edit this page - View Source + View Source

                        DisableSystemHttpProxy()

                        @@ -2945,7 +2979,7 @@
                        Declaration
                        Edit this page - View Source + View Source

                        DisableSystemHttpsProxy()

                        @@ -2961,7 +2995,7 @@
                        Declaration
                        Edit this page - View Source + View Source

                        DisableSystemProxy(ProxyProtocolType)

                        @@ -2994,7 +3028,7 @@
                        Parameters
                        Edit this page - View Source + View Source

                        Dispose()

                        @@ -3010,7 +3044,7 @@
                        Declaration
                        Edit this page - View Source + View Source

                        Dispose(bool)

                        @@ -3043,7 +3077,7 @@
                        Parameters
                        Edit this page - View Source + View Source

                        RemoveEndPoint(ProxyEndPoint)

                        @@ -3078,7 +3112,7 @@
                        Parameters
                        Edit this page - View Source + View Source

                        RestoreOriginalProxySettings()

                        @@ -3094,7 +3128,7 @@
                        Declaration
                        Edit this page - View Source + View Source

                        SetAsSystemHttpProxy(ExplicitProxyEndPoint)

                        @@ -3128,7 +3162,7 @@
                        Parameters
                        Edit this page - View Source + View Source

                        SetAsSystemHttpProxy(ExplicitProxyEndPoint, SystemProxySettings)

                        @@ -3168,7 +3202,7 @@
                        Parameters
                        Edit this page - View Source + View Source

                        SetAsSystemHttpsProxy(ExplicitProxyEndPoint)

                        @@ -3202,7 +3236,7 @@
                        Parameters
                        Edit this page - View Source + View Source

                        SetAsSystemHttpsProxy(ExplicitProxyEndPoint, SystemProxySettings)

                        @@ -3242,7 +3276,7 @@
                        Parameters
                        Edit this page - View Source + View Source

                        SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType)

                        @@ -3282,7 +3316,7 @@
                        Parameters
                        Edit this page - View Source + View Source

                        SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?)

                        @@ -3380,7 +3414,7 @@
                        Returns
                        Edit this page - View Source + View Source

                        Start(bool)

                        @@ -3423,7 +3457,7 @@
                        Parameters
                        Edit this page - View Source + View Source

                        Stop()

                        @@ -3442,7 +3476,7 @@
                        Declaration
                        Edit this page - View Source + View Source

                        StopAsync(TimeSpan?)

                        @@ -3488,6 +3522,85 @@
                        Returns
                        + + | + Edit this page + + + View Source + + +

                        TryDisableAllSystemProxies()

                        +

                        Clear all OS proxy settings without throwing.

                        +
                        +
                        +
                        Declaration
                        +
                        +
                        public SystemProxyChangeResult TryDisableAllSystemProxies()
                        +
                        +
                        Returns
                        + + + + + + + + + + + + + +
                        TypeDescription
                        SystemProxyChangeResult
                        + + | + Edit this page + + + View Source + + +

                        TryDisableSystemProxy(ProxyProtocolType)

                        +

                        Clear OS proxy for the given protocols without throwing.

                        +
                        +
                        +
                        Declaration
                        +
                        +
                        public SystemProxyChangeResult TryDisableSystemProxy(ProxyProtocolType protocolType)
                        +
                        +
                        Parameters
                        + + + + + + + + + + + + + + + +
                        TypeNameDescription
                        ProxyProtocolTypeprotocolType
                        +
                        Returns
                        + + + + + + + + + + + + + +
                        TypeDescription
                        SystemProxyChangeResult
                        | Edit this page @@ -3522,6 +3635,96 @@
                        Returns
                        + + | + Edit this page + + + View Source + + +

                        TryRestoreOriginalProxySettings()

                        +

                        Restore OS proxy without throwing.

                        +
                        +
                        +
                        Declaration
                        +
                        +
                        public SystemProxyChangeResult TryRestoreOriginalProxySettings()
                        +
                        +
                        Returns
                        + + + + + + + + + + + + + +
                        TypeDescription
                        SystemProxyChangeResult
                        + + | + Edit this page + + + View Source + + +

                        TrySetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?)

                        +

                        Enable OS system proxy without throwing. Failures are logged and returned so Inspector/CLI +can show a status message instead of crashing.

                        +
                        +
                        +
                        Declaration
                        +
                        +
                        public SystemProxyChangeResult TrySetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, SystemProxySettings? settings = null)
                        +
                        +
                        Parameters
                        + + + + + + + + + + + + + + + + + + + + + + + + + +
                        TypeNameDescription
                        ExplicitProxyEndPointendPoint
                        ProxyProtocolTypeprotocolType
                        SystemProxySettingssettings
                        +
                        Returns
                        + + + + + + + + + + + + + +
                        TypeDescription
                        SystemProxyChangeResult

                        Events

                        @@ -3529,7 +3732,7 @@

                        Events Edit this page - View Source + View Source

                        AfterResponse

                        Intercept after response event from server.

                        @@ -3559,7 +3762,7 @@
                        Event Type
                        Edit this page - View Source + View Source

                        BeforeRequest

                        Intercept request event to server.

                        @@ -3589,7 +3792,7 @@
                        Event Type
                        Edit this page - View Source + View Source

                        BeforeResponse

                        Intercept response event from server.

                        @@ -3619,7 +3822,7 @@
                        Event Type
                        Edit this page - View Source + View Source

                        BeforeUpStreamConnectRequest

                        Intercept connect request sent to upstream proxy.

                        @@ -3649,7 +3852,7 @@
                        Event Type
                        Edit this page - View Source + View Source

                        ClientCertificateSelectionCallback

                        Event to override client certificate selection during mutual SSL authentication.

                        @@ -3679,7 +3882,7 @@
                        Event Type
                        Edit this page - View Source + View Source

                        ClientConnectionCountChanged

                        Event occurs when client connection count changed.

                        @@ -3709,7 +3912,7 @@
                        Event Type
                        Edit this page - View Source + View Source

                        Http3ClientConnectionCountChanged

                        Event occurs when inbound HTTP/3 client connection count changed.

                        @@ -3739,7 +3942,7 @@
                        Event Type
                        Edit this page - View Source + View Source

                        Http3ServerConnectionCountChanged

                        Event occurs when upstream HTTP/3 server connection count changed.

                        @@ -3769,7 +3972,7 @@
                        Event Type
                        Edit this page - View Source + View Source

                        OnClientConnectionCreate

                        Customize TcpClient used for client connection upon create.

                        @@ -3799,7 +4002,7 @@
                        Event Type
                        Edit this page - View Source + View Source

                        OnRequestBodyWrite

                        Intercept request body send event to server. @@ -3831,7 +4034,7 @@

                        Event Type
                        Edit this page - View Source + View Source

                        OnResponseBodyWrite

                        Intercept response body send event to client. @@ -3863,7 +4066,7 @@

                        Event Type
                        Edit this page - View Source + View Source

                        OnServerConnectionCreate

                        Customize TcpClient used for server connection upon create.

                        @@ -3893,7 +4096,7 @@
                        Event Type
                        Edit this page - View Source + View Source

                        ServerCertificateValidationCallback

                        Event to override the default verification logic of remote SSL certificate received during authentication.

                        @@ -3923,7 +4126,7 @@
                        Event Type
                        Edit this page - View Source + View Source

                        ServerConnectionCountChanged

                        Event occurs when server connection count changed.

                        @@ -3964,7 +4167,7 @@

                        Implements

                        Edit this page
                      • - View Source + View Source
                      • diff --git a/docs/api/Titanium.Web.Proxy.SystemProxyChangeResult.html b/docs/api/Titanium.Web.Proxy.SystemProxyChangeResult.html new file mode 100644 index 000000000..97a0541d6 --- /dev/null +++ b/docs/api/Titanium.Web.Proxy.SystemProxyChangeResult.html @@ -0,0 +1,355 @@ + + + + + + + + Struct SystemProxyChangeResult | Titanium Web Proxy + + + + + + + + + + + + + + + +
                        +
                        + + + + +
                        +
                        + +
                        +
                        Search Results for
                        +
                        +

                        +
                        +
                          +
                          +
                          + + + +
                          + + + + + + diff --git a/docs/api/Titanium.Web.Proxy.SystemProxySettings.html b/docs/api/Titanium.Web.Proxy.SystemProxySettings.html index 8039e94ae..d5794dec2 100644 --- a/docs/api/Titanium.Web.Proxy.SystemProxySettings.html +++ b/docs/api/Titanium.Web.Proxy.SystemProxySettings.html @@ -253,6 +253,56 @@
                          R

                          Ordering matters because rules are evaluated left-to-right; a subtractive rule such as <-loopback> has a different effect before versus after a contradicting bypass rule.

                          +

                          Methods +

                          + + | + Edit this page + + + View Source + + +

                          BuildProxyOverride(string?)

                          +

                          Builds the WinINET-style semicolon-separated bypass list that would be applied to the OS.

                          +
                          +
                          +
                          Declaration
                          +
                          +
                          public string BuildProxyOverride(string? currentProxyOverride)
                          +
                          +
                          Parameters
                          + + + + + + + + + + + + + + + +
                          TypeNameDescription
                          stringcurrentProxyOverride
                          +
                          Returns
                          + + + + + + + + + + + + + +
                          TypeDescription
                          string
                          diff --git a/docs/api/Titanium.Web.Proxy.Transforms.TransformEngine.html b/docs/api/Titanium.Web.Proxy.Transforms.TransformEngine.html index 3981f943c..e5ab805a7 100644 --- a/docs/api/Titanium.Web.Proxy.Transforms.TransformEngine.html +++ b/docs/api/Titanium.Web.Proxy.Transforms.TransformEngine.html @@ -9,7 +9,7 @@ - + @@ -81,7 +81,7 @@

                          Class TransformEngine

                          -

                          Applies known transform kinds (path prefix strip/set header).

                          +

                          Applies known transform kinds to request path/headers/query and staged response headers.

                          @@ -127,7 +127,7 @@

                          Methods Edit this page - View Source + View Source

                          ApplyRequestTransforms(IReadOnlyList<TransformConfig>?, TransformRequestContext)

                          @@ -175,7 +175,7 @@

                          Implements

                          Edit this page
                        • - View Source + View Source
                        • diff --git a/docs/api/Titanium.Web.Proxy.Transforms.html b/docs/api/Titanium.Web.Proxy.Transforms.html index 8df7b38a1..426e5ed8f 100644 --- a/docs/api/Titanium.Web.Proxy.Transforms.html +++ b/docs/api/Titanium.Web.Proxy.Transforms.html @@ -86,7 +86,7 @@

                          Classes

                          TransformEngine

                          -

                          Applies known transform kinds (path prefix strip/set header).

                          +

                          Applies known transform kinds to request path/headers/query and staged response headers.

                          diff --git a/docs/api/Titanium.Web.Proxy.html b/docs/api/Titanium.Web.Proxy.html index 39b503942..18cbd24ac 100644 --- a/docs/api/Titanium.Web.Proxy.html +++ b/docs/api/Titanium.Web.Proxy.html @@ -85,6 +85,14 @@

                          Nam

                          Classes

                          +

                          ClientProcessId

                          +

                          Capability for resolving the local client process that owns a TCP connection to the proxy.

                          +
                          +

                          MitmExclusionDefaults

                          +

                          Default hostname exclusions for MITM proxies (Microsoft identity / certificate pinning). +Use with BypassRules and +DecryptSsl.

                          +

                          ProxyLimits

                          Reference values for a handful of resource-limit defaults, kept here for documentation and cross-checking purposes.

                          @@ -127,10 +135,20 @@

                          We length and then trickles bytes in slowly cannot force unbounded buffer growth while the decoder waits for a frame that will never legitimately complete.

                          +

                          +

                          +Structs +

                          +

                          SystemProxyChangeResult

                          +

                          Outcome of enabling or disabling OS system proxy. Callers must treat failure as +non-fatal: log Message and continue (do not crash the process).

                          Enums

                          +

                          MitmExclusionMode

                          +

                          How factory MITM exclusion defaults interact with caller-supplied host lists.

                          +

                          SystemProxyBypassRuleMode

                          Controls how configured bypass rules are combined with the current Windows system proxy bypass list.

                          diff --git a/docs/api/toc.html b/docs/api/toc.html index 1d7057a50..bab939497 100644 --- a/docs/api/toc.html +++ b/docs/api/toc.html @@ -17,6 +17,15 @@ Titanium.Web.Proxy +
                        • + + Titanium.Web.Proxy.Helpers + + +
                        • Titanium.Web.Proxy.Http @@ -254,6 +276,16 @@
                        • +
                        • + + Titanium.Web.Proxy.Http3 + + +
                        • Titanium.Web.Proxy.Logging @@ -346,9 +378,18 @@
                        • CertificateManager
                        • +
                        • + CertificateOsTrustKind +
                        • +
                        • + CertificateOsTrustResult +
                        • DefaultCertificateDiskCache
                        • +
                        • + FirefoxCertificateTrust +
                        • ICertificateCache
                        • diff --git a/docs/api/toc.json b/docs/api/toc.json index c1a2a01c3..bc6b32a06 100644 --- a/docs/api/toc.json +++ b/docs/api/toc.json @@ -1,2 +1,2 @@ -{"items":[{"name":"Titanium.Web.Proxy","href":"Titanium.Web.Proxy.html","topicHref":"Titanium.Web.Proxy.html","topicUid":"Titanium.Web.Proxy","type":"Namespace","items":[{"name":"ProxyLimits","href":"Titanium.Web.Proxy.ProxyLimits.html","topicHref":"Titanium.Web.Proxy.ProxyLimits.html","topicUid":"Titanium.Web.Proxy.ProxyLimits","type":"Class"},{"name":"ProxyServer","href":"Titanium.Web.Proxy.ProxyServer.html","topicHref":"Titanium.Web.Proxy.ProxyServer.html","topicUid":"Titanium.Web.Proxy.ProxyServer","type":"Class"},{"name":"SystemProxyBypassRuleMode","href":"Titanium.Web.Proxy.SystemProxyBypassRuleMode.html","topicHref":"Titanium.Web.Proxy.SystemProxyBypassRuleMode.html","topicUid":"Titanium.Web.Proxy.SystemProxyBypassRuleMode","type":"Enum"},{"name":"SystemProxyLoopbackPlacement","href":"Titanium.Web.Proxy.SystemProxyLoopbackPlacement.html","topicHref":"Titanium.Web.Proxy.SystemProxyLoopbackPlacement.html","topicUid":"Titanium.Web.Proxy.SystemProxyLoopbackPlacement","type":"Enum"},{"name":"SystemProxySettings","href":"Titanium.Web.Proxy.SystemProxySettings.html","topicHref":"Titanium.Web.Proxy.SystemProxySettings.html","topicUid":"Titanium.Web.Proxy.SystemProxySettings","type":"Class"},{"name":"WebSocketDecoder","href":"Titanium.Web.Proxy.WebSocketDecoder.html","topicHref":"Titanium.Web.Proxy.WebSocketDecoder.html","topicUid":"Titanium.Web.Proxy.WebSocketDecoder","type":"Class"},{"name":"WebSocketFrame","href":"Titanium.Web.Proxy.WebSocketFrame.html","topicHref":"Titanium.Web.Proxy.WebSocketFrame.html","topicUid":"Titanium.Web.Proxy.WebSocketFrame","type":"Class"},{"name":"WebSocketFrameEncoder","href":"Titanium.Web.Proxy.WebSocketFrameEncoder.html","topicHref":"Titanium.Web.Proxy.WebSocketFrameEncoder.html","topicUid":"Titanium.Web.Proxy.WebSocketFrameEncoder","type":"Class"},{"name":"WebSocketFrameWriter","href":"Titanium.Web.Proxy.WebSocketFrameWriter.html","topicHref":"Titanium.Web.Proxy.WebSocketFrameWriter.html","topicUid":"Titanium.Web.Proxy.WebSocketFrameWriter","type":"Class"},{"name":"WebSocketProtocolException","href":"Titanium.Web.Proxy.WebSocketProtocolException.html","topicHref":"Titanium.Web.Proxy.WebSocketProtocolException.html","topicUid":"Titanium.Web.Proxy.WebSocketProtocolException","type":"Class"},{"name":"WebsocketOpCode","href":"Titanium.Web.Proxy.WebsocketOpCode.html","topicHref":"Titanium.Web.Proxy.WebsocketOpCode.html","topicUid":"Titanium.Web.Proxy.WebsocketOpCode","type":"Enum"}]},{"name":"Titanium.Web.Proxy.Caching","href":"Titanium.Web.Proxy.Caching.html","topicHref":"Titanium.Web.Proxy.Caching.html","topicUid":"Titanium.Web.Proxy.Caching","type":"Namespace","items":[{"name":"HttpResponseCacheMiddleware","href":"Titanium.Web.Proxy.Caching.HttpResponseCacheMiddleware.html","topicHref":"Titanium.Web.Proxy.Caching.HttpResponseCacheMiddleware.html","topicUid":"Titanium.Web.Proxy.Caching.HttpResponseCacheMiddleware","type":"Class"},{"name":"MemoryHttpResponseCache","href":"Titanium.Web.Proxy.Caching.MemoryHttpResponseCache.html","topicHref":"Titanium.Web.Proxy.Caching.MemoryHttpResponseCache.html","topicUid":"Titanium.Web.Proxy.Caching.MemoryHttpResponseCache","type":"Class"}]},{"name":"Titanium.Web.Proxy.Clusters","href":"Titanium.Web.Proxy.Clusters.html","topicHref":"Titanium.Web.Proxy.Clusters.html","topicUid":"Titanium.Web.Proxy.Clusters","type":"Namespace","items":[{"name":"ClusterManager","href":"Titanium.Web.Proxy.Clusters.ClusterManager.html","topicHref":"Titanium.Web.Proxy.Clusters.ClusterManager.html","topicUid":"Titanium.Web.Proxy.Clusters.ClusterManager","type":"Class"},{"name":"DestinationHealthTracker","href":"Titanium.Web.Proxy.Clusters.DestinationHealthTracker.html","topicHref":"Titanium.Web.Proxy.Clusters.DestinationHealthTracker.html","topicUid":"Titanium.Web.Proxy.Clusters.DestinationHealthTracker","type":"Class"},{"name":"LoadBalancer","href":"Titanium.Web.Proxy.Clusters.LoadBalancer.html","topicHref":"Titanium.Web.Proxy.Clusters.LoadBalancer.html","topicUid":"Titanium.Web.Proxy.Clusters.LoadBalancer","type":"Class"}]},{"name":"Titanium.Web.Proxy.Diagnostics","href":"Titanium.Web.Proxy.Diagnostics.html","topicHref":"Titanium.Web.Proxy.Diagnostics.html","topicUid":"Titanium.Web.Proxy.Diagnostics","type":"Namespace","items":[{"name":"ClientTlsTiming","href":"Titanium.Web.Proxy.Diagnostics.ClientTlsTiming.html","topicHref":"Titanium.Web.Proxy.Diagnostics.ClientTlsTiming.html","topicUid":"Titanium.Web.Proxy.Diagnostics.ClientTlsTiming","type":"Class"},{"name":"HttpRequestTiming","href":"Titanium.Web.Proxy.Diagnostics.HttpRequestTiming.html","topicHref":"Titanium.Web.Proxy.Diagnostics.HttpRequestTiming.html","topicUid":"Titanium.Web.Proxy.Diagnostics.HttpRequestTiming","type":"Class"},{"name":"TunnelConnectTiming","href":"Titanium.Web.Proxy.Diagnostics.TunnelConnectTiming.html","topicHref":"Titanium.Web.Proxy.Diagnostics.TunnelConnectTiming.html","topicUid":"Titanium.Web.Proxy.Diagnostics.TunnelConnectTiming","type":"Class"},{"name":"UpstreamConnectionTiming","href":"Titanium.Web.Proxy.Diagnostics.UpstreamConnectionTiming.html","topicHref":"Titanium.Web.Proxy.Diagnostics.UpstreamConnectionTiming.html","topicUid":"Titanium.Web.Proxy.Diagnostics.UpstreamConnectionTiming","type":"Class"}]},{"name":"Titanium.Web.Proxy.EventArguments","href":"Titanium.Web.Proxy.EventArguments.html","topicHref":"Titanium.Web.Proxy.EventArguments.html","topicUid":"Titanium.Web.Proxy.EventArguments","type":"Namespace","items":[{"name":"AsyncEventHandler","href":"Titanium.Web.Proxy.EventArguments.AsyncEventHandler-1.html","topicHref":"Titanium.Web.Proxy.EventArguments.AsyncEventHandler-1.html","topicUid":"Titanium.Web.Proxy.EventArguments.AsyncEventHandler`1","type":"Delegate"},{"name":"BeforeBodyWriteEventArgs","href":"Titanium.Web.Proxy.EventArguments.BeforeBodyWriteEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.BeforeBodyWriteEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.BeforeBodyWriteEventArgs","type":"Class"},{"name":"BeforeHttpAuthenticateEventArgs","href":"Titanium.Web.Proxy.EventArguments.BeforeHttpAuthenticateEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.BeforeHttpAuthenticateEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.BeforeHttpAuthenticateEventArgs","type":"Class"},{"name":"BeforeQuicAuthenticateEventArgs","href":"Titanium.Web.Proxy.EventArguments.BeforeQuicAuthenticateEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.BeforeQuicAuthenticateEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.BeforeQuicAuthenticateEventArgs","type":"Class"},{"name":"BeforeSslAuthenticateEventArgs","href":"Titanium.Web.Proxy.EventArguments.BeforeSslAuthenticateEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.BeforeSslAuthenticateEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.BeforeSslAuthenticateEventArgs","type":"Class"},{"name":"CertificateSelectionEventArgs","href":"Titanium.Web.Proxy.EventArguments.CertificateSelectionEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.CertificateSelectionEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.CertificateSelectionEventArgs","type":"Class"},{"name":"CertificateValidationEventArgs","href":"Titanium.Web.Proxy.EventArguments.CertificateValidationEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.CertificateValidationEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.CertificateValidationEventArgs","type":"Class"},{"name":"MultipartRequestPartSentEventArgs","href":"Titanium.Web.Proxy.EventArguments.MultipartRequestPartSentEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.MultipartRequestPartSentEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.MultipartRequestPartSentEventArgs","type":"Class"},{"name":"ProxyEventArgsBase","href":"Titanium.Web.Proxy.EventArguments.ProxyEventArgsBase.html","topicHref":"Titanium.Web.Proxy.EventArguments.ProxyEventArgsBase.html","topicUid":"Titanium.Web.Proxy.EventArguments.ProxyEventArgsBase","type":"Class"},{"name":"SessionEventArgs","href":"Titanium.Web.Proxy.EventArguments.SessionEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.SessionEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.SessionEventArgs","type":"Class"},{"name":"SessionEventArgsBase","href":"Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.html","topicHref":"Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.html","topicUid":"Titanium.Web.Proxy.EventArguments.SessionEventArgsBase","type":"Class"},{"name":"SocksAuthenticateEventArgs","href":"Titanium.Web.Proxy.EventArguments.SocksAuthenticateEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.SocksAuthenticateEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.SocksAuthenticateEventArgs","type":"Class"},{"name":"TunnelConnectFailureEventArgs","href":"Titanium.Web.Proxy.EventArguments.TunnelConnectFailureEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.TunnelConnectFailureEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.TunnelConnectFailureEventArgs","type":"Class"},{"name":"TunnelConnectSessionEventArgs","href":"Titanium.Web.Proxy.EventArguments.TunnelConnectSessionEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.TunnelConnectSessionEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.TunnelConnectSessionEventArgs","type":"Class"},{"name":"WebSocketFrameDirection","href":"Titanium.Web.Proxy.EventArguments.WebSocketFrameDirection.html","topicHref":"Titanium.Web.Proxy.EventArguments.WebSocketFrameDirection.html","topicUid":"Titanium.Web.Proxy.EventArguments.WebSocketFrameDirection","type":"Enum"},{"name":"WebSocketFrameInterceptAction","href":"Titanium.Web.Proxy.EventArguments.WebSocketFrameInterceptAction.html","topicHref":"Titanium.Web.Proxy.EventArguments.WebSocketFrameInterceptAction.html","topicUid":"Titanium.Web.Proxy.EventArguments.WebSocketFrameInterceptAction","type":"Enum"},{"name":"WebSocketFrameInterceptEventArgs","href":"Titanium.Web.Proxy.EventArguments.WebSocketFrameInterceptEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.WebSocketFrameInterceptEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.WebSocketFrameInterceptEventArgs","type":"Class"}]},{"name":"Titanium.Web.Proxy.Exceptions","href":"Titanium.Web.Proxy.Exceptions.html","topicHref":"Titanium.Web.Proxy.Exceptions.html","topicUid":"Titanium.Web.Proxy.Exceptions","type":"Namespace","items":[{"name":"BodyNotFoundException","href":"Titanium.Web.Proxy.Exceptions.BodyNotFoundException.html","topicHref":"Titanium.Web.Proxy.Exceptions.BodyNotFoundException.html","topicUid":"Titanium.Web.Proxy.Exceptions.BodyNotFoundException","type":"Class"},{"name":"OutboundDestinationBlockedException","href":"Titanium.Web.Proxy.Exceptions.OutboundDestinationBlockedException.html","topicHref":"Titanium.Web.Proxy.Exceptions.OutboundDestinationBlockedException.html","topicUid":"Titanium.Web.Proxy.Exceptions.OutboundDestinationBlockedException","type":"Class"},{"name":"ProxyAuthorizationException","href":"Titanium.Web.Proxy.Exceptions.ProxyAuthorizationException.html","topicHref":"Titanium.Web.Proxy.Exceptions.ProxyAuthorizationException.html","topicUid":"Titanium.Web.Proxy.Exceptions.ProxyAuthorizationException","type":"Class"},{"name":"ProxyConnectException","href":"Titanium.Web.Proxy.Exceptions.ProxyConnectException.html","topicHref":"Titanium.Web.Proxy.Exceptions.ProxyConnectException.html","topicUid":"Titanium.Web.Proxy.Exceptions.ProxyConnectException","type":"Class"},{"name":"ProxyException","href":"Titanium.Web.Proxy.Exceptions.ProxyException.html","topicHref":"Titanium.Web.Proxy.Exceptions.ProxyException.html","topicUid":"Titanium.Web.Proxy.Exceptions.ProxyException","type":"Class"},{"name":"ProxyHttpException","href":"Titanium.Web.Proxy.Exceptions.ProxyHttpException.html","topicHref":"Titanium.Web.Proxy.Exceptions.ProxyHttpException.html","topicUid":"Titanium.Web.Proxy.Exceptions.ProxyHttpException","type":"Class"},{"name":"ProxyTimeoutException","href":"Titanium.Web.Proxy.Exceptions.ProxyTimeoutException.html","topicHref":"Titanium.Web.Proxy.Exceptions.ProxyTimeoutException.html","topicUid":"Titanium.Web.Proxy.Exceptions.ProxyTimeoutException","type":"Class"},{"name":"ProxyTimeoutKind","href":"Titanium.Web.Proxy.Exceptions.ProxyTimeoutKind.html","topicHref":"Titanium.Web.Proxy.Exceptions.ProxyTimeoutKind.html","topicUid":"Titanium.Web.Proxy.Exceptions.ProxyTimeoutKind","type":"Enum"},{"name":"UpstreamProxyConnectException","href":"Titanium.Web.Proxy.Exceptions.UpstreamProxyConnectException.html","topicHref":"Titanium.Web.Proxy.Exceptions.UpstreamProxyConnectException.html","topicUid":"Titanium.Web.Proxy.Exceptions.UpstreamProxyConnectException","type":"Class"}]},{"name":"Titanium.Web.Proxy.Http","href":"Titanium.Web.Proxy.Http.html","topicHref":"Titanium.Web.Proxy.Http.html","topicUid":"Titanium.Web.Proxy.Http","type":"Namespace","items":[{"name":"ConnectRequest","href":"Titanium.Web.Proxy.Http.ConnectRequest.html","topicHref":"Titanium.Web.Proxy.Http.ConnectRequest.html","topicUid":"Titanium.Web.Proxy.Http.ConnectRequest","type":"Class"},{"name":"ConnectResponse","href":"Titanium.Web.Proxy.Http.ConnectResponse.html","topicHref":"Titanium.Web.Proxy.Http.ConnectResponse.html","topicUid":"Titanium.Web.Proxy.Http.ConnectResponse","type":"Class"},{"name":"HeaderCollection","href":"Titanium.Web.Proxy.Http.HeaderCollection.html","topicHref":"Titanium.Web.Proxy.Http.HeaderCollection.html","topicUid":"Titanium.Web.Proxy.Http.HeaderCollection","type":"Class"},{"name":"HeaderCollection.Enumerator","href":"Titanium.Web.Proxy.Http.HeaderCollection.Enumerator.html","topicHref":"Titanium.Web.Proxy.Http.HeaderCollection.Enumerator.html","topicUid":"Titanium.Web.Proxy.Http.HeaderCollection.Enumerator","type":"Struct"},{"name":"HttpWebClient","href":"Titanium.Web.Proxy.Http.HttpWebClient.html","topicHref":"Titanium.Web.Proxy.Http.HttpWebClient.html","topicUid":"Titanium.Web.Proxy.Http.HttpWebClient","type":"Class"},{"name":"KnownHeader","href":"Titanium.Web.Proxy.Http.KnownHeader.html","topicHref":"Titanium.Web.Proxy.Http.KnownHeader.html","topicUid":"Titanium.Web.Proxy.Http.KnownHeader","type":"Class"},{"name":"KnownHeaders","href":"Titanium.Web.Proxy.Http.KnownHeaders.html","topicHref":"Titanium.Web.Proxy.Http.KnownHeaders.html","topicUid":"Titanium.Web.Proxy.Http.KnownHeaders","type":"Class"},{"name":"ProxyResults","href":"Titanium.Web.Proxy.Http.ProxyResults.html","topicHref":"Titanium.Web.Proxy.Http.ProxyResults.html","topicUid":"Titanium.Web.Proxy.Http.ProxyResults","type":"Class"},{"name":"Request","href":"Titanium.Web.Proxy.Http.Request.html","topicHref":"Titanium.Web.Proxy.Http.Request.html","topicUid":"Titanium.Web.Proxy.Http.Request","type":"Class"},{"name":"RequestResponseBase","href":"Titanium.Web.Proxy.Http.RequestResponseBase.html","topicHref":"Titanium.Web.Proxy.Http.RequestResponseBase.html","topicUid":"Titanium.Web.Proxy.Http.RequestResponseBase","type":"Class"},{"name":"Response","href":"Titanium.Web.Proxy.Http.Response.html","topicHref":"Titanium.Web.Proxy.Http.Response.html","topicUid":"Titanium.Web.Proxy.Http.Response","type":"Class"},{"name":"StreamingProxyResult","href":"Titanium.Web.Proxy.Http.StreamingProxyResult.html","topicHref":"Titanium.Web.Proxy.Http.StreamingProxyResult.html","topicUid":"Titanium.Web.Proxy.Http.StreamingProxyResult","type":"Struct"},{"name":"TunnelType","href":"Titanium.Web.Proxy.Http.TunnelType.html","topicHref":"Titanium.Web.Proxy.Http.TunnelType.html","topicUid":"Titanium.Web.Proxy.Http.TunnelType","type":"Enum"}]},{"name":"Titanium.Web.Proxy.Http.Responses","href":"Titanium.Web.Proxy.Http.Responses.html","topicHref":"Titanium.Web.Proxy.Http.Responses.html","topicUid":"Titanium.Web.Proxy.Http.Responses","type":"Namespace","items":[{"name":"GenericResponse","href":"Titanium.Web.Proxy.Http.Responses.GenericResponse.html","topicHref":"Titanium.Web.Proxy.Http.Responses.GenericResponse.html","topicUid":"Titanium.Web.Proxy.Http.Responses.GenericResponse","type":"Class"},{"name":"OkResponse","href":"Titanium.Web.Proxy.Http.Responses.OkResponse.html","topicHref":"Titanium.Web.Proxy.Http.Responses.OkResponse.html","topicUid":"Titanium.Web.Proxy.Http.Responses.OkResponse","type":"Class"},{"name":"RedirectResponse","href":"Titanium.Web.Proxy.Http.Responses.RedirectResponse.html","topicHref":"Titanium.Web.Proxy.Http.Responses.RedirectResponse.html","topicUid":"Titanium.Web.Proxy.Http.Responses.RedirectResponse","type":"Class"}]},{"name":"Titanium.Web.Proxy.Logging","href":"Titanium.Web.Proxy.Logging.html","topicHref":"Titanium.Web.Proxy.Logging.html","topicUid":"Titanium.Web.Proxy.Logging","type":"Namespace","items":[{"name":"ProxyLoggingOptions","href":"Titanium.Web.Proxy.Logging.ProxyLoggingOptions.html","topicHref":"Titanium.Web.Proxy.Logging.ProxyLoggingOptions.html","topicUid":"Titanium.Web.Proxy.Logging.ProxyLoggingOptions","type":"Class"}]},{"name":"Titanium.Web.Proxy.Middleware","href":"Titanium.Web.Proxy.Middleware.html","topicHref":"Titanium.Web.Proxy.Middleware.html","topicUid":"Titanium.Web.Proxy.Middleware","type":"Namespace","items":[{"name":"ProxyMiddlewarePipeline","href":"Titanium.Web.Proxy.Middleware.ProxyMiddlewarePipeline.html","topicHref":"Titanium.Web.Proxy.Middleware.ProxyMiddlewarePipeline.html","topicUid":"Titanium.Web.Proxy.Middleware.ProxyMiddlewarePipeline","type":"Class"}]},{"name":"Titanium.Web.Proxy.Models","href":"Titanium.Web.Proxy.Models.html","topicHref":"Titanium.Web.Proxy.Models.html","topicUid":"Titanium.Web.Proxy.Models","type":"Namespace","items":[{"name":"ExplicitProxyEndPoint","href":"Titanium.Web.Proxy.Models.ExplicitProxyEndPoint.html","topicHref":"Titanium.Web.Proxy.Models.ExplicitProxyEndPoint.html","topicUid":"Titanium.Web.Proxy.Models.ExplicitProxyEndPoint","type":"Class"},{"name":"ExternalProxy","href":"Titanium.Web.Proxy.Models.ExternalProxy.html","topicHref":"Titanium.Web.Proxy.Models.ExternalProxy.html","topicUid":"Titanium.Web.Proxy.Models.ExternalProxy","type":"Class"},{"name":"ExternalProxyType","href":"Titanium.Web.Proxy.Models.ExternalProxyType.html","topicHref":"Titanium.Web.Proxy.Models.ExternalProxyType.html","topicUid":"Titanium.Web.Proxy.Models.ExternalProxyType","type":"Enum"},{"name":"HttpHeader","href":"Titanium.Web.Proxy.Models.HttpHeader.html","topicHref":"Titanium.Web.Proxy.Models.HttpHeader.html","topicUid":"Titanium.Web.Proxy.Models.HttpHeader","type":"Class"},{"name":"HttpInterceptionContext","href":"Titanium.Web.Proxy.Models.HttpInterceptionContext.html","topicHref":"Titanium.Web.Proxy.Models.HttpInterceptionContext.html","topicUid":"Titanium.Web.Proxy.Models.HttpInterceptionContext","type":"Struct"},{"name":"IExternalProxy","href":"Titanium.Web.Proxy.Models.IExternalProxy.html","topicHref":"Titanium.Web.Proxy.Models.IExternalProxy.html","topicUid":"Titanium.Web.Proxy.Models.IExternalProxy","type":"Interface"},{"name":"OriginHttpVersionPolicy","href":"Titanium.Web.Proxy.Models.OriginHttpVersionPolicy.html","topicHref":"Titanium.Web.Proxy.Models.OriginHttpVersionPolicy.html","topicUid":"Titanium.Web.Proxy.Models.OriginHttpVersionPolicy","type":"Enum"},{"name":"ProxyAuthenticationContext","href":"Titanium.Web.Proxy.Models.ProxyAuthenticationContext.html","topicHref":"Titanium.Web.Proxy.Models.ProxyAuthenticationContext.html","topicUid":"Titanium.Web.Proxy.Models.ProxyAuthenticationContext","type":"Class"},{"name":"ProxyAuthenticationResult","href":"Titanium.Web.Proxy.Models.ProxyAuthenticationResult.html","topicHref":"Titanium.Web.Proxy.Models.ProxyAuthenticationResult.html","topicUid":"Titanium.Web.Proxy.Models.ProxyAuthenticationResult","type":"Enum"},{"name":"ProxyEndPoint","href":"Titanium.Web.Proxy.Models.ProxyEndPoint.html","topicHref":"Titanium.Web.Proxy.Models.ProxyEndPoint.html","topicUid":"Titanium.Web.Proxy.Models.ProxyEndPoint","type":"Class"},{"name":"ProxyProtocolType","href":"Titanium.Web.Proxy.Models.ProxyProtocolType.html","topicHref":"Titanium.Web.Proxy.Models.ProxyProtocolType.html","topicUid":"Titanium.Web.Proxy.Models.ProxyProtocolType","type":"Enum"},{"name":"SocksProxyEndPoint","href":"Titanium.Web.Proxy.Models.SocksProxyEndPoint.html","topicHref":"Titanium.Web.Proxy.Models.SocksProxyEndPoint.html","topicUid":"Titanium.Web.Proxy.Models.SocksProxyEndPoint","type":"Class"},{"name":"TransparentBaseProxyEndPoint","href":"Titanium.Web.Proxy.Models.TransparentBaseProxyEndPoint.html","topicHref":"Titanium.Web.Proxy.Models.TransparentBaseProxyEndPoint.html","topicUid":"Titanium.Web.Proxy.Models.TransparentBaseProxyEndPoint","type":"Class"},{"name":"TransparentProxyEndPoint","href":"Titanium.Web.Proxy.Models.TransparentProxyEndPoint.html","topicHref":"Titanium.Web.Proxy.Models.TransparentProxyEndPoint.html","topicUid":"Titanium.Web.Proxy.Models.TransparentProxyEndPoint","type":"Class"},{"name":"TransparentQuicProxyEndPoint","href":"Titanium.Web.Proxy.Models.TransparentQuicProxyEndPoint.html","topicHref":"Titanium.Web.Proxy.Models.TransparentQuicProxyEndPoint.html","topicUid":"Titanium.Web.Proxy.Models.TransparentQuicProxyEndPoint","type":"Class"},{"name":"UpstreamHttpProtocol","href":"Titanium.Web.Proxy.Models.UpstreamHttpProtocol.html","topicHref":"Titanium.Web.Proxy.Models.UpstreamHttpProtocol.html","topicUid":"Titanium.Web.Proxy.Models.UpstreamHttpProtocol","type":"Enum"},{"name":"WinAuthCredentials","href":"Titanium.Web.Proxy.Models.WinAuthCredentials.html","topicHref":"Titanium.Web.Proxy.Models.WinAuthCredentials.html","topicUid":"Titanium.Web.Proxy.Models.WinAuthCredentials","type":"Class"}]},{"name":"Titanium.Web.Proxy.Network","href":"Titanium.Web.Proxy.Network.html","topicHref":"Titanium.Web.Proxy.Network.html","topicUid":"Titanium.Web.Proxy.Network","type":"Namespace","items":[{"name":"CertificateEngine","href":"Titanium.Web.Proxy.Network.CertificateEngine.html","topicHref":"Titanium.Web.Proxy.Network.CertificateEngine.html","topicUid":"Titanium.Web.Proxy.Network.CertificateEngine","type":"Enum"},{"name":"CertificateKeyAlgorithm","href":"Titanium.Web.Proxy.Network.CertificateKeyAlgorithm.html","topicHref":"Titanium.Web.Proxy.Network.CertificateKeyAlgorithm.html","topicUid":"Titanium.Web.Proxy.Network.CertificateKeyAlgorithm","type":"Enum"},{"name":"CertificateManager","href":"Titanium.Web.Proxy.Network.CertificateManager.html","topicHref":"Titanium.Web.Proxy.Network.CertificateManager.html","topicUid":"Titanium.Web.Proxy.Network.CertificateManager","type":"Class"},{"name":"DefaultCertificateDiskCache","href":"Titanium.Web.Proxy.Network.DefaultCertificateDiskCache.html","topicHref":"Titanium.Web.Proxy.Network.DefaultCertificateDiskCache.html","topicUid":"Titanium.Web.Proxy.Network.DefaultCertificateDiskCache","type":"Class"},{"name":"ICertificateCache","href":"Titanium.Web.Proxy.Network.ICertificateCache.html","topicHref":"Titanium.Web.Proxy.Network.ICertificateCache.html","topicUid":"Titanium.Web.Proxy.Network.ICertificateCache","type":"Interface"}]},{"name":"Titanium.Web.Proxy.Network.Quic","href":"Titanium.Web.Proxy.Network.Quic.html","topicHref":"Titanium.Web.Proxy.Network.Quic.html","topicUid":"Titanium.Web.Proxy.Network.Quic","type":"Namespace","items":[{"name":"IOriginalDestinationResolver","href":"Titanium.Web.Proxy.Network.Quic.IOriginalDestinationResolver.html","topicHref":"Titanium.Web.Proxy.Network.Quic.IOriginalDestinationResolver.html","topicUid":"Titanium.Web.Proxy.Network.Quic.IOriginalDestinationResolver","type":"Interface"}]},{"name":"Titanium.Web.Proxy.Options","href":"Titanium.Web.Proxy.Options.html","topicHref":"Titanium.Web.Proxy.Options.html","topicUid":"Titanium.Web.Proxy.Options","type":"Namespace","items":[{"name":"PolicyFamily","href":"Titanium.Web.Proxy.Options.PolicyFamily.html","topicHref":"Titanium.Web.Proxy.Options.PolicyFamily.html","topicUid":"Titanium.Web.Proxy.Options.PolicyFamily","type":"Enum"},{"name":"PolicyMode","href":"Titanium.Web.Proxy.Options.PolicyMode.html","topicHref":"Titanium.Web.Proxy.Options.PolicyMode.html","topicUid":"Titanium.Web.Proxy.Options.PolicyMode","type":"Enum"},{"name":"ProxyPolicyModes","href":"Titanium.Web.Proxy.Options.ProxyPolicyModes.html","topicHref":"Titanium.Web.Proxy.Options.ProxyPolicyModes.html","topicUid":"Titanium.Web.Proxy.Options.ProxyPolicyModes","type":"Class"},{"name":"ProxyProfile","href":"Titanium.Web.Proxy.Options.ProxyProfile.html","topicHref":"Titanium.Web.Proxy.Options.ProxyProfile.html","topicUid":"Titanium.Web.Proxy.Options.ProxyProfile","type":"Enum"},{"name":"ProxyProfileSettings","href":"Titanium.Web.Proxy.Options.ProxyProfileSettings.html","topicHref":"Titanium.Web.Proxy.Options.ProxyProfileSettings.html","topicUid":"Titanium.Web.Proxy.Options.ProxyProfileSettings","type":"Class"},{"name":"ProxyResourceLimits","href":"Titanium.Web.Proxy.Options.ProxyResourceLimits.html","topicHref":"Titanium.Web.Proxy.Options.ProxyResourceLimits.html","topicUid":"Titanium.Web.Proxy.Options.ProxyResourceLimits","type":"Class"},{"name":"ProxyTimeoutOptions","href":"Titanium.Web.Proxy.Options.ProxyTimeoutOptions.html","topicHref":"Titanium.Web.Proxy.Options.ProxyTimeoutOptions.html","topicUid":"Titanium.Web.Proxy.Options.ProxyTimeoutOptions","type":"Class"},{"name":"ResolvedSessionPolicy","href":"Titanium.Web.Proxy.Options.ResolvedSessionPolicy.html","topicHref":"Titanium.Web.Proxy.Options.ResolvedSessionPolicy.html","topicUid":"Titanium.Web.Proxy.Options.ResolvedSessionPolicy","type":"Class"}]},{"name":"Titanium.Web.Proxy.Routing","href":"Titanium.Web.Proxy.Routing.html","topicHref":"Titanium.Web.Proxy.Routing.html","topicUid":"Titanium.Web.Proxy.Routing","type":"Namespace","items":[{"name":"ReverseProxyFastPath","href":"Titanium.Web.Proxy.Routing.ReverseProxyFastPath.html","topicHref":"Titanium.Web.Proxy.Routing.ReverseProxyFastPath.html","topicUid":"Titanium.Web.Proxy.Routing.ReverseProxyFastPath","type":"Class"},{"name":"RouteMatcher","href":"Titanium.Web.Proxy.Routing.RouteMatcher.html","topicHref":"Titanium.Web.Proxy.Routing.RouteMatcher.html","topicUid":"Titanium.Web.Proxy.Routing.RouteMatcher","type":"Class"}]},{"name":"Titanium.Web.Proxy.StreamExtended","href":"Titanium.Web.Proxy.StreamExtended.html","topicHref":"Titanium.Web.Proxy.StreamExtended.html","topicUid":"Titanium.Web.Proxy.StreamExtended","type":"Namespace","items":[{"name":"ClientHelloInfo","href":"Titanium.Web.Proxy.StreamExtended.ClientHelloInfo.html","topicHref":"Titanium.Web.Proxy.StreamExtended.ClientHelloInfo.html","topicUid":"Titanium.Web.Proxy.StreamExtended.ClientHelloInfo","type":"Class"},{"name":"ServerHelloInfo","href":"Titanium.Web.Proxy.StreamExtended.ServerHelloInfo.html","topicHref":"Titanium.Web.Proxy.StreamExtended.ServerHelloInfo.html","topicUid":"Titanium.Web.Proxy.StreamExtended.ServerHelloInfo","type":"Class"}]},{"name":"Titanium.Web.Proxy.StreamExtended.BufferPool","href":"Titanium.Web.Proxy.StreamExtended.BufferPool.html","topicHref":"Titanium.Web.Proxy.StreamExtended.BufferPool.html","topicUid":"Titanium.Web.Proxy.StreamExtended.BufferPool","type":"Namespace","items":[{"name":"IBufferPool","href":"Titanium.Web.Proxy.StreamExtended.BufferPool.IBufferPool.html","topicHref":"Titanium.Web.Proxy.StreamExtended.BufferPool.IBufferPool.html","topicUid":"Titanium.Web.Proxy.StreamExtended.BufferPool.IBufferPool","type":"Interface"}]},{"name":"Titanium.Web.Proxy.StreamExtended.Models","href":"Titanium.Web.Proxy.StreamExtended.Models.html","topicHref":"Titanium.Web.Proxy.StreamExtended.Models.html","topicUid":"Titanium.Web.Proxy.StreamExtended.Models","type":"Namespace","items":[{"name":"SslExtension","href":"Titanium.Web.Proxy.StreamExtended.Models.SslExtension.html","topicHref":"Titanium.Web.Proxy.StreamExtended.Models.SslExtension.html","topicUid":"Titanium.Web.Proxy.StreamExtended.Models.SslExtension","type":"Class"}]},{"name":"Titanium.Web.Proxy.StreamExtended.Network","href":"Titanium.Web.Proxy.StreamExtended.Network.html","topicHref":"Titanium.Web.Proxy.StreamExtended.Network.html","topicUid":"Titanium.Web.Proxy.StreamExtended.Network","type":"Namespace","items":[{"name":"DataEventArgs","href":"Titanium.Web.Proxy.StreamExtended.Network.DataEventArgs.html","topicHref":"Titanium.Web.Proxy.StreamExtended.Network.DataEventArgs.html","topicUid":"Titanium.Web.Proxy.StreamExtended.Network.DataEventArgs","type":"Class"},{"name":"IHttpStreamReader","href":"Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamReader.html","topicHref":"Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamReader.html","topicUid":"Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamReader","type":"Interface"},{"name":"IHttpStreamWriter","href":"Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamWriter.html","topicHref":"Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamWriter.html","topicUid":"Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamWriter","type":"Interface"},{"name":"ILineStream","href":"Titanium.Web.Proxy.StreamExtended.Network.ILineStream.html","topicHref":"Titanium.Web.Proxy.StreamExtended.Network.ILineStream.html","topicUid":"Titanium.Web.Proxy.StreamExtended.Network.ILineStream","type":"Interface"},{"name":"IPeekStream","href":"Titanium.Web.Proxy.StreamExtended.Network.IPeekStream.html","topicHref":"Titanium.Web.Proxy.StreamExtended.Network.IPeekStream.html","topicUid":"Titanium.Web.Proxy.StreamExtended.Network.IPeekStream","type":"Interface"}]},{"name":"Titanium.Web.Proxy.Transforms","href":"Titanium.Web.Proxy.Transforms.html","topicHref":"Titanium.Web.Proxy.Transforms.html","topicUid":"Titanium.Web.Proxy.Transforms","type":"Namespace","items":[{"name":"TransformEngine","href":"Titanium.Web.Proxy.Transforms.TransformEngine.html","topicHref":"Titanium.Web.Proxy.Transforms.TransformEngine.html","topicUid":"Titanium.Web.Proxy.Transforms.TransformEngine","type":"Class"}]}],"memberLayout":"SamePage"} +{"items":[{"name":"Titanium.Web.Proxy","href":"Titanium.Web.Proxy.html","topicHref":"Titanium.Web.Proxy.html","topicUid":"Titanium.Web.Proxy","type":"Namespace","items":[{"name":"ClientProcessId","href":"Titanium.Web.Proxy.ClientProcessId.html","topicHref":"Titanium.Web.Proxy.ClientProcessId.html","topicUid":"Titanium.Web.Proxy.ClientProcessId","type":"Class"},{"name":"MitmExclusionDefaults","href":"Titanium.Web.Proxy.MitmExclusionDefaults.html","topicHref":"Titanium.Web.Proxy.MitmExclusionDefaults.html","topicUid":"Titanium.Web.Proxy.MitmExclusionDefaults","type":"Class"},{"name":"MitmExclusionMode","href":"Titanium.Web.Proxy.MitmExclusionMode.html","topicHref":"Titanium.Web.Proxy.MitmExclusionMode.html","topicUid":"Titanium.Web.Proxy.MitmExclusionMode","type":"Enum"},{"name":"ProxyLimits","href":"Titanium.Web.Proxy.ProxyLimits.html","topicHref":"Titanium.Web.Proxy.ProxyLimits.html","topicUid":"Titanium.Web.Proxy.ProxyLimits","type":"Class"},{"name":"ProxyServer","href":"Titanium.Web.Proxy.ProxyServer.html","topicHref":"Titanium.Web.Proxy.ProxyServer.html","topicUid":"Titanium.Web.Proxy.ProxyServer","type":"Class"},{"name":"SystemProxyBypassRuleMode","href":"Titanium.Web.Proxy.SystemProxyBypassRuleMode.html","topicHref":"Titanium.Web.Proxy.SystemProxyBypassRuleMode.html","topicUid":"Titanium.Web.Proxy.SystemProxyBypassRuleMode","type":"Enum"},{"name":"SystemProxyChangeResult","href":"Titanium.Web.Proxy.SystemProxyChangeResult.html","topicHref":"Titanium.Web.Proxy.SystemProxyChangeResult.html","topicUid":"Titanium.Web.Proxy.SystemProxyChangeResult","type":"Struct"},{"name":"SystemProxyLoopbackPlacement","href":"Titanium.Web.Proxy.SystemProxyLoopbackPlacement.html","topicHref":"Titanium.Web.Proxy.SystemProxyLoopbackPlacement.html","topicUid":"Titanium.Web.Proxy.SystemProxyLoopbackPlacement","type":"Enum"},{"name":"SystemProxySettings","href":"Titanium.Web.Proxy.SystemProxySettings.html","topicHref":"Titanium.Web.Proxy.SystemProxySettings.html","topicUid":"Titanium.Web.Proxy.SystemProxySettings","type":"Class"},{"name":"WebSocketDecoder","href":"Titanium.Web.Proxy.WebSocketDecoder.html","topicHref":"Titanium.Web.Proxy.WebSocketDecoder.html","topicUid":"Titanium.Web.Proxy.WebSocketDecoder","type":"Class"},{"name":"WebSocketFrame","href":"Titanium.Web.Proxy.WebSocketFrame.html","topicHref":"Titanium.Web.Proxy.WebSocketFrame.html","topicUid":"Titanium.Web.Proxy.WebSocketFrame","type":"Class"},{"name":"WebSocketFrameEncoder","href":"Titanium.Web.Proxy.WebSocketFrameEncoder.html","topicHref":"Titanium.Web.Proxy.WebSocketFrameEncoder.html","topicUid":"Titanium.Web.Proxy.WebSocketFrameEncoder","type":"Class"},{"name":"WebSocketFrameWriter","href":"Titanium.Web.Proxy.WebSocketFrameWriter.html","topicHref":"Titanium.Web.Proxy.WebSocketFrameWriter.html","topicUid":"Titanium.Web.Proxy.WebSocketFrameWriter","type":"Class"},{"name":"WebSocketProtocolException","href":"Titanium.Web.Proxy.WebSocketProtocolException.html","topicHref":"Titanium.Web.Proxy.WebSocketProtocolException.html","topicUid":"Titanium.Web.Proxy.WebSocketProtocolException","type":"Class"},{"name":"WebsocketOpCode","href":"Titanium.Web.Proxy.WebsocketOpCode.html","topicHref":"Titanium.Web.Proxy.WebsocketOpCode.html","topicUid":"Titanium.Web.Proxy.WebsocketOpCode","type":"Enum"}]},{"name":"Titanium.Web.Proxy.Caching","href":"Titanium.Web.Proxy.Caching.html","topicHref":"Titanium.Web.Proxy.Caching.html","topicUid":"Titanium.Web.Proxy.Caching","type":"Namespace","items":[{"name":"HttpResponseCacheMiddleware","href":"Titanium.Web.Proxy.Caching.HttpResponseCacheMiddleware.html","topicHref":"Titanium.Web.Proxy.Caching.HttpResponseCacheMiddleware.html","topicUid":"Titanium.Web.Proxy.Caching.HttpResponseCacheMiddleware","type":"Class"},{"name":"MemoryHttpResponseCache","href":"Titanium.Web.Proxy.Caching.MemoryHttpResponseCache.html","topicHref":"Titanium.Web.Proxy.Caching.MemoryHttpResponseCache.html","topicUid":"Titanium.Web.Proxy.Caching.MemoryHttpResponseCache","type":"Class"}]},{"name":"Titanium.Web.Proxy.Clusters","href":"Titanium.Web.Proxy.Clusters.html","topicHref":"Titanium.Web.Proxy.Clusters.html","topicUid":"Titanium.Web.Proxy.Clusters","type":"Namespace","items":[{"name":"ClusterManager","href":"Titanium.Web.Proxy.Clusters.ClusterManager.html","topicHref":"Titanium.Web.Proxy.Clusters.ClusterManager.html","topicUid":"Titanium.Web.Proxy.Clusters.ClusterManager","type":"Class"},{"name":"DestinationHealthTracker","href":"Titanium.Web.Proxy.Clusters.DestinationHealthTracker.html","topicHref":"Titanium.Web.Proxy.Clusters.DestinationHealthTracker.html","topicUid":"Titanium.Web.Proxy.Clusters.DestinationHealthTracker","type":"Class"},{"name":"LoadBalancer","href":"Titanium.Web.Proxy.Clusters.LoadBalancer.html","topicHref":"Titanium.Web.Proxy.Clusters.LoadBalancer.html","topicUid":"Titanium.Web.Proxy.Clusters.LoadBalancer","type":"Class"}]},{"name":"Titanium.Web.Proxy.Diagnostics","href":"Titanium.Web.Proxy.Diagnostics.html","topicHref":"Titanium.Web.Proxy.Diagnostics.html","topicUid":"Titanium.Web.Proxy.Diagnostics","type":"Namespace","items":[{"name":"ClientTlsTiming","href":"Titanium.Web.Proxy.Diagnostics.ClientTlsTiming.html","topicHref":"Titanium.Web.Proxy.Diagnostics.ClientTlsTiming.html","topicUid":"Titanium.Web.Proxy.Diagnostics.ClientTlsTiming","type":"Class"},{"name":"HttpRequestTiming","href":"Titanium.Web.Proxy.Diagnostics.HttpRequestTiming.html","topicHref":"Titanium.Web.Proxy.Diagnostics.HttpRequestTiming.html","topicUid":"Titanium.Web.Proxy.Diagnostics.HttpRequestTiming","type":"Class"},{"name":"TunnelConnectTiming","href":"Titanium.Web.Proxy.Diagnostics.TunnelConnectTiming.html","topicHref":"Titanium.Web.Proxy.Diagnostics.TunnelConnectTiming.html","topicUid":"Titanium.Web.Proxy.Diagnostics.TunnelConnectTiming","type":"Class"},{"name":"UpstreamConnectionTiming","href":"Titanium.Web.Proxy.Diagnostics.UpstreamConnectionTiming.html","topicHref":"Titanium.Web.Proxy.Diagnostics.UpstreamConnectionTiming.html","topicUid":"Titanium.Web.Proxy.Diagnostics.UpstreamConnectionTiming","type":"Class"}]},{"name":"Titanium.Web.Proxy.EventArguments","href":"Titanium.Web.Proxy.EventArguments.html","topicHref":"Titanium.Web.Proxy.EventArguments.html","topicUid":"Titanium.Web.Proxy.EventArguments","type":"Namespace","items":[{"name":"AsyncEventHandler","href":"Titanium.Web.Proxy.EventArguments.AsyncEventHandler-1.html","topicHref":"Titanium.Web.Proxy.EventArguments.AsyncEventHandler-1.html","topicUid":"Titanium.Web.Proxy.EventArguments.AsyncEventHandler`1","type":"Delegate"},{"name":"BeforeBodyWriteEventArgs","href":"Titanium.Web.Proxy.EventArguments.BeforeBodyWriteEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.BeforeBodyWriteEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.BeforeBodyWriteEventArgs","type":"Class"},{"name":"BeforeHttpAuthenticateEventArgs","href":"Titanium.Web.Proxy.EventArguments.BeforeHttpAuthenticateEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.BeforeHttpAuthenticateEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.BeforeHttpAuthenticateEventArgs","type":"Class"},{"name":"BeforeQuicAuthenticateEventArgs","href":"Titanium.Web.Proxy.EventArguments.BeforeQuicAuthenticateEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.BeforeQuicAuthenticateEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.BeforeQuicAuthenticateEventArgs","type":"Class"},{"name":"BeforeSslAuthenticateEventArgs","href":"Titanium.Web.Proxy.EventArguments.BeforeSslAuthenticateEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.BeforeSslAuthenticateEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.BeforeSslAuthenticateEventArgs","type":"Class"},{"name":"CertificateSelectionEventArgs","href":"Titanium.Web.Proxy.EventArguments.CertificateSelectionEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.CertificateSelectionEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.CertificateSelectionEventArgs","type":"Class"},{"name":"CertificateValidationEventArgs","href":"Titanium.Web.Proxy.EventArguments.CertificateValidationEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.CertificateValidationEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.CertificateValidationEventArgs","type":"Class"},{"name":"MultipartRequestPartSentEventArgs","href":"Titanium.Web.Proxy.EventArguments.MultipartRequestPartSentEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.MultipartRequestPartSentEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.MultipartRequestPartSentEventArgs","type":"Class"},{"name":"ProxyEventArgsBase","href":"Titanium.Web.Proxy.EventArguments.ProxyEventArgsBase.html","topicHref":"Titanium.Web.Proxy.EventArguments.ProxyEventArgsBase.html","topicUid":"Titanium.Web.Proxy.EventArguments.ProxyEventArgsBase","type":"Class"},{"name":"SessionEventArgs","href":"Titanium.Web.Proxy.EventArguments.SessionEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.SessionEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.SessionEventArgs","type":"Class"},{"name":"SessionEventArgsBase","href":"Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.html","topicHref":"Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.html","topicUid":"Titanium.Web.Proxy.EventArguments.SessionEventArgsBase","type":"Class"},{"name":"SocksAuthenticateEventArgs","href":"Titanium.Web.Proxy.EventArguments.SocksAuthenticateEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.SocksAuthenticateEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.SocksAuthenticateEventArgs","type":"Class"},{"name":"TunnelConnectFailureEventArgs","href":"Titanium.Web.Proxy.EventArguments.TunnelConnectFailureEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.TunnelConnectFailureEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.TunnelConnectFailureEventArgs","type":"Class"},{"name":"TunnelConnectSessionEventArgs","href":"Titanium.Web.Proxy.EventArguments.TunnelConnectSessionEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.TunnelConnectSessionEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.TunnelConnectSessionEventArgs","type":"Class"},{"name":"WebSocketFrameDirection","href":"Titanium.Web.Proxy.EventArguments.WebSocketFrameDirection.html","topicHref":"Titanium.Web.Proxy.EventArguments.WebSocketFrameDirection.html","topicUid":"Titanium.Web.Proxy.EventArguments.WebSocketFrameDirection","type":"Enum"},{"name":"WebSocketFrameInterceptAction","href":"Titanium.Web.Proxy.EventArguments.WebSocketFrameInterceptAction.html","topicHref":"Titanium.Web.Proxy.EventArguments.WebSocketFrameInterceptAction.html","topicUid":"Titanium.Web.Proxy.EventArguments.WebSocketFrameInterceptAction","type":"Enum"},{"name":"WebSocketFrameInterceptEventArgs","href":"Titanium.Web.Proxy.EventArguments.WebSocketFrameInterceptEventArgs.html","topicHref":"Titanium.Web.Proxy.EventArguments.WebSocketFrameInterceptEventArgs.html","topicUid":"Titanium.Web.Proxy.EventArguments.WebSocketFrameInterceptEventArgs","type":"Class"}]},{"name":"Titanium.Web.Proxy.Exceptions","href":"Titanium.Web.Proxy.Exceptions.html","topicHref":"Titanium.Web.Proxy.Exceptions.html","topicUid":"Titanium.Web.Proxy.Exceptions","type":"Namespace","items":[{"name":"BodyNotFoundException","href":"Titanium.Web.Proxy.Exceptions.BodyNotFoundException.html","topicHref":"Titanium.Web.Proxy.Exceptions.BodyNotFoundException.html","topicUid":"Titanium.Web.Proxy.Exceptions.BodyNotFoundException","type":"Class"},{"name":"OutboundDestinationBlockedException","href":"Titanium.Web.Proxy.Exceptions.OutboundDestinationBlockedException.html","topicHref":"Titanium.Web.Proxy.Exceptions.OutboundDestinationBlockedException.html","topicUid":"Titanium.Web.Proxy.Exceptions.OutboundDestinationBlockedException","type":"Class"},{"name":"ProxyAuthorizationException","href":"Titanium.Web.Proxy.Exceptions.ProxyAuthorizationException.html","topicHref":"Titanium.Web.Proxy.Exceptions.ProxyAuthorizationException.html","topicUid":"Titanium.Web.Proxy.Exceptions.ProxyAuthorizationException","type":"Class"},{"name":"ProxyConnectException","href":"Titanium.Web.Proxy.Exceptions.ProxyConnectException.html","topicHref":"Titanium.Web.Proxy.Exceptions.ProxyConnectException.html","topicUid":"Titanium.Web.Proxy.Exceptions.ProxyConnectException","type":"Class"},{"name":"ProxyException","href":"Titanium.Web.Proxy.Exceptions.ProxyException.html","topicHref":"Titanium.Web.Proxy.Exceptions.ProxyException.html","topicUid":"Titanium.Web.Proxy.Exceptions.ProxyException","type":"Class"},{"name":"ProxyHttpException","href":"Titanium.Web.Proxy.Exceptions.ProxyHttpException.html","topicHref":"Titanium.Web.Proxy.Exceptions.ProxyHttpException.html","topicUid":"Titanium.Web.Proxy.Exceptions.ProxyHttpException","type":"Class"},{"name":"ProxyTimeoutException","href":"Titanium.Web.Proxy.Exceptions.ProxyTimeoutException.html","topicHref":"Titanium.Web.Proxy.Exceptions.ProxyTimeoutException.html","topicUid":"Titanium.Web.Proxy.Exceptions.ProxyTimeoutException","type":"Class"},{"name":"ProxyTimeoutKind","href":"Titanium.Web.Proxy.Exceptions.ProxyTimeoutKind.html","topicHref":"Titanium.Web.Proxy.Exceptions.ProxyTimeoutKind.html","topicUid":"Titanium.Web.Proxy.Exceptions.ProxyTimeoutKind","type":"Enum"},{"name":"UpstreamProxyConnectException","href":"Titanium.Web.Proxy.Exceptions.UpstreamProxyConnectException.html","topicHref":"Titanium.Web.Proxy.Exceptions.UpstreamProxyConnectException.html","topicUid":"Titanium.Web.Proxy.Exceptions.UpstreamProxyConnectException","type":"Class"}]},{"name":"Titanium.Web.Proxy.Helpers","href":"Titanium.Web.Proxy.Helpers.html","topicHref":"Titanium.Web.Proxy.Helpers.html","topicUid":"Titanium.Web.Proxy.Helpers","type":"Namespace","items":[{"name":"UnixProxyBypassMapper","href":"Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html","topicHref":"Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html","topicUid":"Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper","type":"Class"}]},{"name":"Titanium.Web.Proxy.Http","href":"Titanium.Web.Proxy.Http.html","topicHref":"Titanium.Web.Proxy.Http.html","topicUid":"Titanium.Web.Proxy.Http","type":"Namespace","items":[{"name":"ConnectRequest","href":"Titanium.Web.Proxy.Http.ConnectRequest.html","topicHref":"Titanium.Web.Proxy.Http.ConnectRequest.html","topicUid":"Titanium.Web.Proxy.Http.ConnectRequest","type":"Class"},{"name":"ConnectResponse","href":"Titanium.Web.Proxy.Http.ConnectResponse.html","topicHref":"Titanium.Web.Proxy.Http.ConnectResponse.html","topicUid":"Titanium.Web.Proxy.Http.ConnectResponse","type":"Class"},{"name":"HeaderCollection","href":"Titanium.Web.Proxy.Http.HeaderCollection.html","topicHref":"Titanium.Web.Proxy.Http.HeaderCollection.html","topicUid":"Titanium.Web.Proxy.Http.HeaderCollection","type":"Class"},{"name":"HeaderCollection.Enumerator","href":"Titanium.Web.Proxy.Http.HeaderCollection.Enumerator.html","topicHref":"Titanium.Web.Proxy.Http.HeaderCollection.Enumerator.html","topicUid":"Titanium.Web.Proxy.Http.HeaderCollection.Enumerator","type":"Struct"},{"name":"HttpWebClient","href":"Titanium.Web.Proxy.Http.HttpWebClient.html","topicHref":"Titanium.Web.Proxy.Http.HttpWebClient.html","topicUid":"Titanium.Web.Proxy.Http.HttpWebClient","type":"Class"},{"name":"KnownHeader","href":"Titanium.Web.Proxy.Http.KnownHeader.html","topicHref":"Titanium.Web.Proxy.Http.KnownHeader.html","topicUid":"Titanium.Web.Proxy.Http.KnownHeader","type":"Class"},{"name":"KnownHeaders","href":"Titanium.Web.Proxy.Http.KnownHeaders.html","topicHref":"Titanium.Web.Proxy.Http.KnownHeaders.html","topicUid":"Titanium.Web.Proxy.Http.KnownHeaders","type":"Class"},{"name":"ProxyResults","href":"Titanium.Web.Proxy.Http.ProxyResults.html","topicHref":"Titanium.Web.Proxy.Http.ProxyResults.html","topicUid":"Titanium.Web.Proxy.Http.ProxyResults","type":"Class"},{"name":"Request","href":"Titanium.Web.Proxy.Http.Request.html","topicHref":"Titanium.Web.Proxy.Http.Request.html","topicUid":"Titanium.Web.Proxy.Http.Request","type":"Class"},{"name":"RequestResponseBase","href":"Titanium.Web.Proxy.Http.RequestResponseBase.html","topicHref":"Titanium.Web.Proxy.Http.RequestResponseBase.html","topicUid":"Titanium.Web.Proxy.Http.RequestResponseBase","type":"Class"},{"name":"Response","href":"Titanium.Web.Proxy.Http.Response.html","topicHref":"Titanium.Web.Proxy.Http.Response.html","topicUid":"Titanium.Web.Proxy.Http.Response","type":"Class"},{"name":"StreamingProxyResult","href":"Titanium.Web.Proxy.Http.StreamingProxyResult.html","topicHref":"Titanium.Web.Proxy.Http.StreamingProxyResult.html","topicUid":"Titanium.Web.Proxy.Http.StreamingProxyResult","type":"Struct"},{"name":"TunnelType","href":"Titanium.Web.Proxy.Http.TunnelType.html","topicHref":"Titanium.Web.Proxy.Http.TunnelType.html","topicUid":"Titanium.Web.Proxy.Http.TunnelType","type":"Enum"}]},{"name":"Titanium.Web.Proxy.Http.Responses","href":"Titanium.Web.Proxy.Http.Responses.html","topicHref":"Titanium.Web.Proxy.Http.Responses.html","topicUid":"Titanium.Web.Proxy.Http.Responses","type":"Namespace","items":[{"name":"GenericResponse","href":"Titanium.Web.Proxy.Http.Responses.GenericResponse.html","topicHref":"Titanium.Web.Proxy.Http.Responses.GenericResponse.html","topicUid":"Titanium.Web.Proxy.Http.Responses.GenericResponse","type":"Class"},{"name":"OkResponse","href":"Titanium.Web.Proxy.Http.Responses.OkResponse.html","topicHref":"Titanium.Web.Proxy.Http.Responses.OkResponse.html","topicUid":"Titanium.Web.Proxy.Http.Responses.OkResponse","type":"Class"},{"name":"RedirectResponse","href":"Titanium.Web.Proxy.Http.Responses.RedirectResponse.html","topicHref":"Titanium.Web.Proxy.Http.Responses.RedirectResponse.html","topicUid":"Titanium.Web.Proxy.Http.Responses.RedirectResponse","type":"Class"}]},{"name":"Titanium.Web.Proxy.Http3","href":"Titanium.Web.Proxy.Http3.html","topicHref":"Titanium.Web.Proxy.Http3.html","topicUid":"Titanium.Web.Proxy.Http3","type":"Namespace","items":[{"name":"Http3NativeBootstrap","href":"Titanium.Web.Proxy.Http3.Http3NativeBootstrap.html","topicHref":"Titanium.Web.Proxy.Http3.Http3NativeBootstrap.html","topicUid":"Titanium.Web.Proxy.Http3.Http3NativeBootstrap","type":"Class"}]},{"name":"Titanium.Web.Proxy.Logging","href":"Titanium.Web.Proxy.Logging.html","topicHref":"Titanium.Web.Proxy.Logging.html","topicUid":"Titanium.Web.Proxy.Logging","type":"Namespace","items":[{"name":"ProxyLoggingOptions","href":"Titanium.Web.Proxy.Logging.ProxyLoggingOptions.html","topicHref":"Titanium.Web.Proxy.Logging.ProxyLoggingOptions.html","topicUid":"Titanium.Web.Proxy.Logging.ProxyLoggingOptions","type":"Class"}]},{"name":"Titanium.Web.Proxy.Middleware","href":"Titanium.Web.Proxy.Middleware.html","topicHref":"Titanium.Web.Proxy.Middleware.html","topicUid":"Titanium.Web.Proxy.Middleware","type":"Namespace","items":[{"name":"ProxyMiddlewarePipeline","href":"Titanium.Web.Proxy.Middleware.ProxyMiddlewarePipeline.html","topicHref":"Titanium.Web.Proxy.Middleware.ProxyMiddlewarePipeline.html","topicUid":"Titanium.Web.Proxy.Middleware.ProxyMiddlewarePipeline","type":"Class"}]},{"name":"Titanium.Web.Proxy.Models","href":"Titanium.Web.Proxy.Models.html","topicHref":"Titanium.Web.Proxy.Models.html","topicUid":"Titanium.Web.Proxy.Models","type":"Namespace","items":[{"name":"ExplicitProxyEndPoint","href":"Titanium.Web.Proxy.Models.ExplicitProxyEndPoint.html","topicHref":"Titanium.Web.Proxy.Models.ExplicitProxyEndPoint.html","topicUid":"Titanium.Web.Proxy.Models.ExplicitProxyEndPoint","type":"Class"},{"name":"ExternalProxy","href":"Titanium.Web.Proxy.Models.ExternalProxy.html","topicHref":"Titanium.Web.Proxy.Models.ExternalProxy.html","topicUid":"Titanium.Web.Proxy.Models.ExternalProxy","type":"Class"},{"name":"ExternalProxyType","href":"Titanium.Web.Proxy.Models.ExternalProxyType.html","topicHref":"Titanium.Web.Proxy.Models.ExternalProxyType.html","topicUid":"Titanium.Web.Proxy.Models.ExternalProxyType","type":"Enum"},{"name":"HttpHeader","href":"Titanium.Web.Proxy.Models.HttpHeader.html","topicHref":"Titanium.Web.Proxy.Models.HttpHeader.html","topicUid":"Titanium.Web.Proxy.Models.HttpHeader","type":"Class"},{"name":"HttpInterceptionContext","href":"Titanium.Web.Proxy.Models.HttpInterceptionContext.html","topicHref":"Titanium.Web.Proxy.Models.HttpInterceptionContext.html","topicUid":"Titanium.Web.Proxy.Models.HttpInterceptionContext","type":"Struct"},{"name":"IExternalProxy","href":"Titanium.Web.Proxy.Models.IExternalProxy.html","topicHref":"Titanium.Web.Proxy.Models.IExternalProxy.html","topicUid":"Titanium.Web.Proxy.Models.IExternalProxy","type":"Interface"},{"name":"OriginHttpVersionPolicy","href":"Titanium.Web.Proxy.Models.OriginHttpVersionPolicy.html","topicHref":"Titanium.Web.Proxy.Models.OriginHttpVersionPolicy.html","topicUid":"Titanium.Web.Proxy.Models.OriginHttpVersionPolicy","type":"Enum"},{"name":"ProxyAuthenticationContext","href":"Titanium.Web.Proxy.Models.ProxyAuthenticationContext.html","topicHref":"Titanium.Web.Proxy.Models.ProxyAuthenticationContext.html","topicUid":"Titanium.Web.Proxy.Models.ProxyAuthenticationContext","type":"Class"},{"name":"ProxyAuthenticationResult","href":"Titanium.Web.Proxy.Models.ProxyAuthenticationResult.html","topicHref":"Titanium.Web.Proxy.Models.ProxyAuthenticationResult.html","topicUid":"Titanium.Web.Proxy.Models.ProxyAuthenticationResult","type":"Enum"},{"name":"ProxyEndPoint","href":"Titanium.Web.Proxy.Models.ProxyEndPoint.html","topicHref":"Titanium.Web.Proxy.Models.ProxyEndPoint.html","topicUid":"Titanium.Web.Proxy.Models.ProxyEndPoint","type":"Class"},{"name":"ProxyProtocolType","href":"Titanium.Web.Proxy.Models.ProxyProtocolType.html","topicHref":"Titanium.Web.Proxy.Models.ProxyProtocolType.html","topicUid":"Titanium.Web.Proxy.Models.ProxyProtocolType","type":"Enum"},{"name":"SocksProxyEndPoint","href":"Titanium.Web.Proxy.Models.SocksProxyEndPoint.html","topicHref":"Titanium.Web.Proxy.Models.SocksProxyEndPoint.html","topicUid":"Titanium.Web.Proxy.Models.SocksProxyEndPoint","type":"Class"},{"name":"TransparentBaseProxyEndPoint","href":"Titanium.Web.Proxy.Models.TransparentBaseProxyEndPoint.html","topicHref":"Titanium.Web.Proxy.Models.TransparentBaseProxyEndPoint.html","topicUid":"Titanium.Web.Proxy.Models.TransparentBaseProxyEndPoint","type":"Class"},{"name":"TransparentProxyEndPoint","href":"Titanium.Web.Proxy.Models.TransparentProxyEndPoint.html","topicHref":"Titanium.Web.Proxy.Models.TransparentProxyEndPoint.html","topicUid":"Titanium.Web.Proxy.Models.TransparentProxyEndPoint","type":"Class"},{"name":"TransparentQuicProxyEndPoint","href":"Titanium.Web.Proxy.Models.TransparentQuicProxyEndPoint.html","topicHref":"Titanium.Web.Proxy.Models.TransparentQuicProxyEndPoint.html","topicUid":"Titanium.Web.Proxy.Models.TransparentQuicProxyEndPoint","type":"Class"},{"name":"UpstreamHttpProtocol","href":"Titanium.Web.Proxy.Models.UpstreamHttpProtocol.html","topicHref":"Titanium.Web.Proxy.Models.UpstreamHttpProtocol.html","topicUid":"Titanium.Web.Proxy.Models.UpstreamHttpProtocol","type":"Enum"},{"name":"WinAuthCredentials","href":"Titanium.Web.Proxy.Models.WinAuthCredentials.html","topicHref":"Titanium.Web.Proxy.Models.WinAuthCredentials.html","topicUid":"Titanium.Web.Proxy.Models.WinAuthCredentials","type":"Class"}]},{"name":"Titanium.Web.Proxy.Network","href":"Titanium.Web.Proxy.Network.html","topicHref":"Titanium.Web.Proxy.Network.html","topicUid":"Titanium.Web.Proxy.Network","type":"Namespace","items":[{"name":"CertificateEngine","href":"Titanium.Web.Proxy.Network.CertificateEngine.html","topicHref":"Titanium.Web.Proxy.Network.CertificateEngine.html","topicUid":"Titanium.Web.Proxy.Network.CertificateEngine","type":"Enum"},{"name":"CertificateKeyAlgorithm","href":"Titanium.Web.Proxy.Network.CertificateKeyAlgorithm.html","topicHref":"Titanium.Web.Proxy.Network.CertificateKeyAlgorithm.html","topicUid":"Titanium.Web.Proxy.Network.CertificateKeyAlgorithm","type":"Enum"},{"name":"CertificateManager","href":"Titanium.Web.Proxy.Network.CertificateManager.html","topicHref":"Titanium.Web.Proxy.Network.CertificateManager.html","topicUid":"Titanium.Web.Proxy.Network.CertificateManager","type":"Class"},{"name":"CertificateOsTrustKind","href":"Titanium.Web.Proxy.Network.CertificateOsTrustKind.html","topicHref":"Titanium.Web.Proxy.Network.CertificateOsTrustKind.html","topicUid":"Titanium.Web.Proxy.Network.CertificateOsTrustKind","type":"Enum"},{"name":"CertificateOsTrustResult","href":"Titanium.Web.Proxy.Network.CertificateOsTrustResult.html","topicHref":"Titanium.Web.Proxy.Network.CertificateOsTrustResult.html","topicUid":"Titanium.Web.Proxy.Network.CertificateOsTrustResult","type":"Class"},{"name":"DefaultCertificateDiskCache","href":"Titanium.Web.Proxy.Network.DefaultCertificateDiskCache.html","topicHref":"Titanium.Web.Proxy.Network.DefaultCertificateDiskCache.html","topicUid":"Titanium.Web.Proxy.Network.DefaultCertificateDiskCache","type":"Class"},{"name":"FirefoxCertificateTrust","href":"Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html","topicHref":"Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html","topicUid":"Titanium.Web.Proxy.Network.FirefoxCertificateTrust","type":"Class"},{"name":"ICertificateCache","href":"Titanium.Web.Proxy.Network.ICertificateCache.html","topicHref":"Titanium.Web.Proxy.Network.ICertificateCache.html","topicUid":"Titanium.Web.Proxy.Network.ICertificateCache","type":"Interface"}]},{"name":"Titanium.Web.Proxy.Network.Quic","href":"Titanium.Web.Proxy.Network.Quic.html","topicHref":"Titanium.Web.Proxy.Network.Quic.html","topicUid":"Titanium.Web.Proxy.Network.Quic","type":"Namespace","items":[{"name":"IOriginalDestinationResolver","href":"Titanium.Web.Proxy.Network.Quic.IOriginalDestinationResolver.html","topicHref":"Titanium.Web.Proxy.Network.Quic.IOriginalDestinationResolver.html","topicUid":"Titanium.Web.Proxy.Network.Quic.IOriginalDestinationResolver","type":"Interface"}]},{"name":"Titanium.Web.Proxy.Options","href":"Titanium.Web.Proxy.Options.html","topicHref":"Titanium.Web.Proxy.Options.html","topicUid":"Titanium.Web.Proxy.Options","type":"Namespace","items":[{"name":"PolicyFamily","href":"Titanium.Web.Proxy.Options.PolicyFamily.html","topicHref":"Titanium.Web.Proxy.Options.PolicyFamily.html","topicUid":"Titanium.Web.Proxy.Options.PolicyFamily","type":"Enum"},{"name":"PolicyMode","href":"Titanium.Web.Proxy.Options.PolicyMode.html","topicHref":"Titanium.Web.Proxy.Options.PolicyMode.html","topicUid":"Titanium.Web.Proxy.Options.PolicyMode","type":"Enum"},{"name":"ProxyPolicyModes","href":"Titanium.Web.Proxy.Options.ProxyPolicyModes.html","topicHref":"Titanium.Web.Proxy.Options.ProxyPolicyModes.html","topicUid":"Titanium.Web.Proxy.Options.ProxyPolicyModes","type":"Class"},{"name":"ProxyProfile","href":"Titanium.Web.Proxy.Options.ProxyProfile.html","topicHref":"Titanium.Web.Proxy.Options.ProxyProfile.html","topicUid":"Titanium.Web.Proxy.Options.ProxyProfile","type":"Enum"},{"name":"ProxyProfileSettings","href":"Titanium.Web.Proxy.Options.ProxyProfileSettings.html","topicHref":"Titanium.Web.Proxy.Options.ProxyProfileSettings.html","topicUid":"Titanium.Web.Proxy.Options.ProxyProfileSettings","type":"Class"},{"name":"ProxyResourceLimits","href":"Titanium.Web.Proxy.Options.ProxyResourceLimits.html","topicHref":"Titanium.Web.Proxy.Options.ProxyResourceLimits.html","topicUid":"Titanium.Web.Proxy.Options.ProxyResourceLimits","type":"Class"},{"name":"ProxyTimeoutOptions","href":"Titanium.Web.Proxy.Options.ProxyTimeoutOptions.html","topicHref":"Titanium.Web.Proxy.Options.ProxyTimeoutOptions.html","topicUid":"Titanium.Web.Proxy.Options.ProxyTimeoutOptions","type":"Class"},{"name":"ResolvedSessionPolicy","href":"Titanium.Web.Proxy.Options.ResolvedSessionPolicy.html","topicHref":"Titanium.Web.Proxy.Options.ResolvedSessionPolicy.html","topicUid":"Titanium.Web.Proxy.Options.ResolvedSessionPolicy","type":"Class"}]},{"name":"Titanium.Web.Proxy.Routing","href":"Titanium.Web.Proxy.Routing.html","topicHref":"Titanium.Web.Proxy.Routing.html","topicUid":"Titanium.Web.Proxy.Routing","type":"Namespace","items":[{"name":"ReverseProxyFastPath","href":"Titanium.Web.Proxy.Routing.ReverseProxyFastPath.html","topicHref":"Titanium.Web.Proxy.Routing.ReverseProxyFastPath.html","topicUid":"Titanium.Web.Proxy.Routing.ReverseProxyFastPath","type":"Class"},{"name":"RouteMatcher","href":"Titanium.Web.Proxy.Routing.RouteMatcher.html","topicHref":"Titanium.Web.Proxy.Routing.RouteMatcher.html","topicUid":"Titanium.Web.Proxy.Routing.RouteMatcher","type":"Class"}]},{"name":"Titanium.Web.Proxy.StreamExtended","href":"Titanium.Web.Proxy.StreamExtended.html","topicHref":"Titanium.Web.Proxy.StreamExtended.html","topicUid":"Titanium.Web.Proxy.StreamExtended","type":"Namespace","items":[{"name":"ClientHelloInfo","href":"Titanium.Web.Proxy.StreamExtended.ClientHelloInfo.html","topicHref":"Titanium.Web.Proxy.StreamExtended.ClientHelloInfo.html","topicUid":"Titanium.Web.Proxy.StreamExtended.ClientHelloInfo","type":"Class"},{"name":"ServerHelloInfo","href":"Titanium.Web.Proxy.StreamExtended.ServerHelloInfo.html","topicHref":"Titanium.Web.Proxy.StreamExtended.ServerHelloInfo.html","topicUid":"Titanium.Web.Proxy.StreamExtended.ServerHelloInfo","type":"Class"}]},{"name":"Titanium.Web.Proxy.StreamExtended.BufferPool","href":"Titanium.Web.Proxy.StreamExtended.BufferPool.html","topicHref":"Titanium.Web.Proxy.StreamExtended.BufferPool.html","topicUid":"Titanium.Web.Proxy.StreamExtended.BufferPool","type":"Namespace","items":[{"name":"IBufferPool","href":"Titanium.Web.Proxy.StreamExtended.BufferPool.IBufferPool.html","topicHref":"Titanium.Web.Proxy.StreamExtended.BufferPool.IBufferPool.html","topicUid":"Titanium.Web.Proxy.StreamExtended.BufferPool.IBufferPool","type":"Interface"}]},{"name":"Titanium.Web.Proxy.StreamExtended.Models","href":"Titanium.Web.Proxy.StreamExtended.Models.html","topicHref":"Titanium.Web.Proxy.StreamExtended.Models.html","topicUid":"Titanium.Web.Proxy.StreamExtended.Models","type":"Namespace","items":[{"name":"SslExtension","href":"Titanium.Web.Proxy.StreamExtended.Models.SslExtension.html","topicHref":"Titanium.Web.Proxy.StreamExtended.Models.SslExtension.html","topicUid":"Titanium.Web.Proxy.StreamExtended.Models.SslExtension","type":"Class"}]},{"name":"Titanium.Web.Proxy.StreamExtended.Network","href":"Titanium.Web.Proxy.StreamExtended.Network.html","topicHref":"Titanium.Web.Proxy.StreamExtended.Network.html","topicUid":"Titanium.Web.Proxy.StreamExtended.Network","type":"Namespace","items":[{"name":"DataEventArgs","href":"Titanium.Web.Proxy.StreamExtended.Network.DataEventArgs.html","topicHref":"Titanium.Web.Proxy.StreamExtended.Network.DataEventArgs.html","topicUid":"Titanium.Web.Proxy.StreamExtended.Network.DataEventArgs","type":"Class"},{"name":"IHttpStreamReader","href":"Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamReader.html","topicHref":"Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamReader.html","topicUid":"Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamReader","type":"Interface"},{"name":"IHttpStreamWriter","href":"Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamWriter.html","topicHref":"Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamWriter.html","topicUid":"Titanium.Web.Proxy.StreamExtended.Network.IHttpStreamWriter","type":"Interface"},{"name":"ILineStream","href":"Titanium.Web.Proxy.StreamExtended.Network.ILineStream.html","topicHref":"Titanium.Web.Proxy.StreamExtended.Network.ILineStream.html","topicUid":"Titanium.Web.Proxy.StreamExtended.Network.ILineStream","type":"Interface"},{"name":"IPeekStream","href":"Titanium.Web.Proxy.StreamExtended.Network.IPeekStream.html","topicHref":"Titanium.Web.Proxy.StreamExtended.Network.IPeekStream.html","topicUid":"Titanium.Web.Proxy.StreamExtended.Network.IPeekStream","type":"Interface"}]},{"name":"Titanium.Web.Proxy.Transforms","href":"Titanium.Web.Proxy.Transforms.html","topicHref":"Titanium.Web.Proxy.Transforms.html","topicUid":"Titanium.Web.Proxy.Transforms","type":"Namespace","items":[{"name":"TransformEngine","href":"Titanium.Web.Proxy.Transforms.TransformEngine.html","topicHref":"Titanium.Web.Proxy.Transforms.TransformEngine.html","topicUid":"Titanium.Web.Proxy.Transforms.TransformEngine","type":"Class"}]}],"memberLayout":"SamePage"} diff --git a/docs/index.json b/docs/index.json index 2b1937ad7..b81ce2719 100644 --- a/docs/index.json +++ b/docs/index.json @@ -19,6 +19,11 @@ "title": "Namespace Titanium.Web.Proxy.Caching | Titanium Web Proxy", "summary": "Namespace Titanium.Web.Proxy.Caching Classes HttpResponseCacheMiddleware GET/HEAD response cache middleware. Only allocated when placed in the middleware list; otherwise the hot path pays nothing. MemoryHttpResponseCache In-process GET/HEAD response cache. Zero cost when unused (not registered in middleware)." }, + "api/Titanium.Web.Proxy.ClientProcessId.html": { + "href": "api/Titanium.Web.Proxy.ClientProcessId.html", + "title": "Class ClientProcessId | Titanium Web Proxy", + "summary": "Class ClientProcessId Capability for resolving the local client process that owns a TCP connection to the proxy. Inheritance object ClientProcessId Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy Assembly: Titanium.Web.Proxy.dll Syntax public static class ClientProcessId Properties | Edit this page View Source IsSupported True when this OS can map a localhost client TCP port to a process id (Windows, Linux, and macOS). Declaration public static bool IsSupported { get; } Property Value Type Description bool" + }, "api/Titanium.Web.Proxy.Clusters.ClusterManager.html": { "href": "api/Titanium.Web.Proxy.Clusters.ClusterManager.html", "title": "Class ClusterManager | Titanium Web Proxy", @@ -112,12 +117,12 @@ "api/Titanium.Web.Proxy.EventArguments.SessionEventArgs.html": { "href": "api/Titanium.Web.Proxy.EventArguments.SessionEventArgs.html", "title": "Class SessionEventArgs | Titanium Web Proxy", - "summary": "Class SessionEventArgs Holds info related to a single proxy session (single request/response exchange). Under HTTP/2 and HTTP/3, many sessions share one client connection (one stream each); ending a session ends that request/response exchange, not necessarily the connection. Inheritance object EventArgs ProxyEventArgsBase SessionEventArgsBase SessionEventArgs Implements IDisposable Inherited Members SessionEventArgsBase.BufferPool SessionEventArgsBase.ClientConnectionId SessionEventArgsBase.ServerConnectionId SessionEventArgsBase.Timing SessionEventArgsBase.UpstreamConnectionTiming SessionEventArgsBase.UserData SessionEventArgsBase.ConnectTimeout SessionEventArgsBase.EnableWinAuth SessionEventArgsBase.IsHttps SessionEventArgsBase.ClientLocalEndPoint SessionEventArgsBase.ClientRemoteEndPoint SessionEventArgsBase.ClientEndPoint SessionEventArgsBase.ServerRemoteEndPoint SessionEventArgsBase.ServerIpAddress SessionEventArgsBase.HttpClient SessionEventArgsBase.WebSession SessionEventArgsBase.CustomUpStreamProxy SessionEventArgsBase.CustomUpStreamProxyUsed SessionEventArgsBase.ProxyEndPoint SessionEventArgsBase.LocalEndPoint SessionEventArgsBase.IsTransparent SessionEventArgsBase.IsSocks SessionEventArgsBase.Exception SessionEventArgsBase.Logger SessionEventArgsBase.Dispose() SessionEventArgsBase.OnException(Exception) SessionEventArgsBase.DataSent SessionEventArgsBase.DataReceived SessionEventArgsBase.TerminateSession() ProxyEventArgsBase.ClientUserData EventArgs.Empty object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.EventArguments Assembly: Titanium.Web.Proxy.dll Syntax public class SessionEventArgs : SessionEventArgsBase, IDisposable Properties | Edit this page View Source IdleReadTimeout Per-session override for IdleReadTimeoutSeconds. null uses the server default; Zero or negative disables. Declaration public TimeSpan? IdleReadTimeout { get; set; } Property Value Type Description TimeSpan? | Edit this page View Source IdleWriteTimeout Per-session override for IdleWriteTimeoutSeconds. null uses the server default; Zero or negative disables. Declaration public TimeSpan? IdleWriteTimeout { get; set; } Property Value Type Description TimeSpan? | Edit this page View Source IsPromise Is this session a HTTP/2 promise? Declaration public bool IsPromise { get; } Property Value Type Description bool | Edit this page View Source MaxBufferedBodyBytes Per-session override for MaxBufferedBodyBytes. null uses the server default. Set in BeforeRequest to increase the limit for large uploads/downloads without relaxing the global limit for all requests. Declaration public int? MaxBufferedBodyBytes { get; set; } Property Value Type Description int? | Edit this page View Source MaxWebSocketFramePayloadBytes Per-session override for MaxWebSocketFramePayloadBytes. null uses the server default. Set in BeforeRequest before the WebSocket upgrade completes. Declaration public int? MaxWebSocketFramePayloadBytes { get; set; } Property Value Type Description int? | Edit this page View Source NetworkFailureRetryAttempts Per-session override for NetworkFailureRetryAttempts. null uses the server default. Set to 0 in BeforeRequest for non-idempotent methods (POST, PATCH) to prevent unsafe retries. Declaration public int? NetworkFailureRetryAttempts { get; set; } Property Value Type Description int? | Edit this page View Source OriginHttpVersionPolicy Per-session override for OriginHttpVersionPolicy. null uses the server default (PreserveClientVersion unless the server property was changed). Set in BeforeRequest. Declaration public OriginHttpVersionPolicy? OriginHttpVersionPolicy { get; set; } Property Value Type Description OriginHttpVersionPolicy? | Edit this page View Source ReRequest Should we send the request again ? Declaration public bool ReRequest { get; set; } Property Value Type Description bool | Edit this page View Source RequestTimeout Per-session override for RequestTimeoutSeconds. null uses the server default; Zero or negative disables. Declaration public TimeSpan? RequestTimeout { get; set; } Property Value Type Description TimeSpan? | Edit this page View Source ResponseHeaderTimeout Per-session override for ResponseHeaderTimeoutSeconds. null uses the server default; Zero or negative disables. Declaration public TimeSpan? ResponseHeaderTimeout { get; set; } Property Value Type Description TimeSpan? | Edit this page View Source UpstreamHttpProtocol Per-request outbound protocol version policy. Overrides the connection-level UpstreamHttpProtocol value set during BeforeSslAuthenticate / BeforeQuicAuthenticate for this single request stream only. null uses the connection-level policy (or Auto if none was set). Evaluated in BeforeRequest; changes after that have no effect. On an H3 inbound connection (one client QUIC connection serving many concurrent streams), each stream resolves its outbound protocol independently after BeforeRequest fires, making per-stream protocol overrides possible even though the inbound leg is already QUIC. Declaration public UpstreamHttpProtocol? UpstreamHttpProtocol { get; set; } Property Value Type Description UpstreamHttpProtocol? | Edit this page View Source WebSocketClientWriter Inject frames toward the local client (server→client direction, unmasked). Available only while an intercepted WebSocket relay is active. Declaration public WebSocketFrameWriter? WebSocketClientWriter { get; } Property Value Type Description WebSocketFrameWriter | Edit this page View Source WebSocketDecoder Declaration [Obsolete(\"Use [WebSocketDecoderReceive] instead\")] public WebSocketDecoder WebSocketDecoder { get; } Property Value Type Description WebSocketDecoder | Edit this page View Source WebSocketDecoderReceive Declaration public WebSocketDecoder WebSocketDecoderReceive { get; } Property Value Type Description WebSocketDecoder | Edit this page View Source WebSocketDecoderSend Declaration public WebSocketDecoder WebSocketDecoderSend { get; } Property Value Type Description WebSocketDecoder | Edit this page View Source WebSocketServerWriter Inject frames toward the remote server (client→server direction, masked). Available only while an intercepted WebSocket relay is active. Declaration public WebSocketFrameWriter? WebSocketServerWriter { get; } Property Value Type Description WebSocketFrameWriter Methods | Edit this page View Source Dispose(bool) Declaration protected override void Dispose(bool disposing) Parameters Type Name Description bool disposing Overrides SessionEventArgsBase.Dispose(bool) | Edit this page View Source DrainClientBodyAsync(CancellationToken) Drains (reads and discards) any unread client request body from the underlying stream or connection so the client's keep-alive / multiplexed connection can be reused. This reads the bytes off the wire without buffering them in memory. It is a no-op if the body was already received or the request has no body. Declaration public Task DrainClientBodyAsync(CancellationToken cancellationToken = default) Parameters Type Name Description CancellationToken cancellationToken Returns Type Description Task Remarks Useful when short-circuiting a request (e.g. Respond(Response, bool), RespondStreaming(StreamingProxyResult, bool), or blocking) while the client is uploading a body: draining leaves the client connection in a reusable state. Note the proxy already drains the client body automatically on the normal synthetic-response path, so this is only needed for advanced/manual control. Warning: for an endless chunked request (one that never sends its terminating zero chunk) this will block until the passed cancellationToken is cancelled or the connection closes. | Edit this page View Source DrainServerBodyAsync(CancellationToken) Drains (reads and discards) any unread server response body from the underlying client/server stream or connection so it can be reused. This reads the bytes off the wire without buffering them in memory. It is a no-op if the body was already received or the response has no body. Declaration public Task DrainServerBodyAsync(CancellationToken cancellationToken = default) Parameters Type Name Description CancellationToken cancellationToken Returns Type Description Task Remarks Warning: for an endless chunked response (one that never sends its terminating zero chunk) this will block until the passed cancellationToken is cancelled or the connection closes. In that case prefer closing the connection (e.g. TerminateServerConnection()) instead. | Edit this page View Source GenericResponse(byte[], HttpStatusCode, IDictionary, bool) Before request is made to server respond with the specified byte[], the specified status to client. And then ignore the request. Declaration public void GenericResponse(byte[] result, HttpStatusCode status, IDictionary headers, bool closeServerConnection = false) Parameters Type Name Description byte[] result The bytes to sent. HttpStatusCode status The HTTP status code. IDictionary headers The HTTP headers. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source GenericResponse(byte[], HttpStatusCode, IEnumerable?, bool) Before request is made to server respond with the specified byte[], the specified status to client. And then ignore the request. Declaration public void GenericResponse(byte[] result, HttpStatusCode status, IEnumerable? headers, bool closeServerConnection = false) Parameters Type Name Description byte[] result The bytes to sent. HttpStatusCode status The HTTP status code. IEnumerable headers The HTTP headers. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source GenericResponse(string, HttpStatusCode, IDictionary?, bool) Before request is made to server respond with the specified HTML string and the specified status to client. And then ignore the request. Declaration public void GenericResponse(string html, HttpStatusCode status, IDictionary? headers, bool closeServerConnection = false) Parameters Type Name Description string html The html content. HttpStatusCode status The HTTP status code. IDictionary headers The HTTP headers. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source GenericResponse(string, HttpStatusCode, IEnumerable?, bool) Before request is made to server respond with the specified HTML string and the specified status to client. And then ignore the request. Declaration public void GenericResponse(string html, HttpStatusCode status, IEnumerable? headers = null, bool closeServerConnection = false) Parameters Type Name Description string html The html content. HttpStatusCode status The HTTP status code. IEnumerable headers The HTTP headers. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source GetRequestBody(CancellationToken) Gets the request body as bytes. Declaration public Task GetRequestBody(CancellationToken cancellationToken = default) Parameters Type Name Description CancellationToken cancellationToken Optional cancellation token for this async task. Returns Type Description Task The body as bytes. | Edit this page View Source GetRequestBodyAsString(CancellationToken) Gets the request body as string. Declaration public Task GetRequestBodyAsString(CancellationToken cancellationToken = default) Parameters Type Name Description CancellationToken cancellationToken Optional cancellation token for this async task. Returns Type Description Task The body as string. | Edit this page View Source GetResponseBody(CancellationToken) Gets the response body as bytes. Declaration public Task GetResponseBody(CancellationToken cancellationToken = default) Parameters Type Name Description CancellationToken cancellationToken Optional cancellation token for this async task. Returns Type Description Task The resulting bytes. | Edit this page View Source GetResponseBodyAsString(CancellationToken) Gets the response body as string. Declaration public Task GetResponseBodyAsString(CancellationToken cancellationToken = default) Parameters Type Name Description CancellationToken cancellationToken Optional cancellation token for this async task. Returns Type Description Task The string body. | Edit this page View Source Ok(byte[], IDictionary?, bool) Before request is made to server respond with the specified byte[] to client and ignore the request. Declaration public void Ok(byte[] result, IDictionary? headers, bool closeServerConnection = false) Parameters Type Name Description byte[] result The html content bytes. IDictionary headers The HTTP headers. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source Ok(byte[], IEnumerable?, bool) Before request is made to server respond with the specified byte[] to client and ignore the request. Declaration public void Ok(byte[] result, IEnumerable? headers = null, bool closeServerConnection = false) Parameters Type Name Description byte[] result The html content bytes. IEnumerable headers The HTTP headers. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source Ok(string, IDictionary?, bool) Before request is made to server respond with the specified HTML string to client and ignore the request. Declaration public void Ok(string html, IDictionary? headers, bool closeServerConnection = false) Parameters Type Name Description string html HTML content to sent. IDictionary headers HTTP response headers. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source Ok(string, IEnumerable?, bool) Before request is made to server respond with the specified HTML string to client and ignore the request. Declaration public void Ok(string html, IEnumerable? headers = null, bool closeServerConnection = false) Parameters Type Name Description string html HTML content to sent. IEnumerable headers HTTP response headers. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source Redirect(string, bool) Redirect to provided URL. Declaration public void Redirect(string url, bool closeServerConnection = false) Parameters Type Name Description string url The URL to redirect. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source Respond(Response, bool) Respond with given response object to client. Declaration public void Respond(Response response, bool closeServerConnection = false) Parameters Type Name Description Response response The response object. bool closeServerConnection Close the server connection used by request if any? Remarks If the server response was already received, the original server body (if any) is drained (syphoned) so the server connection stays reusable. To avoid reading a large or endless server body, pass closeServerConnection = true (or call TerminateServerConnection()), which closes the connection instead of draining. Note that an HTTP/1.1 connection cannot be both reused and have its body skipped. | Edit this page View Source RespondStreaming(Response, Func, bool) Respond to the client with a streamed body produced on the fly, without buffering the whole body in memory. Use this to serve large or endless bodies (e.g. a multi-gigabyte file or a synthetic server-sent-events stream) from scratch. Declaration public void RespondStreaming(Response response, Func writeBody, bool closeServerConnection = false) Parameters Type Name Description Response response The response object (status and headers). Func writeBody Delegate that writes the body to the provided stream. bool closeServerConnection Close the server connection used by request if any? Remarks Framing is chosen from the response headers: if a Content-Length is set on response the body is written raw (the delegate must write exactly that many bytes); otherwise HTTP/1.1 uses chunked transfer-encoding and HTTP/2/HTTP/3 emit DATA / stream frames. The delegate receives a write-only stream; only a single buffer is in flight at a time, so memory stays bounded regardless of the total size. See Respond(Response, bool) for the server body syphon-vs-close trade-off controlled by closeServerConnection. | Edit this page View Source RespondStreaming(StreamingProxyResult, bool) Respond to the client with a streamed body produced by Stream(HttpStatusCode, string, Func, long?) or File(string, string, HttpStatusCode). Declaration public void RespondStreaming(StreamingProxyResult result, bool closeServerConnection) Parameters Type Name Description StreamingProxyResult result Streaming response metadata and body writer. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source SetRequestBody(byte[]) Sets the request body. Declaration public void SetRequestBody(byte[] body) Parameters Type Name Description byte[] body The request body bytes. | Edit this page View Source SetRequestBodyString(string) Sets the body with the specified string. Declaration public void SetRequestBodyString(string body) Parameters Type Name Description string body The request body string to set. | Edit this page View Source SetResponseBody(byte[]) Set the response body bytes. Declaration public void SetResponseBody(byte[] body) Parameters Type Name Description byte[] body The body bytes to set. | Edit this page View Source SetResponseBodyString(string) Replace the response body with the specified string. Declaration public void SetResponseBodyString(string body) Parameters Type Name Description string body The body string to set. | Edit this page View Source TerminateServerConnection() Terminate the connection to server at the end of this HTTP request/response session. Declaration public void TerminateServerConnection() Events | Edit this page View Source BeforeWebSocketFrame Fired for each WebSocket frame after upgrade when at least one handler is subscribed. Handlers may Forward, Drop, or Replace the frame, optionally with a Delay. Observational DataSent / DataReceived still fire for bytes actually written to the peer. Declaration public event AsyncEventHandler? BeforeWebSocketFrame Event Type Type Description AsyncEventHandler | Edit this page View Source MultipartRequestPartSent Occurs when multipart request part sent. Declaration public event EventHandler? MultipartRequestPartSent Event Type Type Description EventHandler Implements IDisposable" + "summary": "Class SessionEventArgs Holds info related to a single proxy session (single request/response exchange). Under HTTP/2 and HTTP/3, many sessions share one client connection (one stream each); ending a session ends that request/response exchange, not necessarily the connection. Inheritance object EventArgs ProxyEventArgsBase SessionEventArgsBase SessionEventArgs Implements IDisposable Inherited Members SessionEventArgsBase.BufferPool SessionEventArgsBase.ClientConnectionId SessionEventArgsBase.ServerConnectionId SessionEventArgsBase.Timing SessionEventArgsBase.UpstreamConnectionTiming SessionEventArgsBase.UserData SessionEventArgsBase.ConnectTimeout SessionEventArgsBase.EnableWinAuth SessionEventArgsBase.IsHttps SessionEventArgsBase.ClientLocalEndPoint SessionEventArgsBase.ClientRemoteEndPoint SessionEventArgsBase.ClientEndPoint SessionEventArgsBase.ServerRemoteEndPoint SessionEventArgsBase.ServerIpAddress SessionEventArgsBase.HttpClient SessionEventArgsBase.WebSession SessionEventArgsBase.CustomUpStreamProxy SessionEventArgsBase.CustomUpStreamProxyUsed SessionEventArgsBase.UpstreamDestinationId SessionEventArgsBase.ProxyEndPoint SessionEventArgsBase.LocalEndPoint SessionEventArgsBase.IsTransparent SessionEventArgsBase.IsSocks SessionEventArgsBase.Exception SessionEventArgsBase.Logger SessionEventArgsBase.Dispose() SessionEventArgsBase.OnException(Exception) SessionEventArgsBase.DataSent SessionEventArgsBase.DataReceived SessionEventArgsBase.TerminateSession() ProxyEventArgsBase.ClientUserData EventArgs.Empty object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.EventArguments Assembly: Titanium.Web.Proxy.dll Syntax public class SessionEventArgs : SessionEventArgsBase, IDisposable Properties | Edit this page View Source IdleReadTimeout Per-session override for IdleReadTimeoutSeconds. null uses the server default; Zero or negative disables. Declaration public TimeSpan? IdleReadTimeout { get; set; } Property Value Type Description TimeSpan? | Edit this page View Source IdleWriteTimeout Per-session override for IdleWriteTimeoutSeconds. null uses the server default; Zero or negative disables. Declaration public TimeSpan? IdleWriteTimeout { get; set; } Property Value Type Description TimeSpan? | Edit this page View Source IsPromise Is this session a HTTP/2 promise? Declaration public bool IsPromise { get; } Property Value Type Description bool | Edit this page View Source MaxBufferedBodyBytes Per-session override for MaxBufferedBodyBytes. null uses the server default. Set in BeforeRequest to increase the limit for large uploads/downloads without relaxing the global limit for all requests. Declaration public int? MaxBufferedBodyBytes { get; set; } Property Value Type Description int? | Edit this page View Source MaxWebSocketFramePayloadBytes Per-session override for MaxWebSocketFramePayloadBytes. null uses the server default. Set in BeforeRequest before the WebSocket upgrade completes. Declaration public int? MaxWebSocketFramePayloadBytes { get; set; } Property Value Type Description int? | Edit this page View Source NetworkFailureRetryAttempts Per-session override for NetworkFailureRetryAttempts. null uses the server default. Set to 0 in BeforeRequest for non-idempotent methods (POST, PATCH) to prevent unsafe retries. Declaration public int? NetworkFailureRetryAttempts { get; set; } Property Value Type Description int? | Edit this page View Source OriginHttpVersionPolicy Per-session override for OriginHttpVersionPolicy. null uses the server default (PreserveClientVersion unless the server property was changed). Set in BeforeRequest. Declaration public OriginHttpVersionPolicy? OriginHttpVersionPolicy { get; set; } Property Value Type Description OriginHttpVersionPolicy? | Edit this page View Source ReRequest Should we send the request again ? Declaration public bool ReRequest { get; set; } Property Value Type Description bool | Edit this page View Source RequestTimeout Per-session override for RequestTimeoutSeconds. null uses the server default; Zero or negative disables. Declaration public TimeSpan? RequestTimeout { get; set; } Property Value Type Description TimeSpan? | Edit this page View Source ResponseHeaderTimeout Per-session override for ResponseHeaderTimeoutSeconds. null uses the server default; Zero or negative disables. Declaration public TimeSpan? ResponseHeaderTimeout { get; set; } Property Value Type Description TimeSpan? | Edit this page View Source UpstreamHttpProtocol Per-request outbound protocol version policy. Overrides the connection-level UpstreamHttpProtocol value set during BeforeSslAuthenticate / BeforeQuicAuthenticate for this single request stream only. null uses the connection-level policy (or Auto if none was set). Evaluated in BeforeRequest; changes after that have no effect. On an H3 inbound connection (one client QUIC connection serving many concurrent streams), each stream resolves its outbound protocol independently after BeforeRequest fires, making per-stream protocol overrides possible even though the inbound leg is already QUIC. Declaration public UpstreamHttpProtocol? UpstreamHttpProtocol { get; set; } Property Value Type Description UpstreamHttpProtocol? | Edit this page View Source WebSocketClientWriter Inject frames toward the local client (server→client direction, unmasked). Available only while an intercepted WebSocket relay is active. Declaration public WebSocketFrameWriter? WebSocketClientWriter { get; } Property Value Type Description WebSocketFrameWriter | Edit this page View Source WebSocketDecoder Declaration [Obsolete(\"Use [WebSocketDecoderReceive] instead\")] public WebSocketDecoder WebSocketDecoder { get; } Property Value Type Description WebSocketDecoder | Edit this page View Source WebSocketDecoderReceive Declaration public WebSocketDecoder WebSocketDecoderReceive { get; } Property Value Type Description WebSocketDecoder | Edit this page View Source WebSocketDecoderSend Declaration public WebSocketDecoder WebSocketDecoderSend { get; } Property Value Type Description WebSocketDecoder | Edit this page View Source WebSocketServerWriter Inject frames toward the remote server (client→server direction, masked). Available only while an intercepted WebSocket relay is active. Declaration public WebSocketFrameWriter? WebSocketServerWriter { get; } Property Value Type Description WebSocketFrameWriter Methods | Edit this page View Source Dispose(bool) Declaration protected override void Dispose(bool disposing) Parameters Type Name Description bool disposing Overrides SessionEventArgsBase.Dispose(bool) | Edit this page View Source DrainClientBodyAsync(CancellationToken) Drains (reads and discards) any unread client request body from the underlying stream or connection so the client's keep-alive / multiplexed connection can be reused. This reads the bytes off the wire without buffering them in memory. It is a no-op if the body was already received or the request has no body. Declaration public Task DrainClientBodyAsync(CancellationToken cancellationToken = default) Parameters Type Name Description CancellationToken cancellationToken Returns Type Description Task Remarks Useful when short-circuiting a request (e.g. Respond(Response, bool), RespondStreaming(StreamingProxyResult, bool), or blocking) while the client is uploading a body: draining leaves the client connection in a reusable state. Note the proxy already drains the client body automatically on the normal synthetic-response path, so this is only needed for advanced/manual control. Warning: for an endless chunked request (one that never sends its terminating zero chunk) this will block until the passed cancellationToken is cancelled or the connection closes. | Edit this page View Source DrainServerBodyAsync(CancellationToken) Drains (reads and discards) any unread server response body from the underlying client/server stream or connection so it can be reused. This reads the bytes off the wire without buffering them in memory. It is a no-op if the body was already received or the response has no body. Declaration public Task DrainServerBodyAsync(CancellationToken cancellationToken = default) Parameters Type Name Description CancellationToken cancellationToken Returns Type Description Task Remarks Warning: for an endless chunked response (one that never sends its terminating zero chunk) this will block until the passed cancellationToken is cancelled or the connection closes. In that case prefer closing the connection (e.g. TerminateServerConnection()) instead. | Edit this page View Source GenericResponse(byte[], HttpStatusCode, IDictionary, bool) Before request is made to server respond with the specified byte[], the specified status to client. And then ignore the request. Declaration public void GenericResponse(byte[] result, HttpStatusCode status, IDictionary headers, bool closeServerConnection = false) Parameters Type Name Description byte[] result The bytes to sent. HttpStatusCode status The HTTP status code. IDictionary headers The HTTP headers. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source GenericResponse(byte[], HttpStatusCode, IEnumerable?, bool) Before request is made to server respond with the specified byte[], the specified status to client. And then ignore the request. Declaration public void GenericResponse(byte[] result, HttpStatusCode status, IEnumerable? headers, bool closeServerConnection = false) Parameters Type Name Description byte[] result The bytes to sent. HttpStatusCode status The HTTP status code. IEnumerable headers The HTTP headers. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source GenericResponse(string, HttpStatusCode, IDictionary?, bool) Before request is made to server respond with the specified HTML string and the specified status to client. And then ignore the request. Declaration public void GenericResponse(string html, HttpStatusCode status, IDictionary? headers, bool closeServerConnection = false) Parameters Type Name Description string html The html content. HttpStatusCode status The HTTP status code. IDictionary headers The HTTP headers. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source GenericResponse(string, HttpStatusCode, IEnumerable?, bool) Before request is made to server respond with the specified HTML string and the specified status to client. And then ignore the request. Declaration public void GenericResponse(string html, HttpStatusCode status, IEnumerable? headers = null, bool closeServerConnection = false) Parameters Type Name Description string html The html content. HttpStatusCode status The HTTP status code. IEnumerable headers The HTTP headers. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source GetRequestBody(CancellationToken) Gets the request body as bytes. Declaration public Task GetRequestBody(CancellationToken cancellationToken = default) Parameters Type Name Description CancellationToken cancellationToken Optional cancellation token for this async task. Returns Type Description Task The body as bytes. | Edit this page View Source GetRequestBodyAsString(CancellationToken) Gets the request body as string. Declaration public Task GetRequestBodyAsString(CancellationToken cancellationToken = default) Parameters Type Name Description CancellationToken cancellationToken Optional cancellation token for this async task. Returns Type Description Task The body as string. | Edit this page View Source GetResponseBody(CancellationToken) Gets the response body as bytes. Declaration public Task GetResponseBody(CancellationToken cancellationToken = default) Parameters Type Name Description CancellationToken cancellationToken Optional cancellation token for this async task. Returns Type Description Task The resulting bytes. | Edit this page View Source GetResponseBodyAsString(CancellationToken) Gets the response body as string. Declaration public Task GetResponseBodyAsString(CancellationToken cancellationToken = default) Parameters Type Name Description CancellationToken cancellationToken Optional cancellation token for this async task. Returns Type Description Task The string body. | Edit this page View Source Ok(byte[], IDictionary?, bool) Before request is made to server respond with the specified byte[] to client and ignore the request. Declaration public void Ok(byte[] result, IDictionary? headers, bool closeServerConnection = false) Parameters Type Name Description byte[] result The html content bytes. IDictionary headers The HTTP headers. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source Ok(byte[], IEnumerable?, bool) Before request is made to server respond with the specified byte[] to client and ignore the request. Declaration public void Ok(byte[] result, IEnumerable? headers = null, bool closeServerConnection = false) Parameters Type Name Description byte[] result The html content bytes. IEnumerable headers The HTTP headers. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source Ok(string, IDictionary?, bool) Before request is made to server respond with the specified HTML string to client and ignore the request. Declaration public void Ok(string html, IDictionary? headers, bool closeServerConnection = false) Parameters Type Name Description string html HTML content to sent. IDictionary headers HTTP response headers. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source Ok(string, IEnumerable?, bool) Before request is made to server respond with the specified HTML string to client and ignore the request. Declaration public void Ok(string html, IEnumerable? headers = null, bool closeServerConnection = false) Parameters Type Name Description string html HTML content to sent. IEnumerable headers HTTP response headers. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source Redirect(string, bool) Redirect to provided URL. Declaration public void Redirect(string url, bool closeServerConnection = false) Parameters Type Name Description string url The URL to redirect. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source Respond(Response, bool) Respond with given response object to client. Declaration public void Respond(Response response, bool closeServerConnection = false) Parameters Type Name Description Response response The response object. bool closeServerConnection Close the server connection used by request if any? Remarks If the server response was already received, the original server body (if any) is drained (syphoned) so the server connection stays reusable. To avoid reading a large or endless server body, pass closeServerConnection = true (or call TerminateServerConnection()), which closes the connection instead of draining. Note that an HTTP/1.1 connection cannot be both reused and have its body skipped. | Edit this page View Source RespondStreaming(Response, Func, bool) Respond to the client with a streamed body produced on the fly, without buffering the whole body in memory. Use this to serve large or endless bodies (e.g. a multi-gigabyte file or a synthetic server-sent-events stream) from scratch. Declaration public void RespondStreaming(Response response, Func writeBody, bool closeServerConnection = false) Parameters Type Name Description Response response The response object (status and headers). Func writeBody Delegate that writes the body to the provided stream. bool closeServerConnection Close the server connection used by request if any? Remarks Framing is chosen from the response headers: if a Content-Length is set on response the body is written raw (the delegate must write exactly that many bytes); otherwise HTTP/1.1 uses chunked transfer-encoding and HTTP/2/HTTP/3 emit DATA / stream frames. The delegate receives a write-only stream; only a single buffer is in flight at a time, so memory stays bounded regardless of the total size. See Respond(Response, bool) for the server body syphon-vs-close trade-off controlled by closeServerConnection. | Edit this page View Source RespondStreaming(StreamingProxyResult, bool) Respond to the client with a streamed body produced by Stream(HttpStatusCode, string, Func, long?) or File(string, string, HttpStatusCode). Declaration public void RespondStreaming(StreamingProxyResult result, bool closeServerConnection) Parameters Type Name Description StreamingProxyResult result Streaming response metadata and body writer. bool closeServerConnection Close the server connection used by request if any? | Edit this page View Source SetRequestBody(byte[]) Sets the request body. Declaration public void SetRequestBody(byte[] body) Parameters Type Name Description byte[] body The request body bytes. | Edit this page View Source SetRequestBodyString(string) Sets the body with the specified string. Declaration public void SetRequestBodyString(string body) Parameters Type Name Description string body The request body string to set. | Edit this page View Source SetResponseBody(byte[]) Set the response body bytes. Declaration public void SetResponseBody(byte[] body) Parameters Type Name Description byte[] body The body bytes to set. | Edit this page View Source SetResponseBodyString(string) Replace the response body with the specified string. Declaration public void SetResponseBodyString(string body) Parameters Type Name Description string body The body string to set. | Edit this page View Source TerminateServerConnection() Terminate the connection to server at the end of this HTTP request/response session. Declaration public void TerminateServerConnection() Events | Edit this page View Source BeforeWebSocketFrame Fired for each WebSocket frame after upgrade when at least one handler is subscribed. Handlers may Forward, Drop, or Replace the frame, optionally with a Delay. Observational DataSent / DataReceived still fire for bytes actually written to the peer. Declaration public event AsyncEventHandler? BeforeWebSocketFrame Event Type Type Description AsyncEventHandler | Edit this page View Source MultipartRequestPartSent Occurs when multipart request part sent. Declaration public event EventHandler? MultipartRequestPartSent Event Type Type Description EventHandler Implements IDisposable" }, "api/Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.html": { "href": "api/Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.html", "title": "Class SessionEventArgsBase | Titanium Web Proxy", - "summary": "Class SessionEventArgsBase Holds info related to a single proxy session (single request/response exchange). Under HTTP/2 and HTTP/3, many sessions share one client connection (one stream each); ending a session ends that request/response exchange, not necessarily the connection. Inheritance object EventArgs ProxyEventArgsBase SessionEventArgsBase SessionEventArgs TunnelConnectSessionEventArgs Implements IDisposable Inherited Members ProxyEventArgsBase.ClientUserData EventArgs.Empty object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.EventArguments Assembly: Titanium.Web.Proxy.dll Syntax public abstract class SessionEventArgsBase : ProxyEventArgsBase, IDisposable Fields | Edit this page View Source BufferPool Declaration protected readonly IBufferPool BufferPool Field Value Type Description IBufferPool Properties | Edit this page View Source ClientConnectionId Identity of the inbound client transport connection. Multiplexed HTTP/2 and HTTP/3 streams that share one client connection expose the same value. Values are process-wide monotonic counters starting at 1 (wrapping back to 1 after MaxValue). Declaration public long ClientConnectionId { get; } Property Value Type Description long | Edit this page View Source ClientEndPoint Declaration [Obsolete(\"Use ClientRemoteEndPoint instead.\")] public IPEndPoint ClientEndPoint { get; } Property Value Type Description IPEndPoint | Edit this page View Source ClientLocalEndPoint Client Local End Point. Declaration public IPEndPoint ClientLocalEndPoint { get; } Property Value Type Description IPEndPoint | Edit this page View Source ClientRemoteEndPoint Client Remote End Point. Declaration public IPEndPoint ClientRemoteEndPoint { get; } Property Value Type Description IPEndPoint | Edit this page View Source ConnectTimeout Per-session override for ConnectTimeOutSeconds. null uses the server default; Zero or negative disables the connect timeout. Set in BeforeRequest to speed up or slow down the TCP connect race for this individual request. Declaration public TimeSpan? ConnectTimeout { get; set; } Property Value Type Description TimeSpan? | Edit this page View Source CustomUpStreamProxy Gets or sets the custom up stream proxy. Declaration public IExternalProxy? CustomUpStreamProxy { get; set; } Property Value Type Description IExternalProxy The custom up stream proxy. | Edit this page View Source CustomUpStreamProxyUsed Are we using a custom upstream HTTP(S) proxy? Declaration public IExternalProxy? CustomUpStreamProxyUsed { get; } Property Value Type Description IExternalProxy | Edit this page View Source EnableWinAuth Enable/disable Windows Authentication (NTLM/Kerberos) for the current session. Declaration public bool EnableWinAuth { get; set; } Property Value Type Description bool | Edit this page View Source Exception The last exception that happened. Declaration public Exception? Exception { get; } Property Value Type Description Exception | Edit this page View Source HttpClient The web client used to communicate with server for this session. Declaration public HttpWebClient HttpClient { get; } Property Value Type Description HttpWebClient | Edit this page View Source IsHttps Does this session uses SSL? Declaration public bool IsHttps { get; } Property Value Type Description bool | Edit this page View Source IsSocks Is this a SOCKS endpoint? Declaration public bool IsSocks { get; } Property Value Type Description bool | Edit this page View Source IsTransparent Is this a transparent endpoint (TCP or QUIC)? Declaration public bool IsTransparent { get; } Property Value Type Description bool | Edit this page View Source LocalEndPoint Declaration [Obsolete(\"Use ProxyEndPoint instead.\")] public ProxyEndPoint LocalEndPoint { get; } Property Value Type Description ProxyEndPoint | Edit this page View Source Logger The live logger for the ProxyServer that owns this session. Always reads the server's current logger rather than a value snapshotted at session creation, so a logger replaced via ApplyLoggingConfiguration() is picked up immediately. Declaration protected ILogger Logger { get; } Property Value Type Description ILogger | Edit this page View Source ProxyEndPoint Local endpoint via which we make the request. Declaration public ProxyEndPoint ProxyEndPoint { get; } Property Value Type Description ProxyEndPoint | Edit this page View Source ServerConnectionId Identity of the upstream origin transport connection when one has been acquired for this session; otherwise 0. Multiplexed HTTP/2 and HTTP/3 sessions that share one origin connection expose the same value. This does not imply per-session pool ownership of that connection. Values are process-wide monotonic counters starting at 1 (wrapping back to 1 after MaxValue). Declaration public long ServerConnectionId { get; } Property Value Type Description long | Edit this page View Source ServerIpAddress IP address of ServerRemoteEndPoint. Declaration public IPAddress? ServerIpAddress { get; } Property Value Type Description IPAddress | Edit this page View Source ServerRemoteEndPoint Physical peer of the established upstream connection (no second DNS lookup). Available after the server connection is established (for example in BeforeResponse), including multiplexed HTTP/2 and HTTP/3 sessions that bind identity without transferring HTTP/1.1 TCP ownership. When an upstream HTTP/SOCKS proxy is used, this is the proxy hop endpoint, not the origin server. null when no upstream connection exists (for example a synthetic local response). Declaration public IPEndPoint? ServerRemoteEndPoint { get; } Property Value Type Description IPEndPoint | Edit this page View Source Timing Structured timing for this session's request/response exchange, populated only when EnableRequestTimingCapture is enabled (otherwise null and no timing overhead is incurred anywhere in the proxy). See HttpRequestTiming. Declaration public HttpRequestTiming? Timing { get; } Property Value Type Description HttpRequestTiming | Edit this page View Source UpstreamConnectionTiming Structured timing for the upstream connection currently used by this session, populated only when EnableRequestTimingCapture is enabled, including multiplexed HTTP/2 and HTTP/3 sessions that bind identity without transferring HTTP/1.1 TCP ownership (those sharing one origin connection expose the same instance). null when timing capture is disabled or no upstream connection has been acquired yet (e.g. the request was answered synthetically). See UpstreamConnectionTiming. Declaration public UpstreamConnectionTiming? UpstreamConnectionTiming { get; } Property Value Type Description UpstreamConnectionTiming | Edit this page View Source UserData Returns a user data for this request/response session which is same as the user data of HttpClient. Declaration public object? UserData { get; set; } Property Value Type Description object | Edit this page View Source WebSession Declaration [Obsolete(\"Use HttpClient instead.\")] public HttpWebClient WebSession { get; } Property Value Type Description HttpWebClient Methods | Edit this page View Source Dispose() Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. Declaration public void Dispose() | Edit this page View Source Dispose(bool) Declaration protected virtual void Dispose(bool disposing) Parameters Type Name Description bool disposing | Edit this page View Source OnException(Exception) Declaration protected void OnException(Exception exception) Parameters Type Name Description Exception exception | Edit this page View Source TerminateSession() Terminates the session abruptly by terminating client/server connections. Declaration public void TerminateSession() Events | Edit this page View Source DataReceived Fired when data is received within this session from client/server. Declaration public event EventHandler? DataReceived Event Type Type Description EventHandler | Edit this page View Source DataSent Fired when data is sent within this session to server/client. Declaration public event EventHandler? DataSent Event Type Type Description EventHandler Implements IDisposable" + "summary": "Class SessionEventArgsBase Holds info related to a single proxy session (single request/response exchange). Under HTTP/2 and HTTP/3, many sessions share one client connection (one stream each); ending a session ends that request/response exchange, not necessarily the connection. Inheritance object EventArgs ProxyEventArgsBase SessionEventArgsBase SessionEventArgs TunnelConnectSessionEventArgs Implements IDisposable Inherited Members ProxyEventArgsBase.ClientUserData EventArgs.Empty object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.EventArguments Assembly: Titanium.Web.Proxy.dll Syntax public abstract class SessionEventArgsBase : ProxyEventArgsBase, IDisposable Fields | Edit this page View Source BufferPool Declaration protected readonly IBufferPool BufferPool Field Value Type Description IBufferPool Properties | Edit this page View Source ClientConnectionId Identity of the inbound client transport connection. Multiplexed HTTP/2 and HTTP/3 streams that share one client connection expose the same value. Values are process-wide monotonic counters starting at 1 (wrapping back to 1 after MaxValue). Declaration public long ClientConnectionId { get; } Property Value Type Description long | Edit this page View Source ClientEndPoint Declaration [Obsolete(\"Use ClientRemoteEndPoint instead.\")] public IPEndPoint ClientEndPoint { get; } Property Value Type Description IPEndPoint | Edit this page View Source ClientLocalEndPoint Client Local End Point. Declaration public IPEndPoint ClientLocalEndPoint { get; } Property Value Type Description IPEndPoint | Edit this page View Source ClientRemoteEndPoint Client Remote End Point. Declaration public IPEndPoint ClientRemoteEndPoint { get; } Property Value Type Description IPEndPoint | Edit this page View Source ConnectTimeout Per-session override for ConnectTimeOutSeconds. null uses the server default; Zero or negative disables the connect timeout. Set in BeforeRequest to speed up or slow down the TCP connect race for this individual request. Declaration public TimeSpan? ConnectTimeout { get; set; } Property Value Type Description TimeSpan? | Edit this page View Source CustomUpStreamProxy Gets or sets the custom up stream proxy. Declaration public IExternalProxy? CustomUpStreamProxy { get; set; } Property Value Type Description IExternalProxy The custom up stream proxy. | Edit this page View Source CustomUpStreamProxyUsed Are we using a custom upstream HTTP(S) proxy? Declaration public IExternalProxy? CustomUpStreamProxyUsed { get; } Property Value Type Description IExternalProxy | Edit this page View Source EnableWinAuth Enable/disable Windows Authentication (NTLM/Kerberos) for the current session. Declaration public bool EnableWinAuth { get; set; } Property Value Type Description bool | Edit this page View Source Exception The last exception that happened. Declaration public Exception? Exception { get; } Property Value Type Description Exception | Edit this page View Source HttpClient The web client used to communicate with server for this session. Declaration public HttpWebClient HttpClient { get; } Property Value Type Description HttpWebClient | Edit this page View Source IsHttps Does this session uses SSL? Declaration public bool IsHttps { get; } Property Value Type Description bool | Edit this page View Source IsSocks Is this a SOCKS endpoint? Declaration public bool IsSocks { get; } Property Value Type Description bool | Edit this page View Source IsTransparent Is this a transparent endpoint (TCP or QUIC)? Declaration public bool IsTransparent { get; } Property Value Type Description bool | Edit this page View Source LocalEndPoint Declaration [Obsolete(\"Use ProxyEndPoint instead.\")] public ProxyEndPoint LocalEndPoint { get; } Property Value Type Description ProxyEndPoint | Edit this page View Source Logger The live logger for the ProxyServer that owns this session. Always reads the server's current logger rather than a value snapshotted at session creation, so a logger replaced via ApplyLoggingConfiguration() is picked up immediately. Declaration protected ILogger Logger { get; } Property Value Type Description ILogger | Edit this page View Source ProxyEndPoint Local endpoint via which we make the request. Declaration public ProxyEndPoint ProxyEndPoint { get; } Property Value Type Description ProxyEndPoint | Edit this page View Source ServerConnectionId Identity of the upstream origin transport connection when one has been acquired for this session; otherwise 0. Multiplexed HTTP/2 and HTTP/3 sessions that share one origin connection expose the same value. This does not imply per-session pool ownership of that connection. Values are process-wide monotonic counters starting at 1 (wrapping back to 1 after MaxValue). Declaration public long ServerConnectionId { get; } Property Value Type Description long | Edit this page View Source ServerIpAddress IP address of ServerRemoteEndPoint. Declaration public IPAddress? ServerIpAddress { get; } Property Value Type Description IPAddress | Edit this page View Source ServerRemoteEndPoint Physical peer of the established upstream connection (no second DNS lookup). Available after the server connection is established (for example in BeforeResponse), including multiplexed HTTP/2 and HTTP/3 sessions that bind identity without transferring HTTP/1.1 TCP ownership. When an upstream HTTP/SOCKS proxy is used, this is the proxy hop endpoint, not the origin server. null when no upstream connection exists (for example a synthetic local response). Declaration public IPEndPoint? ServerRemoteEndPoint { get; } Property Value Type Description IPEndPoint | Edit this page View Source Timing Structured timing for this session's request/response exchange, populated only when EnableRequestTimingCapture is enabled (otherwise null and no timing overhead is incurred anywhere in the proxy). See HttpRequestTiming. Declaration public HttpRequestTiming? Timing { get; } Property Value Type Description HttpRequestTiming | Edit this page View Source UpstreamConnectionTiming Structured timing for the upstream connection currently used by this session, populated only when EnableRequestTimingCapture is enabled, including multiplexed HTTP/2 and HTTP/3 sessions that bind identity without transferring HTTP/1.1 TCP ownership (those sharing one origin connection expose the same instance). null when timing capture is disabled or no upstream connection has been acquired yet (e.g. the request was answered synthetically). See UpstreamConnectionTiming. Declaration public UpstreamConnectionTiming? UpstreamConnectionTiming { get; } Property Value Type Description UpstreamConnectionTiming | Edit this page View Source UpstreamDestinationId Selected cluster destination id when reverse-proxy routing applied this session; otherwise null. Used by Plus circuit breaker / health bookkeeping. Declaration public string? UpstreamDestinationId { get; } Property Value Type Description string | Edit this page View Source UserData Returns a user data for this request/response session which is same as the user data of HttpClient. Declaration public object? UserData { get; set; } Property Value Type Description object | Edit this page View Source WebSession Declaration [Obsolete(\"Use HttpClient instead.\")] public HttpWebClient WebSession { get; } Property Value Type Description HttpWebClient Methods | Edit this page View Source Dispose() Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. Declaration public void Dispose() | Edit this page View Source Dispose(bool) Declaration protected virtual void Dispose(bool disposing) Parameters Type Name Description bool disposing | Edit this page View Source OnException(Exception) Declaration protected void OnException(Exception exception) Parameters Type Name Description Exception exception | Edit this page View Source TerminateSession() Terminates the session abruptly by terminating client/server connections. Declaration public void TerminateSession() Events | Edit this page View Source DataReceived Fired when data is received within this session from client/server. Declaration public event EventHandler? DataReceived Event Type Type Description EventHandler | Edit this page View Source DataSent Fired when data is sent within this session to server/client. Declaration public event EventHandler? DataSent Event Type Type Description EventHandler Implements IDisposable" }, "api/Titanium.Web.Proxy.EventArguments.SocksAuthenticateEventArgs.html": { "href": "api/Titanium.Web.Proxy.EventArguments.SocksAuthenticateEventArgs.html", @@ -132,7 +137,7 @@ "api/Titanium.Web.Proxy.EventArguments.TunnelConnectSessionEventArgs.html": { "href": "api/Titanium.Web.Proxy.EventArguments.TunnelConnectSessionEventArgs.html", "title": "Class TunnelConnectSessionEventArgs | Titanium Web Proxy", - "summary": "Class TunnelConnectSessionEventArgs A class that wraps the state when a tunnel connect event happen for Explicit endpoints. Inheritance object EventArgs ProxyEventArgsBase SessionEventArgsBase TunnelConnectSessionEventArgs Implements IDisposable Inherited Members SessionEventArgsBase.BufferPool SessionEventArgsBase.ClientConnectionId SessionEventArgsBase.ServerConnectionId SessionEventArgsBase.Timing SessionEventArgsBase.UpstreamConnectionTiming SessionEventArgsBase.UserData SessionEventArgsBase.ConnectTimeout SessionEventArgsBase.EnableWinAuth SessionEventArgsBase.IsHttps SessionEventArgsBase.ClientLocalEndPoint SessionEventArgsBase.ClientRemoteEndPoint SessionEventArgsBase.ClientEndPoint SessionEventArgsBase.ServerRemoteEndPoint SessionEventArgsBase.ServerIpAddress SessionEventArgsBase.HttpClient SessionEventArgsBase.WebSession SessionEventArgsBase.CustomUpStreamProxy SessionEventArgsBase.CustomUpStreamProxyUsed SessionEventArgsBase.ProxyEndPoint SessionEventArgsBase.LocalEndPoint SessionEventArgsBase.IsTransparent SessionEventArgsBase.IsSocks SessionEventArgsBase.Exception SessionEventArgsBase.Logger SessionEventArgsBase.Dispose() SessionEventArgsBase.OnException(Exception) SessionEventArgsBase.Dispose(bool) SessionEventArgsBase.DataSent SessionEventArgsBase.DataReceived SessionEventArgsBase.TerminateSession() ProxyEventArgsBase.ClientUserData EventArgs.Empty object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.EventArguments Assembly: Titanium.Web.Proxy.dll Syntax public class TunnelConnectSessionEventArgs : SessionEventArgsBase, IDisposable Properties | Edit this page View Source AllowHttpProtocolTranslation Whether the proxy may bridge a mismatch between the client's negotiated HTTP version and the origin's HTTP version implied by UpstreamHttpProtocol. Defaults to false, in which case Http11 instead simply never offers \"h2\" to the client (so no mismatch, and no translation, is ever needed) and Http2 fails the connection outright if the client does not also support HTTP/2. Declaration public bool AllowHttpProtocolTranslation { get; set; } Property Value Type Description bool | Edit this page View Source ClientTlsTiming Timing of the client-facing (browser-to-proxy) TLS handshake performed while decrypting this tunnel, populated only when EnableRequestTimingCapture is enabled and DecryptSsl is true; null otherwise (including for a plain, non-HTTPS CONNECT tunnel that is never TLS-decrypted at all). Declaration public ClientTlsTiming? ClientTlsTiming { get; } Property Value Type Description ClientTlsTiming | Edit this page View Source ConnectTiming CONNECT-phase milestones (certificate readiness, origin capability resolution, HTTP/2 probe, browser TLS) populated only when EnableRequestTimingCapture is enabled and DecryptSsl is true; otherwise null. Declaration public TunnelConnectTiming? ConnectTiming { get; } Property Value Type Description TunnelConnectTiming | Edit this page View Source DecryptSsl Should we decrypt the Ssl or relay it to server? Default is true. Declaration public bool DecryptSsl { get; set; } Property Value Type Description bool | Edit this page View Source DenyConnect When set to true it denies the connect request with a Forbidden status. Declaration public bool DenyConnect { get; set; } Property Value Type Description bool | Edit this page View Source EstablishServerConnectionBeforeResponse When true, the proxy establishes the upstream TCP connection (and upstream-proxy CONNECT when configured) before writing HTTP 200 to the client. On failure, BeforeTunnelConnectFailure can supply a custom HTTP error response (the client never receives 200 / never starts TLS). Default is false — no preconnect and no added latency. Set this during BeforeTunnelConnectRequest. Declaration public bool EstablishServerConnectionBeforeResponse { get; set; } Property Value Type Description bool | Edit this page View Source IsHttpsConnect Is this a connect request to secure HTTP server? Or is it to some other protocol. Declaration public bool IsHttpsConnect { get; } Property Value Type Description bool | Edit this page View Source UpstreamHttpProtocol Controls which HTTP version the proxy uses on its own connection to the origin server for this tunnel, independent of the HTTP version the client itself negotiates with the proxy. Must be set during BeforeTunnelConnectRequest - it is read before the client TLS handshake, and the client's own ALPN offer/negotiation cannot change afterward. See UpstreamHttpProtocol. Declaration public UpstreamHttpProtocol UpstreamHttpProtocol { get; set; } Property Value Type Description UpstreamHttpProtocol Exceptions Type Condition ArgumentOutOfRangeException The value is not a defined UpstreamHttpProtocol member. Events | Edit this page View Source DecryptedDataReceived Fired when decrypted data is received within this session from client/server. Declaration public event EventHandler? DecryptedDataReceived Event Type Type Description EventHandler | Edit this page View Source DecryptedDataSent Fired when decrypted data is sent within this session to server/client. Declaration public event EventHandler? DecryptedDataSent Event Type Type Description EventHandler Implements IDisposable" + "summary": "Class TunnelConnectSessionEventArgs A class that wraps the state when a tunnel connect event happen for Explicit endpoints. Inheritance object EventArgs ProxyEventArgsBase SessionEventArgsBase TunnelConnectSessionEventArgs Implements IDisposable Inherited Members SessionEventArgsBase.BufferPool SessionEventArgsBase.ClientConnectionId SessionEventArgsBase.ServerConnectionId SessionEventArgsBase.Timing SessionEventArgsBase.UpstreamConnectionTiming SessionEventArgsBase.UserData SessionEventArgsBase.ConnectTimeout SessionEventArgsBase.EnableWinAuth SessionEventArgsBase.IsHttps SessionEventArgsBase.ClientLocalEndPoint SessionEventArgsBase.ClientRemoteEndPoint SessionEventArgsBase.ClientEndPoint SessionEventArgsBase.ServerRemoteEndPoint SessionEventArgsBase.ServerIpAddress SessionEventArgsBase.HttpClient SessionEventArgsBase.WebSession SessionEventArgsBase.CustomUpStreamProxy SessionEventArgsBase.CustomUpStreamProxyUsed SessionEventArgsBase.UpstreamDestinationId SessionEventArgsBase.ProxyEndPoint SessionEventArgsBase.LocalEndPoint SessionEventArgsBase.IsTransparent SessionEventArgsBase.IsSocks SessionEventArgsBase.Exception SessionEventArgsBase.Logger SessionEventArgsBase.Dispose() SessionEventArgsBase.OnException(Exception) SessionEventArgsBase.Dispose(bool) SessionEventArgsBase.DataSent SessionEventArgsBase.DataReceived SessionEventArgsBase.TerminateSession() ProxyEventArgsBase.ClientUserData EventArgs.Empty object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.EventArguments Assembly: Titanium.Web.Proxy.dll Syntax public class TunnelConnectSessionEventArgs : SessionEventArgsBase, IDisposable Properties | Edit this page View Source AllowHttpProtocolTranslation Whether the proxy may bridge a mismatch between the client's negotiated HTTP version and the origin's HTTP version implied by UpstreamHttpProtocol. Defaults to false, in which case Http11 instead simply never offers \"h2\" to the client (so no mismatch, and no translation, is ever needed) and Http2 fails the connection outright if the client does not also support HTTP/2. Declaration public bool AllowHttpProtocolTranslation { get; set; } Property Value Type Description bool | Edit this page View Source ClientTlsTiming Timing of the client-facing (browser-to-proxy) TLS handshake performed while decrypting this tunnel, populated only when EnableRequestTimingCapture is enabled and DecryptSsl is true; null otherwise (including for a plain, non-HTTPS CONNECT tunnel that is never TLS-decrypted at all). Declaration public ClientTlsTiming? ClientTlsTiming { get; } Property Value Type Description ClientTlsTiming | Edit this page View Source ConnectTiming CONNECT-phase milestones (certificate readiness, origin capability resolution, HTTP/2 probe, browser TLS) populated only when EnableRequestTimingCapture is enabled and DecryptSsl is true; otherwise null. Declaration public TunnelConnectTiming? ConnectTiming { get; } Property Value Type Description TunnelConnectTiming | Edit this page View Source DecryptSsl Should we decrypt the Ssl or relay it to server? Default is true. Declaration public bool DecryptSsl { get; set; } Property Value Type Description bool | Edit this page View Source DenyConnect When set to true it denies the connect request with a Forbidden status. Declaration public bool DenyConnect { get; set; } Property Value Type Description bool | Edit this page View Source EstablishServerConnectionBeforeResponse When true, the proxy establishes the upstream TCP connection (and upstream-proxy CONNECT when configured) before writing HTTP 200 to the client. On failure, BeforeTunnelConnectFailure can supply a custom HTTP error response (the client never receives 200 / never starts TLS). Default is false — no preconnect and no added latency. Set this during BeforeTunnelConnectRequest. Declaration public bool EstablishServerConnectionBeforeResponse { get; set; } Property Value Type Description bool | Edit this page View Source IsHttpsConnect Is this a connect request to secure HTTP server? Or is it to some other protocol. Declaration public bool IsHttpsConnect { get; } Property Value Type Description bool | Edit this page View Source UpstreamHttpProtocol Controls which HTTP version the proxy uses on its own connection to the origin server for this tunnel, independent of the HTTP version the client itself negotiates with the proxy. Must be set during BeforeTunnelConnectRequest - it is read before the client TLS handshake, and the client's own ALPN offer/negotiation cannot change afterward. See UpstreamHttpProtocol. Declaration public UpstreamHttpProtocol UpstreamHttpProtocol { get; set; } Property Value Type Description UpstreamHttpProtocol Exceptions Type Condition ArgumentOutOfRangeException The value is not a defined UpstreamHttpProtocol member. Events | Edit this page View Source DecryptedDataReceived Fired when decrypted data is received within this session from client/server. Declaration public event EventHandler? DecryptedDataReceived Event Type Type Description EventHandler | Edit this page View Source DecryptedDataSent Fired when decrypted data is sent within this session to server/client. Declaration public event EventHandler? DecryptedDataSent Event Type Type Description EventHandler Implements IDisposable" }, "api/Titanium.Web.Proxy.EventArguments.WebSocketFrameDirection.html": { "href": "api/Titanium.Web.Proxy.EventArguments.WebSocketFrameDirection.html", @@ -204,6 +209,16 @@ "title": "Namespace Titanium.Web.Proxy.Exceptions | Titanium Web Proxy", "summary": "Namespace Titanium.Web.Proxy.Exceptions Classes BodyNotFoundException An exception thrown when body is unexpectedly empty. OutboundDestinationBlockedException Thrown when BlockPrivateNetworkDestinations is enabled and a request's resolved destination address is loopback, private, link-local, or another non-globally-routable address - the outbound destination policy hook described in the hardening plan's \"PublicFacing\" posture, protecting a proxy that accepts requests from untrusted clients against SSRF into the host's own private network. ProxyAuthorizationException Proxy authorization exception. ProxyConnectException Proxy Connection exception. ProxyException Base class exception associated with this proxy server. ProxyHttpException Proxy HTTP exception. ProxyTimeoutException Thrown when a configured proxy timeout elapses. Surfaced through Titanium.Web.Proxy.Logging.ProxyDiagnostics so callers can distinguish connect, response-header, idle, and total request deadlines. UpstreamProxyConnectException Thrown when an HTTP upstream proxy rejects a CONNECT tunnel (or otherwise fails to establish one) with a non-success response. Carries the upstream status, headers, and a bounded body snapshot for diagnostics. Relaying that response to the client is only safe before the client-facing CONNECT 200 has been committed — enable EstablishServerConnectionBeforeResponse and handle BeforeTunnelConnectFailure (issue #768). Enums ProxyTimeoutKind Identifies which configured proxy timeout elapsed." }, + "api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html": { + "href": "api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html", + "title": "Class UnixProxyBypassMapper | Titanium Web Proxy", + "summary": "Class UnixProxyBypassMapper Maps WinINET-style semicolon bypass lists to macOS/Linux formats. Inheritance object UnixProxyBypassMapper Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Helpers Assembly: Titanium.Web.Proxy.dll Syntax public static class UnixProxyBypassMapper Methods | Edit this page View Source IsLocalHost(string?) Declaration public static bool IsLocalHost(string? host) Parameters Type Name Description string host Returns Type Description bool | Edit this page View Source ToCommaSeparated(string?) Declaration public static string ToCommaSeparated(string? winInetProxyOverride) Parameters Type Name Description string winInetProxyOverride Returns Type Description string | Edit this page View Source ToGsettingsArray(string?) Declaration public static string ToGsettingsArray(string? winInetProxyOverride) Parameters Type Name Description string winInetProxyOverride Returns Type Description string | Edit this page View Source ToNoProxyEnv(string?) Declaration public static string ToNoProxyEnv(string? winInetProxyOverride) Parameters Type Name Description string winInetProxyOverride Returns Type Description string | Edit this page View Source ToNoProxyEnv(string?, bool) Builds a NO_PROXY value. When proxyLoopback is true, localhost is omitted so loopback traffic can use the proxy (parity with WinINET <-loopback>). Declaration public static string ToNoProxyEnv(string? winInetProxyOverride, bool proxyLoopback) Parameters Type Name Description string winInetProxyOverride bool proxyLoopback Returns Type Description string | Edit this page View Source ToUnixBypassHosts(string?) Declaration public static IReadOnlyList ToUnixBypassHosts(string? winInetProxyOverride) Parameters Type Name Description string winInetProxyOverride Returns Type Description IReadOnlyList" + }, + "api/Titanium.Web.Proxy.Helpers.html": { + "href": "api/Titanium.Web.Proxy.Helpers.html", + "title": "Namespace Titanium.Web.Proxy.Helpers | Titanium Web Proxy", + "summary": "Namespace Titanium.Web.Proxy.Helpers Classes UnixProxyBypassMapper Maps WinINET-style semicolon bypass lists to macOS/Linux formats." + }, "api/Titanium.Web.Proxy.Http.ConnectRequest.html": { "href": "api/Titanium.Web.Proxy.Http.ConnectRequest.html", "title": "Class ConnectRequest | Titanium Web Proxy", @@ -227,7 +242,7 @@ "api/Titanium.Web.Proxy.Http.HttpWebClient.html": { "href": "api/Titanium.Web.Proxy.Http.HttpWebClient.html", "title": "Class HttpWebClient | Titanium Web Proxy", - "summary": "Class HttpWebClient Used to communicate with the server over HTTP(S) Inheritance object HttpWebClient Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Http Assembly: Titanium.Web.Proxy.dll Syntax public class HttpWebClient Properties | Edit this page View Source ConnectRequest Headers passed with Connect. Declaration public ConnectRequest? ConnectRequest { get; } Property Value Type Description ConnectRequest | Edit this page View Source IsHttps Is Https? Declaration public bool IsHttps { get; } Property Value Type Description bool | Edit this page View Source ProcessId PID of the process that is created the current session when client is running in this machine If client is remote then this will return Declaration public Lazy ProcessId { get; } Property Value Type Description Lazy | Edit this page View Source Request Web Request. Declaration public Request Request { get; } Property Value Type Description Request | Edit this page View Source Response Web Response. Declaration public Response Response { get; } Property Value Type Description Response | Edit this page View Source UpStreamEndPoint Override UpStreamEndPoint for this request; Local NIC via request is made. Ignored for a destination whose address family does not match; prefer UpStreamEndPointIPv4 / UpStreamEndPointIPv6 for dual-stack. Declaration public IPEndPoint? UpStreamEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv4 Per-request local bind for IPv4 upstream destinations (overrides server UpStreamEndPointIPv4). Declaration public IPEndPoint? UpStreamEndPointIPv4 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv6 Per-request local bind for IPv6 upstream destinations (overrides server UpStreamEndPointIPv6). Declaration public IPEndPoint? UpStreamEndPointIPv6 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UserData Gets or sets the user data. Declaration public object? UserData { get; set; } Property Value Type Description object" + "summary": "Class HttpWebClient Used to communicate with the server over HTTP(S) Inheritance object HttpWebClient Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Http Assembly: Titanium.Web.Proxy.dll Syntax public class HttpWebClient Properties | Edit this page View Source ConnectRequest Headers passed with Connect. Declaration public ConnectRequest? ConnectRequest { get; } Property Value Type Description ConnectRequest | Edit this page View Source IsHttps Is Https? Declaration public bool IsHttps { get; } Property Value Type Description bool | Edit this page View Source ProcessId PID of the local client process for this session (Windows, Linux, and macOS). Remote clients, unsupported platforms, and unresolved sockets yield a non-positive value. See IsSupported. Declaration public Lazy ProcessId { get; } Property Value Type Description Lazy | Edit this page View Source Request Web Request. Declaration public Request Request { get; } Property Value Type Description Request | Edit this page View Source Response Web Response. Created on first access so H2/H3 MITM Lite request-only work does not allocate a Response + HeaderCollection graph per stream up front. CompareExchange: H2 request/response legs can race the first access. Declaration public Response Response { get; } Property Value Type Description Response | Edit this page View Source UpStreamEndPoint Override UpStreamEndPoint for this request; Local NIC via request is made. Ignored for a destination whose address family does not match; prefer UpStreamEndPointIPv4 / UpStreamEndPointIPv6 for dual-stack. Declaration public IPEndPoint? UpStreamEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv4 Per-request local bind for IPv4 upstream destinations (overrides server UpStreamEndPointIPv4). Declaration public IPEndPoint? UpStreamEndPointIPv4 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv6 Per-request local bind for IPv6 upstream destinations (overrides server UpStreamEndPointIPv6). Declaration public IPEndPoint? UpStreamEndPointIPv6 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UserData Gets or sets the user data. Declaration public object? UserData { get; set; } Property Value Type Description object" }, "api/Titanium.Web.Proxy.Http.KnownHeader.html": { "href": "api/Titanium.Web.Proxy.Http.KnownHeader.html", @@ -294,6 +309,16 @@ "title": "Namespace Titanium.Web.Proxy.Http | Titanium Web Proxy", "summary": "Namespace Titanium.Web.Proxy.Http Classes ConnectRequest The tcp tunnel Connect request. ConnectResponse The tcp tunnel connect response object. HeaderCollection The http header collection. HttpWebClient Used to communicate with the server over HTTP(S) KnownHeader KnownHeaders Well known http headers. ProxyResults Factory methods that build synthetic Response or StreamingProxyResult objects for use with Respond(Response, bool) and RespondStreaming(StreamingProxyResult, bool). Request Http(s) request object RequestResponseBase Abstract base class for similar objects shared by both request and response objects. Response Http(s) response object Structs HeaderCollection.Enumerator Walks first the unique-header dictionary's values, then each list in the non-unique-header dictionary's values in turn - the same effective order as the previous Concat(...SelectMany(...)) implementation - without allocating any LINQ iterator objects. See the remarks on GetEnumerator() for why this is worth a hand-written enumerator instead of the equivalent LINQ expression. StreamingProxyResult Pairs a synthetic response (status and headers) with a delegate that writes the body on the fly. Pass to RespondStreaming(StreamingProxyResult, bool) to stream without buffering the whole body in memory. Enums TunnelType" }, + "api/Titanium.Web.Proxy.Http3.Http3NativeBootstrap.html": { + "href": "api/Titanium.Web.Proxy.Http3.Http3NativeBootstrap.html", + "title": "Class Http3NativeBootstrap | Titanium Web Proxy", + "summary": "Class Http3NativeBootstrap Ensures app-local MsQuic natives are visible to QuicListener on macOS framework-dependent hosts (typical Debug / dotnet run). Inheritance object Http3NativeBootstrap Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Http3 Assembly: Titanium.Web.Proxy.dll Syntax public static class Http3NativeBootstrap Remarks On non-Windows, IsSupported loads MsQuic by leaf name only (libmsquic), not from BaseDirectory. Self-contained publishes place System.Net.Quic.dll next to the bundled dylibs so AssemblyDirectory search works. Framework-dependent builds keep Quic in the shared framework directory, so copying dylibs beside the app is not enough unless DYLD_FALLBACK_LIBRARY_PATH (or DYLD_LIBRARY_PATH) includes that folder — set before process start. Re-launch uses a child process. The parent must forward POSIX termination/reload signals to that child (and cancel the parent's default terminate) so kill -HUP , SIGTERM from a service manager, and Ctrl+C reach the process that actually runs the proxy. Methods | Edit this page View Source EnsureAppLocalMsQuicVisible(string[]?) When macOS app-local libmsquic.dylib is present but dyld cannot see it yet, re-launches the current process with DYLD_FALLBACK_LIBRARY_PATH pointing at BaseDirectory. No-ops on Windows/Linux, self-contained layouts, when natives are missing, or when the library path already includes the app directory. Declaration public static void EnsureAppLocalMsQuicVisible(string[]? args = null) Parameters Type Name Description string[] args Application arguments (as passed to Main), used when relaunching." + }, + "api/Titanium.Web.Proxy.Http3.html": { + "href": "api/Titanium.Web.Proxy.Http3.html", + "title": "Namespace Titanium.Web.Proxy.Http3 | Titanium Web Proxy", + "summary": "Namespace Titanium.Web.Proxy.Http3 Classes Http3NativeBootstrap Ensures app-local MsQuic natives are visible to QuicListener on macOS framework-dependent hosts (typical Debug / dotnet run)." + }, "api/Titanium.Web.Proxy.Logging.ProxyLoggingOptions.html": { "href": "api/Titanium.Web.Proxy.Logging.ProxyLoggingOptions.html", "title": "Class ProxyLoggingOptions | Titanium Web Proxy", @@ -314,6 +339,16 @@ "title": "Namespace Titanium.Web.Proxy.Middleware | Titanium Web Proxy", "summary": "Namespace Titanium.Web.Proxy.Middleware Classes ProxyMiddlewarePipeline Builds a middleware chain once per config reload. Empty list → invoke terminus with zero allocation." }, + "api/Titanium.Web.Proxy.MitmExclusionDefaults.html": { + "href": "api/Titanium.Web.Proxy.MitmExclusionDefaults.html", + "title": "Class MitmExclusionDefaults | Titanium Web Proxy", + "summary": "Class MitmExclusionDefaults Default hostname exclusions for MITM proxies (Microsoft identity / certificate pinning). Use with BypassRules and DecryptSsl. Inheritance object MitmExclusionDefaults Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy Assembly: Titanium.Web.Proxy.dll Syntax public static class MitmExclusionDefaults Fields | Edit this page View Source SystemProxyBypassRules WinINET / system-proxy bypass patterns for Microsoft identity endpoints (Entra / WAM / RDP). Declaration public static readonly string[] SystemProxyBypassRules Field Value Type Description string[] | Edit this page View Source TunnelOnlyPinningDomains Pinning hosts that should tunnel (DecryptSsl=false) but stay visible. Declaration public static readonly string[] TunnelOnlyPinningDomains Field Value Type Description string[] Methods | Edit this page View Source ApplyDecryptExclusions(ExplicitProxyEndPoint, Func) Registers per-CONNECT DecryptSsl gating on an explicit endpoint (Merge mode). Declaration public static void ApplyDecryptExclusions(ExplicitProxyEndPoint endPoint, Func decryptHttpsEnabled) Parameters Type Name Description ExplicitProxyEndPoint endPoint Func decryptHttpsEnabled | Edit this page View Source ApplyDecryptExclusions(ExplicitProxyEndPoint, Func, IEnumerable?, IEnumerable?) Registers per-CONNECT DecryptSsl gating on an explicit endpoint (Merge mode). Declaration public static void ApplyDecryptExclusions(ExplicitProxyEndPoint endPoint, Func decryptHttpsEnabled, IEnumerable? decryptSkipHosts, IEnumerable? decryptOnlyHosts) Parameters Type Name Description ExplicitProxyEndPoint endPoint Func decryptHttpsEnabled IEnumerable decryptSkipHosts IEnumerable decryptOnlyHosts | Edit this page View Source ApplyDecryptExclusions(ExplicitProxyEndPoint, Func, IEnumerable?, IEnumerable?, MitmExclusionMode) Registers per-CONNECT DecryptSsl gating on an explicit endpoint. Declaration public static void ApplyDecryptExclusions(ExplicitProxyEndPoint endPoint, Func decryptHttpsEnabled, IEnumerable? decryptSkipHosts, IEnumerable? decryptOnlyHosts, MitmExclusionMode mode) Parameters Type Name Description ExplicitProxyEndPoint endPoint Func decryptHttpsEnabled IEnumerable decryptSkipHosts IEnumerable decryptOnlyHosts MitmExclusionMode mode | Edit this page View Source CreateSystemProxySettings() Builds SystemProxySettings with factory identity bypass rules and loopback (Merge). Declaration public static SystemProxySettings CreateSystemProxySettings() Returns Type Description SystemProxySettings | Edit this page View Source CreateSystemProxySettings(bool) Builds SystemProxySettings with factory identity bypass rules (Merge). Declaration public static SystemProxySettings CreateSystemProxySettings(bool proxyLoopback) Parameters Type Name Description bool proxyLoopback Returns Type Description SystemProxySettings | Edit this page View Source CreateSystemProxySettings(bool, IEnumerable?) Builds SystemProxySettings with identity bypass rules and optional user additions (Merge). Declaration public static SystemProxySettings CreateSystemProxySettings(bool proxyLoopback, IEnumerable? additionalBypassRules) Parameters Type Name Description bool proxyLoopback IEnumerable additionalBypassRules Returns Type Description SystemProxySettings | Edit this page View Source CreateSystemProxySettings(bool, IEnumerable?, MitmExclusionMode) Builds SystemProxySettings from factory and/or caller bypass rules. Declaration public static SystemProxySettings CreateSystemProxySettings(bool proxyLoopback, IEnumerable? bypassRules, MitmExclusionMode mode) Parameters Type Name Description bool proxyLoopback When true, localhost uses the proxy (platform-specific loopback rule). IEnumerable bypassRules Extra rules in Merge, or the full authoritative list in Replace. MitmExclusionMode mode Merge factory OS-bypass defaults, or replace them with bypassRules. Returns Type Description SystemProxySettings | Edit this page View Source HostnameMatches(string, string) Wildcard-aware hostname match (*.example.com). Declaration public static bool HostnameMatches(string hostname, string pattern) Parameters Type Name Description string hostname string pattern Returns Type Description bool | Edit this page View Source IsBuiltInSslBypass(string) True when hostname matches factory OS-bypass or tunnel-only defaults. Declaration public static bool IsBuiltInSslBypass(string hostname) Parameters Type Name Description string hostname Returns Type Description bool | Edit this page View Source ShouldDisableSslDecrypt(string?) Returns true when CONNECT should use SSL passthrough instead of MITM (Merge mode). Declaration public static bool ShouldDisableSslDecrypt(string? hostname) Parameters Type Name Description string hostname Returns Type Description bool | Edit this page View Source ShouldDisableSslDecrypt(string?, IEnumerable?, IEnumerable?) Returns true when TLS should stay opaque (no MITM decrypt) using Merge. Declaration public static bool ShouldDisableSslDecrypt(string? hostname, IEnumerable? userSkipHosts, IEnumerable? userOnlyHosts) Parameters Type Name Description string hostname IEnumerable userSkipHosts IEnumerable userOnlyHosts Returns Type Description bool | Edit this page View Source ShouldDisableSslDecrypt(string?, IEnumerable?, IEnumerable?, MitmExclusionMode) Returns true when TLS should stay opaque (no MITM decrypt). Merge: factory SSO/pinning hosts always skip, then user skip / optional decrypt-only allowlist. Replace: only userSkipHosts and optional userOnlyHosts apply (factory hosts are not forced). Declaration public static bool ShouldDisableSslDecrypt(string? hostname, IEnumerable? userSkipHosts, IEnumerable? userOnlyHosts, MitmExclusionMode mode) Parameters Type Name Description string hostname IEnumerable userSkipHosts IEnumerable userOnlyHosts MitmExclusionMode mode Returns Type Description bool" + }, + "api/Titanium.Web.Proxy.MitmExclusionMode.html": { + "href": "api/Titanium.Web.Proxy.MitmExclusionMode.html", + "title": "Enum MitmExclusionMode | Titanium Web Proxy", + "summary": "Enum MitmExclusionMode How factory MITM exclusion defaults interact with caller-supplied host lists. Namespace: Titanium.Web.Proxy Assembly: Titanium.Web.Proxy.dll Syntax public enum MitmExclusionMode Fields Name Description Merge Factory OS-bypass and tunnel/SSO decrypt skips are always applied, then caller lists add more (and optional decrypt-only allowlist). Default for back-compat. Replace Caller lists are authoritative. Factory defaults are not re-injected — use them only as a seed when building the lists you pass in." + }, "api/Titanium.Web.Proxy.Models.ExplicitProxyEndPoint.html": { "href": "api/Titanium.Web.Proxy.Models.ExplicitProxyEndPoint.html", "title": "Class ExplicitProxyEndPoint | Titanium Web Proxy", @@ -337,7 +372,7 @@ "api/Titanium.Web.Proxy.Models.HttpInterceptionContext.html": { "href": "api/Titanium.Web.Proxy.Models.HttpInterceptionContext.html", "title": "Struct HttpInterceptionContext | Titanium Web Proxy", - "summary": "Struct HttpInterceptionContext Minimal, read-only context passed to ShouldInterceptHttp to let callers route requests to the fast-forward path or the full interception path without materialising a SessionEventArgs. Inherited Members ValueType.Equals(object) ValueType.GetHashCode() ValueType.ToString() object.Equals(object, object) object.GetType() object.ReferenceEquals(object, object) Namespace: Titanium.Web.Proxy.Models Assembly: Titanium.Web.Proxy.dll Syntax public readonly struct HttpInterceptionContext Properties | Edit this page View Source ClientProcessId Process ID of the local client (explicit proxy / Windows only; null otherwise). Declaration public int? ClientProcessId { get; init; } Property Value Type Description int? | Edit this page View Source ClientRemoteEndPoint Remote IP endpoint of the connected client (null when unavailable). Declaration public IPEndPoint? ClientRemoteEndPoint { get; init; } Property Value Type Description IPEndPoint | Edit this page View Source Hostname Target hostname (from Host / :authority), no port, no userinfo. Declaration public string Hostname { get; init; } Property Value Type Description string | Edit this page View Source HttpVersion HTTP version (1.1 / 2.0 / 3.0). Declaration public Version HttpVersion { get; init; } Property Value Type Description Version | Edit this page View Source IsHttps True when the connection is over TLS. Declaration public bool IsHttps { get; init; } Property Value Type Description bool | Edit this page View Source Method HTTP method (GET, POST, CONNECT, …). Declaration public string Method { get; init; } Property Value Type Description string | Edit this page View Source PathAndQuery Path and query only (no scheme/authority). Declaration public string PathAndQuery { get; init; } Property Value Type Description string | Edit this page View Source Port Target port (80, 443, or explicit). Declaration public int Port { get; init; } Property Value Type Description int | Edit this page View Source ProxyEndPoint The proxy endpoint that accepted this connection. Declaration public ProxyEndPoint ProxyEndPoint { get; init; } Property Value Type Description ProxyEndPoint" + "summary": "Struct HttpInterceptionContext Minimal, read-only context passed to ShouldInterceptHttp to let callers route requests to the fast-forward path or the full interception path without materialising a SessionEventArgs. Inherited Members ValueType.Equals(object) ValueType.GetHashCode() ValueType.ToString() object.Equals(object, object) object.GetType() object.ReferenceEquals(object, object) Namespace: Titanium.Web.Proxy.Models Assembly: Titanium.Web.Proxy.dll Syntax public readonly struct HttpInterceptionContext Properties | Edit this page View Source ClientProcessId Process ID of the local client when available (Windows/Linux/macOS explicit-proxy paths). Null when unset on the fast path or when the client is remote / unresolved. Declaration public int? ClientProcessId { get; init; } Property Value Type Description int? | Edit this page View Source ClientRemoteEndPoint Remote IP endpoint of the connected client (null when unavailable). Declaration public IPEndPoint? ClientRemoteEndPoint { get; init; } Property Value Type Description IPEndPoint | Edit this page View Source Hostname Target hostname (from Host / :authority), no port, no userinfo. Declaration public string Hostname { get; init; } Property Value Type Description string | Edit this page View Source HttpVersion HTTP version (1.1 / 2.0 / 3.0). Declaration public Version HttpVersion { get; init; } Property Value Type Description Version | Edit this page View Source IsHttps True when the connection is over TLS. Declaration public bool IsHttps { get; init; } Property Value Type Description bool | Edit this page View Source Method HTTP method (GET, POST, CONNECT, …). Declaration public string Method { get; init; } Property Value Type Description string | Edit this page View Source PathAndQuery Path and query only (no scheme/authority). Declaration public string PathAndQuery { get; init; } Property Value Type Description string | Edit this page View Source Port Target port (80, 443, or explicit). Declaration public int Port { get; init; } Property Value Type Description int | Edit this page View Source ProxyEndPoint The proxy endpoint that accepted this connection. Declaration public ProxyEndPoint ProxyEndPoint { get; init; } Property Value Type Description ProxyEndPoint" }, "api/Titanium.Web.Proxy.Models.IExternalProxy.html": { "href": "api/Titanium.Web.Proxy.Models.IExternalProxy.html", @@ -377,7 +412,7 @@ "api/Titanium.Web.Proxy.Models.TransparentBaseProxyEndPoint.html": { "href": "api/Titanium.Web.Proxy.Models.TransparentBaseProxyEndPoint.html", "title": "Class TransparentBaseProxyEndPoint | Titanium Web Proxy", - "summary": "Class TransparentBaseProxyEndPoint Inheritance object ProxyEndPoint TransparentBaseProxyEndPoint SocksProxyEndPoint TransparentProxyEndPoint TransparentQuicProxyEndPoint Inherited Members ProxyEndPoint.IpAddress ProxyEndPoint.Port ProxyEndPoint.DecryptSsl ProxyEndPoint.GenericCertificate ProxyEndPoint.MaxCachedConnections ProxyEndPoint.MaxConcurrentClients ProxyEndPoint.EnableHttpInterception ProxyEndPoint.AdmittedClientCount object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Models Assembly: Titanium.Web.Proxy.dll Syntax public abstract class TransparentBaseProxyEndPoint : ProxyEndPoint Constructors | Edit this page View Source TransparentBaseProxyEndPoint(IPAddress, int, bool) Declaration protected TransparentBaseProxyEndPoint(IPAddress ipAddress, int port, bool decryptSsl) Parameters Type Name Description IPAddress ipAddress int port bool decryptSsl Properties | Edit this page View Source ForwardCleartext When true together with DecryptSsl, the proxy terminates client TLS and opens a cleartext upstream TCP connection to ForwardHost/ForwardPort (classic TLS-terminating reverse proxy). Defaults to false (re-encrypt to the origin when the client spoke HTTPS). Declaration public bool ForwardCleartext { get; set; } Property Value Type Description bool | Edit this page View Source ForwardHost Optional fixed upstream server to forward all traffic on this endpoint to. Only the TCP connection target is changed; the original host is still used for TLS SNI/certificate validation and the HTTP Host header. Declaration public string? ForwardHost { get; set; } Property Value Type Description string | Edit this page View Source ForwardPort Optional fixed upstream port. When null the original request port is used. Declaration public int? ForwardPort { get; set; } Property Value Type Description int? | Edit this page View Source GenericCertificateName The hostname of the generic certificate to negotiate SSL. This will be only used when Sever Name Indication (SNI) is not supported by client, or when it does not indicate any host name. Declaration public abstract string GenericCertificateName { get; set; } Property Value Type Description string" + "summary": "Class TransparentBaseProxyEndPoint Inheritance object ProxyEndPoint TransparentBaseProxyEndPoint SocksProxyEndPoint TransparentProxyEndPoint TransparentQuicProxyEndPoint Inherited Members ProxyEndPoint.IpAddress ProxyEndPoint.Port ProxyEndPoint.DecryptSsl ProxyEndPoint.GenericCertificate ProxyEndPoint.MaxCachedConnections ProxyEndPoint.MaxConcurrentClients ProxyEndPoint.EnableHttpInterception ProxyEndPoint.AdmittedClientCount object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Models Assembly: Titanium.Web.Proxy.dll Syntax public abstract class TransparentBaseProxyEndPoint : ProxyEndPoint Constructors | Edit this page View Source TransparentBaseProxyEndPoint(IPAddress, int, bool) Declaration protected TransparentBaseProxyEndPoint(IPAddress ipAddress, int port, bool decryptSsl) Parameters Type Name Description IPAddress ipAddress int port bool decryptSsl Properties | Edit this page View Source ForwardCleartext When true together with DecryptSsl, the proxy terminates client TLS and opens a cleartext upstream TCP connection to ForwardHost/ForwardPort (classic TLS-terminating reverse proxy). Defaults to false (re-encrypt to the origin when the client spoke HTTPS). Declaration public bool ForwardCleartext { get; set; } Property Value Type Description bool | Edit this page View Source ForwardHost Optional fixed upstream server to forward all traffic on this endpoint to. TCP connects to this host. For re-encrypt (ForwardCleartext false), TLS SNI and HTTP Host stay on the client authority. For TLS terminate (ForwardCleartext true), Host is rewritten to this host and ForwardPort so HTTP origins see their own bind identity. Declaration public string? ForwardHost { get; set; } Property Value Type Description string | Edit this page View Source ForwardPort Optional fixed upstream port. When null the original request port is used. Declaration public int? ForwardPort { get; set; } Property Value Type Description int? | Edit this page View Source GenericCertificateName The hostname of the generic certificate to negotiate SSL. This will be only used when Sever Name Indication (SNI) is not supported by client, or when it does not indicate any host name. Declaration public abstract string GenericCertificateName { get; set; } Property Value Type Description string" }, "api/Titanium.Web.Proxy.Models.TransparentProxyEndPoint.html": { "href": "api/Titanium.Web.Proxy.Models.TransparentProxyEndPoint.html", @@ -417,13 +452,28 @@ "api/Titanium.Web.Proxy.Network.CertificateManager.html": { "href": "api/Titanium.Web.Proxy.Network.CertificateManager.html", "title": "Class CertificateManager | Titanium Web Proxy", - "summary": "Class CertificateManager A class to manage SSL certificates used by this proxy server. Inheritance object CertificateManager Implements IDisposable Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Network Assembly: Titanium.Web.Proxy.dll Syntax public sealed class CertificateManager : IDisposable Properties | Edit this page View Source CertificateCacheTimeOutMinutes Minutes certificates should be kept in cache when not used. Declaration public int CertificateCacheTimeOutMinutes { get; set; } Property Value Type Description int | Edit this page View Source CertificateEngine Selects the certificate generation engine. Default is BouncyCastle on all platforms. On non-Windows runtimes, DefaultWindows is coerced to BouncyCastle; both BouncyCastle engines are supported. Declaration public CertificateEngine CertificateEngine { get; set; } Property Value Type Description CertificateEngine | Edit this page View Source CertificateGraceDays Number of days by which the certificate's NotBefore timestamp is backdated relative to the current UTC time. A small backdate (the default is 2 days) compensates for minor clock-skew between the proxy machine and clients; it is not necessary to backdate by a year. The total certificate lifetime is CertificateValidDays + CertificateGraceDays. Chrome 70+ and iOS 14+ cap this at 398 days for TLS leaf certificates. Declaration public int CertificateGraceDays { get; set; } Property Value Type Description int | Edit this page View Source CertificateStorage The fake certificate cache storage. The default implementation stores leaf certificates in a crts subdirectory of the per-user Titanium.Web.Proxy directory (%LocalAppData% on Windows, ApplicationData on Linux/macOS). Implement ICertificateCache and assign a concrete class here to customize. Declaration public ICertificateCache CertificateStorage { get; set; } Property Value Type Description ICertificateCache | Edit this page View Source CertificateValidDays Number of days generated HTTPS leaf certificates are valid for, measured forward from the moment of creation. The certificate's NotBefore is set to UtcNow - CertificateGraceDays, so the effective total validity window (NotAfter − NotBefore) equals CertificateValidDays + CertificateGraceDays. Chrome 70+ and iOS 14+ reject certificates whose total validity window exceeds 398 days. To stay within that limit, keep CertificateValidDays + CertificateGraceDays <= 398. The default value of 396, combined with the default grace of 2, equals exactly 398 days total. Declaration public int CertificateValidDays { get; set; } Property Value Type Description int | Edit this page View Source DisableWildCardCertificates When true, issue per-host certificates instead of *.parent.tld wildcards. Default false (wildcards enabled where applicable). Declaration public bool DisableWildCardCertificates { get; set; } Property Value Type Description bool | Edit this page View Source IntermediateCertificates Additional certificates to send to clients as part of the TLS certificate chain. Use this when RootCertificate is an intermediate CA rather than the trust anchor: set this to the ordered list of intermediate certificates between the signing certificate and the client-trusted root so that clients can build a complete verified chain. When RootCertificate is not self-signed it is automatically included in the chain even if this collection is empty; any certificates in this collection are appended after it. Declaration public X509Certificate2Collection? IntermediateCertificates { get; set; } Property Value Type Description X509Certificate2Collection | Edit this page View Source LeafCertificateKeyAlgorithm Key algorithm for generated leaf certificates. Honoured by the BouncyCastle engines; the Windows engine always issues RSA. Defaults to Rsa2048. Switching to EcdsaP256 makes generating a certificate for a not-yet-seen host roughly fifty times cheaper, which is the single largest cost the proxy adds to a first visit. Only clients that accept ECDSA server certificates can be intercepted afterwards. Declaration public CertificateKeyAlgorithm LeafCertificateKeyAlgorithm { get; set; } Property Value Type Description CertificateKeyAlgorithm | Edit this page View Source LeafRsaKeyPairBufferSize How many RSA-2048 leaf private keys to keep ready in a background-refilled buffer so first visits do not pay key-generation cost on the CONNECT that needs the certificate. Defaults to 8. Set to 0 to disable buffering (keys are generated on demand). Only applies when LeafCertificateKeyAlgorithm is Rsa2048. ECDSA P-256 keys are cheap enough that they are always generated inline. The buffer is process-wide and shared by every CertificateManager instance. Declaration public static int LeafRsaKeyPairBufferSize { get; set; } Property Value Type Description int | Edit this page View Source OverwritePfxFile Overwrite Root certificate file. true : replace an existing .pfx file if password is incorrect or if RootCertificate = null. Declaration public bool OverwritePfxFile { get; set; } Property Value Type Description bool | Edit this page View Source PfxFilePath Name(path) of the Root certificate file. Set the name or path of the .pfx file. When empty, the file is named rootCert.pfx. Relative or empty values are resolved under the per-user Titanium.Web.Proxy directory (%LocalAppData% on Windows, ApplicationData on Linux/macOS). Absolute paths are honored as-is. Declaration public string PfxFilePath { get; set; } Property Value Type Description string | Edit this page View Source PfxPassword Password of the Root certificate file. Set a password for the .pfx file Declaration public string PfxPassword { get; set; } Property Value Type Description string | Edit this page View Source RootCertificate The root certificate. Declaration public X509Certificate2? RootCertificate { get; set; } Property Value Type Description X509Certificate2 | Edit this page View Source RootCertificateIssuerName Name of the root certificate issuer. (This is valid only when RootCertificate property is not set.) Declaration public string RootCertificateIssuerName { get; set; } Property Value Type Description string | Edit this page View Source RootCertificateName Subject/CN name used when generating a root certificate. (This is valid only when RootCertificate property is not set.) If no certificate is provided then a default root certificate will be created and used. Persistence uses PfxFilePath / CertificateStorage under the per-user Titanium.Web.Proxy directory (not the process executable directory). Declaration public string RootCertificateName { get; set; } Property Value Type Description string | Edit this page View Source SaveFakeCertificates When true, persist generated leaf certificates via CertificateStorage so subsequent runs can reload them instead of regenerating. Declaration public bool SaveFakeCertificates { get; set; } Property Value Type Description bool | Edit this page View Source StorageFlag Adjust behaviour when certificates are saved to filesystem. Declaration public X509KeyStorageFlags StorageFlag { get; set; } Property Value Type Description X509KeyStorageFlags | Edit this page View Source SuppressInteractiveRootStoreMutations When true, skip Root Add/Remove that trigger Windows CryptUI \"Root Certificate Store\" Yes/No dialogs (which hang headless CI and unattended dotnet test). Personal (My) mutations still run. Also treated as true when CI, GITHUB_ACTIONS, TF_BUILD, or TITANIUM_SKIP_ROOT_STORE_UI=1 is set. Opt back in for intentional interactive Install CA (e.g. local E2E-Slow Chrome) by setting this to false in a process that does not set those env vars. Declaration public static bool SuppressInteractiveRootStoreMutations { get; set; } Property Value Type Description bool Methods | Edit this page View Source ApplyFastColdStartLeafSettings() Fast first-visit MITM for modern TLS clients (browsers, current HttpClient): BouncyCastleFast, ECDSA P-256 leaves, and SaveFakeCertificates enabled. The root CA stays RSA. Library Balanced still defaults to RSA-2048 leaves for widest compatibility. Call this from Inspector, CLI, and desktop MITM hosts where clients are known to accept ECDSA server certificates — RSA leaf generation is the dominant cold-start cost when a page hits many not-yet-seen hosts (often ~1 s per host). Declaration public void ApplyFastColdStartLeafSettings() | Edit this page View Source ClearRootCertificate() Clear the root certificate and cache. Declaration public void ClearRootCertificate() | Edit this page View Source CreateRootCertificate(bool) Attempts to create a RootCertificate. Declaration public bool CreateRootCertificate(bool persistToFile = true) Parameters Type Name Description bool persistToFile if set to true try to load/save the certificate from rootCert.pfx. Returns Type Description bool true if succeeded, else false. | Edit this page View Source CreateServerCertificate(string) Creates a server certificate signed by the root certificate. Declaration public Task CreateServerCertificate(string certificateName) Parameters Type Name Description string certificateName Returns Type Description Task | Edit this page View Source Dispose() Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. Declaration public void Dispose() | Edit this page View Source EnsureRootCertificate() Ensure certificates are setup (creates root if required). Also makes root certificate trusted based on initial setup from proxy constructor for user/machine trust. Declaration public void EnsureRootCertificate() | Edit this page View Source EnsureRootCertificate(bool, bool, bool) Ensure certificates are setup (creates root if required). Also makes root certificate trusted based on provided parameters. Declaration public void EnsureRootCertificate(bool userTrustRootCertificate, bool machineTrustRootCertificate, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description bool userTrustRootCertificate Trust in the current-user stores. Prefer true for interactive MITM; false for fully opt-in trust. bool machineTrustRootCertificate Also trust in local-machine stores (needs elevation). Implies user trust. Prefer false unless installing for a service / all users. bool trustRootCertificateAsAdmin Elevate via UAC when installing (Windows only). Defaults to false. | Edit this page View Source IsRootCertificateMachineTrusted() Determines whether the root certificate is machine trusted. Declaration public bool IsRootCertificateMachineTrusted() Returns Type Description bool | Edit this page View Source IsRootCertificateUserTrusted() Determines whether the root certificate is trusted. Declaration public bool IsRootCertificateUserTrusted() Returns Type Description bool | Edit this page View Source LoadRootCertificate() Loads the root certificate via CertificateStorage (default: per-user Titanium.Web.Proxy directory, file name from PfxFilePath or rootCert.pfx). Declaration public X509Certificate2? LoadRootCertificate() Returns Type Description X509Certificate2 | Edit this page View Source LoadRootCertificate(string, string, bool, X509KeyStorageFlags) Manually load a Root certificate file from give path (.pfx file). Declaration public bool LoadRootCertificate(string pfxFilePath, string password, bool overwritePfXFile = true, X509KeyStorageFlags storageFlag = X509KeyStorageFlags.Exportable) Parameters Type Name Description string pfxFilePath Set the name or path of the .pfx file. When empty, the file is named rootCert.pfx under the per-user Titanium.Web.Proxy directory. Absolute paths are honored as-is. string password Set a password for the .pfx file. bool overwritePfXFile true : replace an existing .pfx file if password is incorrect or if RootCertificate==null. X509KeyStorageFlags storageFlag Returns Type Description bool true if succeeded, else false. | Edit this page View Source RemoveTrustedRootCertificate(bool) Removes the trusted certificates from the current-user Personal and Trusted Root stores, and optionally also from the local-machine Personal and Trusted Root stores. Declaration public void RemoveTrustedRootCertificate(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted When true, also remove from local-machine stores (needs elevation; fails silently otherwise). Pass the same value used when trusting. | Edit this page View Source RemoveTrustedRootCertificateAsAdmin(bool) Removes the trusted certificates from user store, optionally also from machine store Declaration public bool RemoveTrustedRootCertificateAsAdmin(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted Returns Type Description bool Should also remove from machine store? | Edit this page View Source TrustRootCertificate(bool) Trusts the root certificate in the current-user Personal and Trusted Root stores, and optionally also in the local-machine Personal and Trusted Root stores. Declaration public void TrustRootCertificate(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted When true, also install into the local-machine stores. Defaults to false — user-only trust is the recommended default for interactive apps; machine trust needs elevation (or a privileged service account) and otherwise fails silently. | Edit this page View Source TrustRootCertificateAsAdmin(bool) Puts the certificate to the user store, optionally also to the machine store, prompting with UAC when elevation is required. Works only on Windows. Declaration public bool TrustRootCertificateAsAdmin(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted When true, elevate to install into local-machine stores. Defaults to false (user store only). Returns Type Description bool True if success. Implements IDisposable" + "summary": "Class CertificateManager A class to manage SSL certificates used by this proxy server. Inheritance object CertificateManager Implements IDisposable Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Network Assembly: Titanium.Web.Proxy.dll Syntax public sealed class CertificateManager : IDisposable Properties | Edit this page View Source AreInteractiveRootStoreMutationsSuppressed True when Root-store Add/Remove should be skipped to avoid modal CryptUI prompts (static flag, CI env, or TITANIUM_SKIP_ROOT_STORE_UI=1). Declaration public static bool AreInteractiveRootStoreMutationsSuppressed { get; } Property Value Type Description bool | Edit this page View Source CertificateCacheTimeOutMinutes Minutes certificates should be kept in cache when not used. Declaration public int CertificateCacheTimeOutMinutes { get; set; } Property Value Type Description int | Edit this page View Source CertificateEngine Selects the certificate generation engine. Default is BouncyCastle on all platforms. On non-Windows runtimes, DefaultWindows is coerced to BouncyCastle; both BouncyCastle engines are supported. Declaration public CertificateEngine CertificateEngine { get; set; } Property Value Type Description CertificateEngine | Edit this page View Source CertificateGraceDays Number of days by which the certificate's NotBefore timestamp is backdated relative to the current UTC time. A small backdate (the default is 2 days) compensates for minor clock-skew between the proxy machine and clients; it is not necessary to backdate by a year. The total certificate lifetime is CertificateValidDays + CertificateGraceDays. Chrome 70+ and iOS 14+ cap this at 398 days for TLS leaf certificates. Declaration public int CertificateGraceDays { get; set; } Property Value Type Description int | Edit this page View Source CertificateStorage The fake certificate cache storage. The default implementation stores leaf certificates in a crts subdirectory of the per-user Titanium.Web.Proxy directory (%LocalAppData% on Windows, ApplicationData on Linux/macOS). Implement ICertificateCache and assign a concrete class here to customize. Declaration public ICertificateCache CertificateStorage { get; set; } Property Value Type Description ICertificateCache | Edit this page View Source CertificateValidDays Number of days generated HTTPS leaf certificates are valid for, measured forward from the moment of creation. The certificate's NotBefore is set to UtcNow - CertificateGraceDays, so the effective total validity window (NotAfter − NotBefore) equals CertificateValidDays + CertificateGraceDays. Chrome 70+ and iOS 14+ reject certificates whose total validity window exceeds 398 days. To stay within that limit, keep CertificateValidDays + CertificateGraceDays <= 398. The default value of 396, combined with the default grace of 2, equals exactly 398 days total. Declaration public int CertificateValidDays { get; set; } Property Value Type Description int | Edit this page View Source DisableWildCardCertificates When true, issue per-host certificates instead of *.parent.tld wildcards. Default false (wildcards enabled where applicable). Declaration public bool DisableWildCardCertificates { get; set; } Property Value Type Description bool | Edit this page View Source IntermediateCertificates Additional certificates to send to clients as part of the TLS certificate chain. Use this when RootCertificate is an intermediate CA rather than the trust anchor: set this to the ordered list of intermediate certificates between the signing certificate and the client-trusted root so that clients can build a complete verified chain. When RootCertificate is not self-signed it is automatically included in the chain even if this collection is empty; any certificates in this collection are appended after it. Declaration public X509Certificate2Collection? IntermediateCertificates { get; set; } Property Value Type Description X509Certificate2Collection | Edit this page View Source LastOsTrustResult Last OS/browser trust outcome from TrustRootCertificate(bool) / related helpers. Declaration public CertificateOsTrustResult? LastOsTrustResult { get; } Property Value Type Description CertificateOsTrustResult | Edit this page View Source LeafCertificateKeyAlgorithm Key algorithm for generated leaf certificates. Honoured by the BouncyCastle engines; the Windows engine always issues RSA. Defaults to Rsa2048. Switching to EcdsaP256 makes generating a certificate for a not-yet-seen host roughly fifty times cheaper, which is the single largest cost the proxy adds to a first visit. Only clients that accept ECDSA server certificates can be intercepted afterwards. Declaration public CertificateKeyAlgorithm LeafCertificateKeyAlgorithm { get; set; } Property Value Type Description CertificateKeyAlgorithm | Edit this page View Source LeafRsaKeyPairBufferSize How many RSA-2048 leaf private keys to keep ready in a background-refilled buffer so first visits do not pay key-generation cost on the CONNECT that needs the certificate. Defaults to 8. Set to 0 to disable buffering (keys are generated on demand). Only applies when LeafCertificateKeyAlgorithm is Rsa2048. ECDSA P-256 keys are cheap enough that they are always generated inline. The buffer is process-wide and shared by every CertificateManager instance. Declaration public static int LeafRsaKeyPairBufferSize { get; set; } Property Value Type Description int | Edit this page View Source OverwritePfxFile Overwrite Root certificate file. true : replace an existing .pfx file if password is incorrect or if RootCertificate = null. Declaration public bool OverwritePfxFile { get; set; } Property Value Type Description bool | Edit this page View Source PfxFilePath Name(path) of the Root certificate file. Set the name or path of the .pfx file. When empty, the file is named rootCert.pfx. Relative or empty values are resolved under the per-user Titanium.Web.Proxy directory (%LocalAppData% on Windows, ApplicationData on Linux/macOS). Absolute paths are honored as-is. Declaration public string PfxFilePath { get; set; } Property Value Type Description string | Edit this page View Source PfxPassword Password of the Root certificate file. Set a password for the .pfx file Declaration public string PfxPassword { get; set; } Property Value Type Description string | Edit this page View Source RootCertificate The root certificate. Declaration public X509Certificate2? RootCertificate { get; set; } Property Value Type Description X509Certificate2 | Edit this page View Source RootCertificateIssuerName Name of the root certificate issuer. (This is valid only when RootCertificate property is not set.) Declaration public string RootCertificateIssuerName { get; set; } Property Value Type Description string | Edit this page View Source RootCertificateName Subject/CN name used when generating a root certificate. (This is valid only when RootCertificate property is not set.) If no certificate is provided then a default root certificate will be created and used. Persistence uses PfxFilePath / CertificateStorage under the per-user Titanium.Web.Proxy directory (not the process executable directory). Declaration public string RootCertificateName { get; set; } Property Value Type Description string | Edit this page View Source SaveFakeCertificates When true, persist generated leaf certificates via CertificateStorage so subsequent runs can reload them instead of regenerating. Declaration public bool SaveFakeCertificates { get; set; } Property Value Type Description bool | Edit this page View Source StorageFlag Adjust behaviour when certificates are saved to filesystem. Declaration public X509KeyStorageFlags StorageFlag { get; set; } Property Value Type Description X509KeyStorageFlags | Edit this page View Source SuppressInteractiveRootStoreMutations When true, skip Root Add/Remove that trigger Windows CryptUI \"Root Certificate Store\" Yes/No dialogs (which hang headless CI and unattended dotnet test). Personal (My) mutations still run. Also treated as true when CI, GITHUB_ACTIONS, TF_BUILD, or TITANIUM_SKIP_ROOT_STORE_UI=1 is set. Opt back in for intentional interactive Install CA (e.g. local E2E-Slow Chrome) by setting this to false in a process that does not set those env vars. Declaration public static bool SuppressInteractiveRootStoreMutations { get; set; } Property Value Type Description bool Methods | Edit this page View Source ApplyFastColdStartLeafSettings() Fast first-visit MITM for modern TLS clients (browsers, current HttpClient): BouncyCastleFast, ECDSA P-256 leaves, and SaveFakeCertificates enabled. The root CA stays RSA. Library Balanced still defaults to RSA-2048 leaves for widest compatibility. Call this from Inspector, CLI, and desktop MITM hosts where clients are known to accept ECDSA server certificates — RSA leaf generation is the dominant cold-start cost when a page hits many not-yet-seen hosts (often ~1 s per host). Declaration public void ApplyFastColdStartLeafSettings() | Edit this page View Source ClearRootCertificate() Clear the root certificate and cache. Declaration public void ClearRootCertificate() | Edit this page View Source CreateRootCertificate(bool) Attempts to create a RootCertificate. Declaration public bool CreateRootCertificate(bool persistToFile = true) Parameters Type Name Description bool persistToFile if set to true try to load/save the certificate from rootCert.pfx. Returns Type Description bool true if succeeded, else false. | Edit this page View Source CreateServerCertificate(string) Creates a server certificate signed by the root certificate. Declaration public Task CreateServerCertificate(string certificateName) Parameters Type Name Description string certificateName Returns Type Description Task | Edit this page View Source Dispose() Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. Declaration public void Dispose() | Edit this page View Source EnsureRootCertificate() Ensure certificates are setup (creates root if required). Also makes root certificate trusted based on initial setup from proxy constructor for user/machine trust. Declaration public void EnsureRootCertificate() | Edit this page View Source EnsureRootCertificate(bool, bool, bool) Ensure certificates are setup (creates root if required). Also makes root certificate trusted based on provided parameters. Declaration public void EnsureRootCertificate(bool userTrustRootCertificate, bool machineTrustRootCertificate, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description bool userTrustRootCertificate Trust in the current-user stores. Prefer true for interactive MITM; false for fully opt-in trust. bool machineTrustRootCertificate Also trust in local-machine stores (needs elevation). Implies user trust. Prefer false unless installing for a service / all users. bool trustRootCertificateAsAdmin Elevate via UAC when installing (Windows only). Defaults to false. | Edit this page View Source InstallNssCertutilAndRetryUserTrust() Installs NSS certutil (Linux package or macOS Homebrew) after user consent, then retries user SSL trust. Declaration public CertificateOsTrustResult InstallNssCertutilAndRetryUserTrust() Returns Type Description CertificateOsTrustResult | Edit this page View Source IsOsRootStillPresent() True when a Titanium root (current hash or known CN) remains in login or System keychain. Declaration public bool IsOsRootStillPresent() Returns Type Description bool | Edit this page View Source IsRootCertificateMachineTrusted() Determines whether the root certificate is machine trusted. Declaration public bool IsRootCertificateMachineTrusted() Returns Type Description bool | Edit this page View Source IsRootCertificateUserTrusted() Determines whether the root certificate is trusted. Declaration public bool IsRootCertificateUserTrusted() Returns Type Description bool | Edit this page View Source IsRootInLoginKeychain() Best-effort: true when the current root appears in the macOS login keychain. Does not imply SSL Always Trust — use VerifyOsUserSslTrust(). Declaration public bool IsRootInLoginKeychain() Returns Type Description bool | Edit this page View Source LoadRootCertificate() Loads the root certificate via CertificateStorage (default: per-user Titanium.Web.Proxy directory, file name from PfxFilePath or rootCert.pfx). Declaration public X509Certificate2? LoadRootCertificate() Returns Type Description X509Certificate2 | Edit this page View Source LoadRootCertificate(string, string, bool, X509KeyStorageFlags) Manually load a Root certificate file from give path (.pfx file). Declaration public bool LoadRootCertificate(string pfxFilePath, string password, bool overwritePfXFile = true, X509KeyStorageFlags storageFlag = X509KeyStorageFlags.Exportable) Parameters Type Name Description string pfxFilePath Set the name or path of the .pfx file. When empty, the file is named rootCert.pfx under the per-user Titanium.Web.Proxy directory. Absolute paths are honored as-is. string password Set a password for the .pfx file. bool overwritePfXFile true : replace an existing .pfx file if password is incorrect or if RootCertificate==null. X509KeyStorageFlags storageFlag Returns Type Description bool true if succeeded, else false. | Edit this page View Source OpenMacKeychainGuidance() Opens Keychain Access (and a temp .cer) for manual Always Trust on macOS. Declaration public string? OpenMacKeychainGuidance() Returns Type Description string | Edit this page View Source RemoveTrustedRootCertificate(bool) Removes the trusted certificates from the current-user Personal and Trusted Root stores, and optionally also from the local-machine Personal and Trusted Root stores. Declaration public void RemoveTrustedRootCertificate(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted When true, also remove from local-machine stores (needs elevation; fails silently otherwise). Pass the same value used when trusting. | Edit this page View Source RemoveTrustedRootCertificateAsAdmin(bool) Removes the trusted certificates from user store, optionally also from machine store Declaration public bool RemoveTrustedRootCertificateAsAdmin(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted Returns Type Description bool Should also remove from machine store? | Edit this page View Source TrustRootCertificate(bool) Trusts the root certificate in the current-user Personal and Trusted Root stores, and optionally also in the local-machine Personal and Trusted Root stores. Declaration public void TrustRootCertificate(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted When true, also install machine-wide trust (LocalMachine on Windows; System.keychain / system CA store on macOS/Linux, with an admin prompt). Defaults to false — user-only trust is the recommended default. Check LastOsTrustResult and VerifyOsUserSslTrust() after calling. | Edit this page View Source TrustRootCertificateAsAdmin(bool) Puts the certificate to the user store, optionally also to the machine store, prompting with UAC when elevation is required. Works only on Windows. Declaration public bool TrustRootCertificateAsAdmin(bool machineTrusted = false) Parameters Type Name Description bool machineTrusted When true, elevate to install into local-machine stores. Defaults to false (user store only). Returns Type Description bool True if success. | Edit this page View Source VerifyOsUserSslTrust() Re-checks macOS/Linux user SSL trust for the current root. Declaration public bool VerifyOsUserSslTrust() Returns Type Description bool Implements IDisposable" + }, + "api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html": { + "href": "api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html", + "title": "Enum CertificateOsTrustKind | Titanium Web Proxy", + "summary": "Enum CertificateOsTrustKind Outcome kind for OS / browser SSL trust helpers (Keychain, NSS, package install). Namespace: Titanium.Web.Proxy.Network Assembly: Titanium.Web.Proxy.dll Syntax public enum CertificateOsTrustKind Fields Name Description Cancelled CertutilMissing Failed HomebrewMissing MacKeychainFailed MacNeedsManualTrustConfirm NssFailed Succeeded Unsupported" + }, + "api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html": { + "href": "api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html", + "title": "Class CertificateOsTrustResult | Titanium Web Proxy", + "summary": "Class CertificateOsTrustResult Structured result from Unix OS trust or related helper operations. Inheritance object CertificateOsTrustResult Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Network Assembly: Titanium.Web.Proxy.dll Syntax public sealed class CertificateOsTrustResult Constructors | Edit this page View Source CertificateOsTrustResult(CertificateOsTrustKind, string, string?, bool) Declaration public CertificateOsTrustResult(CertificateOsTrustKind kind, string message, string? packageHint = null, bool brewAvailable = false) Parameters Type Name Description CertificateOsTrustKind kind string message string packageHint bool brewAvailable Properties | Edit this page View Source BrewAvailable Declaration public bool BrewAvailable { get; } Property Value Type Description bool | Edit this page View Source Kind Declaration public CertificateOsTrustKind Kind { get; } Property Value Type Description CertificateOsTrustKind | Edit this page View Source Message Declaration public string Message { get; } Property Value Type Description string | Edit this page View Source PackageHint Declaration public string? PackageHint { get; } Property Value Type Description string | Edit this page View Source Succeeded Declaration public bool Succeeded { get; } Property Value Type Description bool Methods | Edit this page View Source Fail(CertificateOsTrustKind, string, string?, bool) Declaration public static CertificateOsTrustResult Fail(CertificateOsTrustKind kind, string message, string? packageHint = null, bool brewAvailable = false) Parameters Type Name Description CertificateOsTrustKind kind string message string packageHint bool brewAvailable Returns Type Description CertificateOsTrustResult | Edit this page View Source Ok(string) Declaration public static CertificateOsTrustResult Ok(string message = \"Trusted\") Parameters Type Name Description string message Returns Type Description CertificateOsTrustResult" }, "api/Titanium.Web.Proxy.Network.DefaultCertificateDiskCache.html": { "href": "api/Titanium.Web.Proxy.Network.DefaultCertificateDiskCache.html", "title": "Class DefaultCertificateDiskCache | Titanium Web Proxy", "summary": "Class DefaultCertificateDiskCache Inheritance object DefaultCertificateDiskCache Implements ICertificateCache Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Network Assembly: Titanium.Web.Proxy.dll Syntax public sealed class DefaultCertificateDiskCache : ICertificateCache Methods | Edit this page View Source Clear() Clears the storage. Declaration public void Clear() | Edit this page View Source GetSharedLeafCertificateDirectory() Shared default leaf-cache directory (%LocalAppData%/Titanium.Web.Proxy/crts on Windows). Used by Inspector to prune legacy leaves after migrating to an absolute root path. Declaration public static string GetSharedLeafCertificateDirectory() Returns Type Description string | Edit this page View Source LoadCertificate(string, X509KeyStorageFlags) Loads a leaf certificate by subject name. Returns null if the certificate is missing or cannot be loaded. Declaration public X509Certificate2? LoadCertificate(string subjectName, X509KeyStorageFlags storageFlags) Parameters Type Name Description string subjectName X509KeyStorageFlags storageFlags Returns Type Description X509Certificate2 | Edit this page View Source LoadRootCertificate(string, string, X509KeyStorageFlags) Loads the root certificate from the storage. Declaration public X509Certificate2? LoadRootCertificate(string pathOrName, string password, X509KeyStorageFlags storageFlags) Parameters Type Name Description string pathOrName string password X509KeyStorageFlags storageFlags Returns Type Description X509Certificate2 | Edit this page View Source PruneToMaxEntries(int?) Deletes the oldest (by last-write time) leaf certificate files in the on-disk cache directory until at most maxEntries remain. Unlike the in-memory certificate cache, nothing else ever removes entries here, so without this bound a long-running proxy that sees traffic to many distinct hostnames accumulates one permanent .pfx file per hostname forever. A null or non-positive maxEntries disables the bound. Declaration public void PruneToMaxEntries(int? maxEntries) Parameters Type Name Description int? maxEntries | Edit this page View Source SaveCertificate(string, X509Certificate2) Stores certificate into the storage. Declaration public void SaveCertificate(string subjectName, X509Certificate2 certificate) Parameters Type Name Description string subjectName X509Certificate2 certificate | Edit this page View Source SaveRootCertificate(string, string, X509Certificate2) Saves the root certificate to the storage. Declaration public void SaveRootCertificate(string pathOrName, string password, X509Certificate2 certificate) Parameters Type Name Description string pathOrName string password X509Certificate2 certificate Implements ICertificateCache" }, + "api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html": { + "href": "api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html", + "title": "Class FirefoxCertificateTrust | Titanium Web Proxy", + "summary": "Class FirefoxCertificateTrust Firefox-specific CA trust: Windows ImportEnterpriseRoots policy / policies.json, profile user.js (and prefs.js when Firefox is not running) so Firefox uses OS roots, plus optional NSS import into the default profile (cert9.db). Does not write into the Firefox.app bundle (that would invalidate the code signature). Inheritance object FirefoxCertificateTrust Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Network Assembly: Titanium.Web.Proxy.dll Syntax public static class FirefoxCertificateTrust Methods | Edit this page View Source IsFirefoxProcessRunning() True when a firefox process is running (best-effort). Declaration public static bool IsFirefoxProcessRunning() Returns Type Description bool | Edit this page View Source IsFirefoxProfilePresent() True when a Firefox profiles.ini (or common profile root) is present. Declaration public static bool IsFirefoxProfilePresent() Returns Type Description bool | Edit this page View Source TrustDefaultProfile(X509Certificate2, string) Imports the CA into the default Firefox profile NSS DB via certutil. Caller should ensure Firefox is not locking the DB. Declaration public static CertificateOsTrustResult TrustDefaultProfile(X509Certificate2 certificate, string friendlyName) Parameters Type Name Description X509Certificate2 certificate string friendlyName Returns Type Description CertificateOsTrustResult | Edit this page View Source TryClearWindowsEnterpriseRoots() Clears the HKCU ImportEnterpriseRoots value and profile user.js pref we may have set. Declaration public static bool TryClearWindowsEnterpriseRoots() Returns Type Description bool | Edit this page View Source TryEnableEnterpriseRootsUserPref() Enables security.enterprise_roots.enabled in the default Firefox profile (user.js; also prefs.js when Firefox is not running) so Firefox trusts OS roots (Windows store / macOS Keychain / Linux system CAs). Declaration public static CertificateOsTrustResult TryEnableEnterpriseRootsUserPref() Returns Type Description CertificateOsTrustResult | Edit this page View Source TryEnableWindowsEnterpriseRoots() Windows: enable OS-root trust for Firefox via HKCU policy when allowed, otherwise set security.enterprise_roots.enabled in the default profile user.js. Also best-effort writes/merges Mozilla policies.json on all OSes. Declaration public static CertificateOsTrustResult TryEnableWindowsEnterpriseRoots() Returns Type Description CertificateOsTrustResult | Edit this page View Source TryRequestFirefoxQuit(TimeSpan?) Asks Firefox to quit gracefully (user already consented). Waits briefly for exit. Does not force-kill; returns false if Firefox is still running after the wait. Declaration public static bool TryRequestFirefoxQuit(TimeSpan? waitForExit = null) Parameters Type Name Description TimeSpan? waitForExit Returns Type Description bool | Edit this page View Source TryResolveDefaultProfileDirectory(out string, out string?) Resolves the default Firefox profile directory from profiles.ini. Declaration public static bool TryResolveDefaultProfileDirectory(out string profileDirectory, out string? error) Parameters Type Name Description string profileDirectory string error Returns Type Description bool | Edit this page View Source UntrustDefaultProfile(string) Best-effort removal of the CA nickname from the default Firefox profile. Declaration public static bool UntrustDefaultProfile(string friendlyName) Parameters Type Name Description string friendlyName Returns Type Description bool" + }, "api/Titanium.Web.Proxy.Network.ICertificateCache.html": { "href": "api/Titanium.Web.Proxy.Network.ICertificateCache.html", "title": "Interface ICertificateCache | Titanium Web Proxy", @@ -442,7 +492,7 @@ "api/Titanium.Web.Proxy.Network.html": { "href": "api/Titanium.Web.Proxy.Network.html", "title": "Namespace Titanium.Web.Proxy.Network | Titanium Web Proxy", - "summary": "Namespace Titanium.Web.Proxy.Network Classes CertificateManager A class to manage SSL certificates used by this proxy server. DefaultCertificateDiskCache Interfaces ICertificateCache Enums CertificateEngine Certificate Engine option. CertificateKeyAlgorithm Key algorithm used for the leaf (\"fake\") certificates the proxy generates per intercepted host." + "summary": "Namespace Titanium.Web.Proxy.Network Classes CertificateManager A class to manage SSL certificates used by this proxy server. CertificateOsTrustResult Structured result from Unix OS trust or related helper operations. DefaultCertificateDiskCache FirefoxCertificateTrust Firefox-specific CA trust: Windows ImportEnterpriseRoots policy / policies.json, profile user.js (and prefs.js when Firefox is not running) so Firefox uses OS roots, plus optional NSS import into the default profile (cert9.db). Does not write into the Firefox.app bundle (that would invalidate the code signature). Interfaces ICertificateCache Enums CertificateEngine Certificate Engine option. CertificateKeyAlgorithm Key algorithm used for the leaf (\"fake\") certificates the proxy generates per intercepted host. CertificateOsTrustKind Outcome kind for OS / browser SSL trust helpers (Keychain, NSS, package install)." }, "api/Titanium.Web.Proxy.Options.PolicyFamily.html": { "href": "api/Titanium.Web.Proxy.Options.PolicyFamily.html", @@ -497,7 +547,7 @@ "api/Titanium.Web.Proxy.ProxyServer.html": { "href": "api/Titanium.Web.Proxy.ProxyServer.html", "title": "Class ProxyServer | Titanium Web Proxy", - "summary": "Class ProxyServer Translates an HTTP/1.1 client connection onto an h2-only origin (Http2 with AllowHttpProtocolTranslation enabled - see ResolveHttp2ForClientAsync(SessionEventArgsBase, bool, string, int, string, int?, UpstreamHttpProtocol, bool, bool, CancellationToken, bool)), leasing one h2 stream per HTTP/1.1 request from a shared Titanium.Web.Proxy.Http2.Http2OriginConnection via Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool rather than opening a new TCP/TLS connection for every request. Inheritance object ProxyServer Implements IDisposable Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy Assembly: Titanium.Web.Proxy.dll Syntax public class ProxyServer : IDisposable Remarks This re-implements the HTTP/1.1 client read loop (request line, headers, BeforeRequest, authorization, header preparation, CancelRequest/replaced-response handling) rather than reusing the private HandleHttpSessionRequest/HandleHttpSessionResponse methods, because those methods send/receive over TcpServerConnection.Stream using the raw HTTP/1.1 wire format, which an h2 origin connection cannot speak. This mirrors the precedent set by the h2-to-HTTP/1.1 bridge (Http2ToHttp11BridgeHandler), which similarly bypasses the wire-format-specific machinery for the leg that does not match it. Origin connections are multiplexed across independent HTTP/1.1 clients through Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool (fan-in share). Response bodies are delivered via Titanium.Web.Proxy.Http2.Http2OriginConnection streaming writers where available. Constructors | Edit this page View Source ProxyServer(bool, bool, bool) Initializes a new instance of ProxyServer class with provided parameters. Declaration public ProxyServer(bool userTrustRootCertificate = true, bool machineTrustRootCertificate = false, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description bool userTrustRootCertificate When true (the default), EnsureRootCertificate() installs the MITM root into the current-user Personal and Trusted Root stores. Prefer user-only trust for interactive apps; pass false when trust must be fully opt-in. bool machineTrustRootCertificate When true, also trust in the local-machine stores (needs elevation). Defaults to false — machine trust is opt-in for services/admin installs, not for normal desktop use. bool trustRootCertificateAsAdmin When true, attempt elevated trust via UAC (Windows only). Defaults to false. | Edit this page View Source ProxyServer(string?, string?, bool, bool, bool) Initializes a new instance of ProxyServer class with provided parameters. Declaration public ProxyServer(string? rootCertificateName, string? rootCertificateIssuerName, bool userTrustRootCertificate = true, bool machineTrustRootCertificate = false, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description string rootCertificateName Name of the root certificate. string rootCertificateIssuerName Name of the root certificate issuer. bool userTrustRootCertificate When true (the default), EnsureRootCertificate() installs the MITM root into the current-user Personal and Trusted Root stores. Prefer user-only trust for interactive apps; pass false when trust must be fully opt-in. bool machineTrustRootCertificate When true, also trust in the local-machine stores (needs elevation). Defaults to false — machine trust is opt-in for services/admin installs, not for normal desktop use. bool trustRootCertificateAsAdmin When true, attempt elevated trust via UAC (Windows only). Defaults to false. Properties | Edit this page View Source AdmittedClientConnectionCount Number of client connections currently admitted (accepted and past the admission gate, not yet finished being handled), across all TCP-based endpoints. Unlike ClientConnectionCount, this drops to zero as soon as the handler returns, without the trailing TIME_WAIT delay. Declaration public int AdmittedClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source BlockPrivateNetworkDestinations Outbound destination policy hook: when true, every resolved destination IP address is checked against loopback, private (RFC 1918/4193), link-local (which subsumes the 169.254.169.254 cloud metadata endpoint), and other non-globally-routable ranges before connecting, and the connection attempt is rejected with an OutboundDestinationBlockedException if it matches. Off by default: blocking private destinations would break this library's most common configurations, including upstream-proxy chaining to localhost and interception of local development servers. Only enable this when the proxy accepts requests from untrusted clients (an SSRF-relevant deployment), where those same destinations become an attacker-reachable pivot into the host's private network instead of an operator's own intentional configuration. An explicitly configured upstream proxy address (UpStreamHttpProxy, UpStreamHttpsProxy, or a per-session external proxy) is always exempt - that address is operator intent, not attacker-controlled. Checked against the resolved address actually used to connect (no re-resolution afterward, which would make the check a TOCTOU no-op against DNS rebinding). Not currently enforced for a SOCKS upstream with ProxyDnsRequests enabled, since the proxy never resolves the origin itself in that mode and has no address of its own to validate. Declaration public bool BlockPrivateNetworkDestinations { get; set; } Property Value Type Description bool | Edit this page View Source BufferPool The buffer pool used throughout this proxy instance. Set custom implementations by implementing this interface. By default this uses DefaultBufferPool implementation available in StreamExtended library package. Buffer size should be at least 10 bytes. Declaration public IBufferPool BufferPool { get; set; } Property Value Type Description IBufferPool | Edit this page View Source CertificateManager Manages certificates used by this proxy. Declaration public CertificateManager CertificateManager { get; } Property Value Type Description CertificateManager | Edit this page View Source CheckCertificateRevocation Should we check for certificate revocation during SSL authentication to servers Note: If enabled can reduce performance. Defaults to false. Declaration public X509RevocationMode CheckCertificateRevocation { get; set; } Property Value Type Description X509RevocationMode | Edit this page View Source ClientConnectionCount Total number of active TCP client connections. Does not include inbound HTTP/3 (QUIC) clients; see Http3ClientConnectionCount. Declaration public int ClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source ClientHeaderTimeoutSeconds Seconds to wait for a client to finish sending the request line and headers, from the moment this proxy starts reading a new request on the connection. Enforced with a linked CancellationTokenSource around the request-line and header read, not Socket.ReceiveTimeout: that property only bounds a single blocking Receive call, not the asynchronous reads this proxy actually issues, so without this deadline a client that opens a connection and trickles bytes arbitrarily slowly (or stops sending entirely) after the first byte ties up a read loop indefinitely. Default is 0 (disabled), matching every other deadline in this class - no per-session override exists because there is no SessionEventArgs for this request yet at the point this deadline applies. Declaration public int ClientHeaderTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source CompatibilityMode100Continue When true, the proxy immediately responds with a synthetic 100 Continue to any client request carrying Expect: 100-continue, before forwarding the headers to the origin and without waiting for the origin to respond. This breaks the strict handshake (client → proxy 100 → client body → origin body) but prevents the deadlock that occurs with strict clients when Enable100ContinueBehaviour is false (the default). Has no effect when Enable100ContinueBehaviour is true. Default: false. Declaration public bool CompatibilityMode100Continue { get; set; } Property Value Type Description bool | Edit this page View Source ConnectTimeOutSeconds Seconds server connection are to wait for connection to be established. Default value is 20 seconds. Declaration public int ConnectTimeOutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ConnectionTimeOutSeconds Seconds client/server connection are to be kept alive when waiting for read/write to complete. This will also determine the pool eviction time when connection pool is enabled. Default value is 60 seconds. Declaration public int ConnectionTimeOutSeconds { get; set; } Property Value Type Description int | Edit this page View Source CustomUpStreamProxyFailureFunc A callback to provide a chance for an upstream proxy failure to be handled by a new upstream proxy. User should return the ExternalProxy object with valid credentials or null. Declaration public Func>? CustomUpStreamProxyFailureFunc { get; set; } Property Value Type Description Func> | Edit this page View Source DnsServerEndPoint DNS server endpoint used by Titanium.Web.Proxy.Http3.Dns.UdpSvcbDnsResolver for HTTPS/SVCB queries. Defaults to the first usable OS-configured plain-UDP DNS server discovered via NetworkInterface. This is a best-effort default and does not honor Windows NRPT, DoH, or VPN split-DNS policy. When no OS-configured DNS server can be discovered, the property reports 0.0.0.0:0 and proactive SVCB discovery is skipped (never falls back to a public third-party resolver). Assign an explicit endpoint to override discovery. Declaration [Experimental(\"TWP001\")] public IPEndPoint DnsServerEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source Enable100ContinueBehaviour Does this proxy uses the HTTP protocol 100 continue behaviour strictly? Broken 100 continue implementations on server/client may cause problems if enabled. Defaults to false. Declaration public bool Enable100ContinueBehaviour { get; set; } Property Value Type Description bool | Edit this page View Source EnableConnectionPool Should we enable the server connection pool. Defaults to true. When connection pooling is enabled, instead of creating a new TCP connection to the server for each client TCP connection, we check if an idle server connection is available in our cached pool. If a compatible connection (same destination, scheme, upstream proxy, credentials and negotiated protocol) created from an earlier request is available, we reuse it. Only connections that are safe to reuse under the HTTP protocol are pooled: the response body must be fully received and the connection must be persistent (HTTP/1.1 keep-alive, or an HTTP/1.0 connection that explicitly opted in via \"Connection: keep-alive\"). Connections whose response asked to close, that failed, or that carry connection-oriented authentication state (WinAuth NTLM/Negotiate) or a per-session client certificate are never returned to the shared pool. The ConnectionTimeOutSeconds parameter determines the eviction time for inactive server connections. This reduces TCP (and TLS) connection establishment cost, both in wall clock time and CPU cycles. Set to false to force a fresh server connection for every client connection. Declaration public bool EnableConnectionPool { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttp2 Enable disable HTTP/2 support. Client-facing HTTP/2 is negotiated via TLS ALPN, or as prior-knowledge cleartext h2c on a transparent reverse endpoint (DecryptSsl: false). No Upgrade: h2c. Origin-facing HTTP/2 uses TLS ALPN h2 by default; with ForwardCleartext and Http2, the origin speaks cleartext HTTP/2 prior-knowledge (outbound h2c). A client/server that does not support HTTP/2 transparently falls back to HTTP/1.1 when policy allows. Request/response header and body modification in BeforeRequest/BeforeResponse, chunked trailers, interim (1xx) responses, and the synthetic-response APIs (Ok/Respond/Redirect/GenericResponse/ RespondStreaming) are all supported over HTTP/2, the same as over HTTP/1.x. Not supported: HTTP/2 server push (the wire frames are transcoded but there is no public API to originate a push) and Upgrade: h2c. Explicit-proxy inbound h2c is not implemented. See the protocol support matrix on the wiki for exact, up-to-date HTTP/1.x/HTTP/2 feature coverage. Declaration public bool EnableHttp2 { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttp3 Enable HTTP/3 (QUIC) support. When true: Any TransparentQuicProxyEndPoint is started as a UDP-only QUIC listener for transparent/NAT HTTP/3 interception. Any TransparentProxyEndPoint with EnableHttp3 also listens for HTTP/3 on the same IP:port (TCP H1/H2 + UDP H3) and injects client-facing Alt-Svc. With Auto (default), a cached Alt-Svc / HTTPS/SVCB capability only arms background QUIC warm-up. Outbound HTTP/3 is used once that origin is warm; until then the request stays on HTTP/2 or HTTP/1.1. Forced Http3 skips warm-up gating and fails closed with no TCP fallback. Requires MsQuic native library and a supported operating-system version (IsSupported). Setting to true with no inbound HTTP/3 endpoint is fine when an explicit/SOCKS/transparent TCP endpoint is present (origin-side QUIC only). A warning is emitted only when EnableHttp3 is set with no client-facing endpoints at all. Default: false (opt-in). Experimental: HTTP/3 support has not yet completed the full interop/soak/fuzz gate process. Suppress TWP001 to opt in; the attribute is removed when the feature graduates to stable. Declaration [Experimental(\"TWP001\")] public bool EnableHttp3 { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttpInterception Forces the full interception path (SessionEventArgs, BeforeRequest, etc.) even when no event handlers are subscribed. Set this when consuming SessionEventArgs for timing or metrics without subscribing to any event. Default: false. Declaration public bool EnableHttpInterception { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttpsSvcbDnsDiscovery When true, the proxy queues a background HTTPS/SVCB RR (DNS type 65) lookup after an Auto-mode capability-cache miss. A positive result (ALPN h3 found) warms Titanium.Web.Proxy.Http3.Http3OriginCapabilityCache for subsequent connections; the CONNECT / request path itself never awaits DNS. Negative results are cached for 1 minute; transient failures use a short backoff. Defaults to true whenever EnableHttp3 is true. Set explicitly to false to disable discovery even when HTTP/3 is enabled — for example, when the configured DNS server is untrusted or unreachable. First-connection HTTP/3 adoption then comes from Alt-Svc. Declaration [Experimental(\"TWP001\")] public bool EnableHttpsSvcbDnsDiscovery { get; set; } Property Value Type Description bool | Edit this page View Source EnableIpv6UnreachableSoftSkip When true (default), after one IPv6 connect failure with NetworkUnreachable (or equivalent), temporarily omit IPv6 addresses from the Happy Eyeballs race for 30 seconds. Reduces first-chance SocketException noise on dual-stack hosts with a broken IPv6 path. Disable if operators require strict IPv6 preference even when the path is unreachable. Declaration public bool EnableIpv6UnreachableSoftSkip { get; set; } Property Value Type Description bool | Edit this page View Source EnableQpackDynamicTable When true, enables RFC 9204 QPACK dynamic table encoding and decoding for inbound HTTP/3 connections. Each connection gets its own Titanium.Web.Proxy.Http3.Qpack.QpackContext with two independent 4096-byte tables (one inbound, one outbound). Defaults to false (static-table-only); existing deployments are unaffected. Declaration [Experimental(\"TWP001\")] public bool EnableQpackDynamicTable { get; set; } Property Value Type Description bool | Edit this page View Source EnableRequestTimingCapture Enables structured request/connection timing capture. When false (the default) no timing objects are allocated and no UtcNow calls are made for timing purposes anywhere in the proxy, so there is zero overhead on the hot path. When enabled, every SessionEventArgsBase exposes a populated Timing (per-request phases: client header read, connection wait, request send, time-to-first-byte, response delivery, total), every upstream connection exposes a populated UpstreamConnectionTiming (reachable from a session via UpstreamConnectionTiming, describing DNS, TCP connect, optional upstream-proxy CONNECT, and TLS handshake durations), and a decrypted TunnelConnectSessionEventArgs exposes the client-facing TLS handshake duration via ClientTlsTiming. Can be toggled at any time; it only affects sessions/connections created after the change, never mutating timing objects already handed out. Defaults to false. Declaration public bool EnableRequestTimingCapture { get; set; } Property Value Type Description bool | Edit this page View Source EnableRfc8441 When true, the proxy enables RFC 8441 WebSocket-over-HTTP/2: Accepts extended CONNECT (:protocol = websocket) from h2 clients and advertises SETTINGS_ENABLE_CONNECT_PROTOCOL=1 to them. Per origin: if the origin is HTTP/2 and advertises RFC 8441 support, DATA frames are relayed directly; if the origin is HTTP/2 and does not, the stream is reset with REFUSED_STREAM; if the origin is HTTP/1.1, the h2→h1 WebSocket upgrade bridge is used. On the HTTP/1.1-client-to-h2-origin translation bridge, translates Upgrade: websocket into extended CONNECT when the origin advertises the setting; otherwise falls back to a dedicated HTTP/1.1 origin connection for that WebSocket. When this property is false, that bridge still returns synthetic 501 Not Implemented for WebSocket upgrades (historical default). Default: false (must opt-in). Declaration public bool EnableRfc8441 { get; set; } Property Value Type Description bool | Edit this page View Source EnableTcpKeepAlive Enable TCP KeepAlive on client and server sockets so NAT/firewall mappings for long-lived CONNECT tunnels are refreshed. Default: true. Declaration public bool EnableTcpKeepAlive { get; set; } Property Value Type Description bool | Edit this page View Source EnableTcpServerConnectionPrefetch Should we enable tcp server connection prefetching? When enabled, as soon as we receive a client connection we concurrently initiate corresponding server connection process using CONNECT hostname or SNI hostname on a separate task so that after parsing client request we will have the server connection immediately ready or in the process of getting ready. If a server connection is available in cache then this prefetch task will immediately return with the available connection from cache. Defaults to true. Declaration public bool EnableTcpServerConnectionPrefetch { get; set; } Property Value Type Description bool | Edit this page View Source EnableWinAuth Enable disable Windows Authentication (NTLM/Kerberos). By default SSPI uses the process identity. To authenticate as another user, set WinAuthCredentialsProvider (issue #461). Defaults to false. Declaration public bool EnableWinAuth { get; set; } Property Value Type Description bool | Edit this page View Source EndpointAdmissionRejectionCount Total number of client connections rejected by any endpoint's MaxConcurrentClients since this instance was created. Declaration public long EndpointAdmissionRejectionCount { get; } Property Value Type Description long | Edit this page View Source ForwardToUpstreamGateway Gets or sets a value indicating whether requests will be chained to upstream gateway. Defaults to false. Declaration public bool ForwardToUpstreamGateway { get; set; } Property Value Type Description bool | Edit this page View Source GetCustomUpStreamProxyFunc A callback to provide authentication credentials for up stream proxy this proxy is using for HTTP(S) requests. User should return the ExternalProxy object with valid credentials. Declaration public Func>? GetCustomUpStreamProxyFunc { get; set; } Property Value Type Description Func> | Edit this page View Source GlobalAdmissionRejectionCount Total number of client connections rejected by MaxConcurrentClientConnections since this instance was created. Declaration public long GlobalAdmissionRejectionCount { get; } Property Value Type Description long | Edit this page View Source Http3ClientConnectionCount Total number of active inbound HTTP/3 (QUIC) client connections. Declaration public int Http3ClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source Http3ServerConnectionCount Total number of active upstream HTTP/3 (QUIC) server connections. These are also included in ServerConnectionCount. Declaration public int Http3ServerConnectionCount { get; } Property Value Type Description int | Edit this page View Source IdleReadTimeoutSeconds Seconds of idle time allowed while reading from the origin (stalled header/body waits). Applied via CancelAfter on the active read operation. Default is 0 (disabled). Per-session override: IdleReadTimeout. Declaration public int IdleReadTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source IdleWriteTimeoutSeconds Seconds of idle time allowed while writing to the origin (stalled header/body waits). Applied via CancelAfter on the active write operation. Default is 0 (disabled). Per-session override: IdleWriteTimeout. Declaration public int IdleWriteTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ListenerBackLog TCP listener accept backlog. Default: 1024 for burst connection handling. Declaration public int ListenerBackLog { get; set; } Property Value Type Description int | Edit this page View Source Logger The live, shared logger used throughout this proxy instance. Reflects the most recent call to ApplyLoggingConfiguration(). Declaration public ILogger Logger { get; } Property Value Type Description ILogger | Edit this page View Source Logging Configuration for this proxy instance's built-in diagnostic logging - the replacement for the removed ExceptionFunc callback. Every exception the proxy catches (even when handled internally and never surfaced to user code) is reported through this logger at an appropriate severity; see ProxyLoggingOptions for the console/file sinks, enable/disable switch, and minimum level. Mutate the returned instance (or assign a new one) at any point; each assignment/mutation you want to take effect must be followed by ApplyLoggingConfiguration() (which Start(bool) also calls automatically, so the configuration active at the moment the proxy starts running is picked up for the run even if you never call it yourself). Calling it again later - including while the proxy is already running - immediately swaps in the new configuration; this is safe because logging never blocks or otherwise affects proxy traffic. Declaration public ProxyLoggingOptions Logging { get; set; } Property Value Type Description ProxyLoggingOptions | Edit this page View Source MaxBufferedBodyBytes Maximum bytes the proxy will buffer for a single request or response body when body buffering is required (body-read hooks, authentication retry, etc.). Bodies larger than this limit are rejected with 413 (upstream request) or connection teardown (upstream response). Set to 0 to disable the limit (not recommended). Default: 4,194,304 (4 MiB). Declaration public int MaxBufferedBodyBytes { get; set; } Property Value Type Description int | Edit this page View Source MaxCachedConnections Maximum number of concurrent connections per remote host in cache. Only meaningful when EnableConnectionPool is true; to disable pooling, set EnableConnectionPool to false rather than setting this to 0 - the pool eviction loop treats a value below 1 as \"evict without limit while holding the pool-wide lock\", which spins indefinitely once the cache for that host is empty and would stall every other connection acquire/release in the process. Rejected outright at assignment so that state cannot be reached. Default value is 128. Declaration public int MaxCachedConnections { get; set; } Property Value Type Description int Exceptions Type Condition ArgumentOutOfRangeException The assigned value is less than 1. | Edit this page View Source MaxConcurrentClientConnections Maximum number of client connections admitted across all TCP-based endpoints at once. null (the default) disables the global admission gate, preserving today's unbounded behavior. When set, a connection beyond this limit is rejected and disposed immediately after accept, before a handler task is even started. Enforced independently of ClientConnectionCount: see Titanium.Web.Proxy.ProxyServer.admittedClientConnectionCount for why. See also MaxConcurrentClients for a per-endpoint cap layered on top of this global one. Declaration public int? MaxConcurrentClientConnections { get; set; } Property Value Type Description int? | Edit this page View Source MaxConcurrentHttp11HttpsOriginCreates Caps concurrent new HTTPS origin TCP/TLS opens on the H2→H1 bridge only (MITM / re-encrypt). Pool hits (warm keep-alive) are uncapped. Cleartext H1 origins are not gated. Default is Clamp(ProcessorCount, 4, 32). Set before the first H2→H1 HTTPS origin open (typically before Start(bool)); changing the value after the create gate has been used has no effect on the live semaphore. Declaration public int MaxConcurrentHttp11HttpsOriginCreates { get; set; } Property Value Type Description int Exceptions Type Condition ArgumentOutOfRangeException The assigned value is less than 1. | Edit this page View Source MaxDecodedHeaderListBytes Maximum decoded HTTP/2 header list size in bytes, using RFC 7541 accounting (name.Length + value.Length + 32 per field). Requests or responses with a decoded header list exceeding this limit will be refused with RST_STREAM(ENHANCE_YOUR_CALM) (code 0xb). Set to 0 to disable the limit (not recommended). Default: 65,536 (64 KiB). Advertised via SETTINGS_MAX_HEADER_LIST_SIZE. Declaration public int MaxDecodedHeaderListBytes { get; set; } Property Value Type Description int | Edit this page View Source MaxWebSocketFramePayloadBytes Maximum WebSocket frame payload size in bytes that the proxy will accept during frame-level interception (i.e. when BeforeWebSocketFrame has at least one subscriber). Frames whose decoded payload exceeds this limit cause the WebSocket connection to be closed with Close code 1009 (Message Too Big). Raw-relay sessions (no BeforeWebSocketFrame subscriber) bypass this check entirely and pass all frames through unvalidated. Default: 16,777,216 (16 MiB). Declaration public int MaxWebSocketFramePayloadBytes { get; set; } Property Value Type Description int | Edit this page View Source NetworkFailureRetryAttempts Number of times to retry upon network failures when connection pool is enabled. Declaration public int NetworkFailureRetryAttempts { get; set; } Property Value Type Description int | Edit this page View Source NoDelay Gets or sets a Boolean value that specifies whether server and client stream Sockets are using the Nagle algorithm. Defaults to true, no nagle algorithm is used. Declaration public bool NoDelay { get; set; } Property Value Type Description bool | Edit this page View Source OriginHttpVersionPolicy Controls which HTTP version is declared to the origin server on the request line, independently of the version the client declared to the proxy. Defaults to PreserveClientVersion, which matches the proxy's historical pass-through behavior exactly. Set to NormalizeToHttp11 to let HTTP/1.0 clients share pooled, persistent origin connections the same way HTTP/1.1 clients already do. This only changes the wire version written to the origin request line - it never changes the client-facing Http.Request.HttpVersion that event handlers observe, nor the version/persistence used to write the response back to the client. Declaration public OriginHttpVersionPolicy OriginHttpVersionPolicy { get; set; } Property Value Type Description OriginHttpVersionPolicy | Edit this page View Source PolicyModes Which resource-bound PolicyFamily is enforced, observed, or disabled, per the plan's rollout section. Read live by each family's enforcement call site - not baked into a per-request snapshot at connection accept time - so assigning a new value here (a whole-object replacement, never a mutation of the previous instance) takes effect for the next check any in-flight or new request makes, without restarting the proxy. This is the \"runtime switch to drop to Observe without redeploying\" the plan requires; see WithAllObservedExceptDisabled() for the one-call way to do that. Defaults to AllEnforce, matching Balanced. Assigning Profile also replaces this value with that profile's bundle; assign PolicyModes afterward to deviate from the selected profile's modes without changing anything else the profile set. Declaration public ProxyPolicyModes PolicyModes { get; set; } Property Value Type Description ProxyPolicyModes | Edit this page View Source Profile The last profile applied via this property's setter, defaulting to Balanced - the profile every field on this instance already starts at, so a fresh new ProxyServer() reports Balanced without needing its setter to run once at construction time. Assigning this property applies its entire ProxyProfileSettings bundle - ResourceLimits, PolicyModes, SupportedSslProtocols, BlockPrivateNetworkDestinations, MaxConcurrentClientConnections and the deadline-seconds properties - as a single atomic assignment, so a reader can never observe a half-applied profile. Assigning any of those properties individually afterward overrides just that one, without reverting the rest of the profile's bundle. Logged once per Start(bool) call, by name only - never with hosts, URLs or secrets, per the plan's rollout section. Declaration public ProxyProfile Profile { get; set; } Property Value Type Description ProxyProfile | Edit this page View Source ProxyAuthenticationRealm Realm used during Proxy Basic Authentication. Declaration public string ProxyAuthenticationRealm { get; set; } Property Value Type Description string | Edit this page View Source ProxyAuthenticationSchemes A collection of scheme types, e.g. basic, NTLM, Kerberos, Negotiate, to return if scheme authentication is required. Works in relation with ProxySchemeAuthenticateFunc. Declaration public IEnumerable ProxyAuthenticationSchemes { get; set; } Property Value Type Description IEnumerable | Edit this page View Source ProxyBasicAuthenticateFunc A callback to authenticate proxy clients via basic authentication. Parameters are username and password as provided by client. Should return true for successful authentication. Declaration public Func>? ProxyBasicAuthenticateFunc { get; set; } Property Value Type Description Func> | Edit this page View Source ProxyEndPoints A list of IpAddress and port this proxy is listening to. Declaration public List ProxyEndPoints { get; set; } Property Value Type Description List | Edit this page View Source ProxyRunning Is the proxy currently running? Declaration public bool ProxyRunning { get; } Property Value Type Description bool | Edit this page View Source ProxySchemeAuthenticateFunc A pluggable callback to authenticate clients by scheme instead of requiring basic authentication through ProxyBasicAuthenticateFunc. Parameters are current working session, schemeType, and token as provided by a calling client. Should return success for successful authentication, continuation if the package requests, or failure. Declaration public Func>? ProxySchemeAuthenticateFunc { get; set; } Property Value Type Description Func> | Edit this page View Source RequestTimeoutSeconds Total seconds allowed for a single request/response exchange after BeforeRequest returns (connect, send, wait for headers, and body copy). Default is 0 (disabled). Per-session override: RequestTimeout. Declaration public int RequestTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ResourceLimits The shared, immutable resource-bound snapshot (concurrent-stream cap, CONTINUATION frame-count/wall-clock bounds, peer-initiated incomplete-stream-reset budget, and the other limits described in ProxyResourceLimits) consulted by the HTTP/2 relay so a single proxy-owned value governs both what is enforced and what is advertised to each peer, rather than admitting purely against whatever the origin advertised. Assign a new ProxyResourceLimits (constructed via Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?)) to override the Default snapshot. There is no artificial upper clamp: high-CPU/RAM hosts may pass larger maxCachedConnectionsPerHost, maxConcurrentStreamsPerConnection, etc. as needed. The live TCP pool depth knob MaxCachedConnections remains independently settable and should usually be kept in sync with MaxCachedConnectionsPerHost. Declaration public ProxyResourceLimits ResourceLimits { get; set; } Property Value Type Description ProxyResourceLimits | Edit this page View Source ResponseHeaderTimeoutSeconds Seconds to wait for the origin to send the response status line and headers after the request has been sent. Enforced with a linked CancellationTokenSource (not Socket receive timeout alone). When the deadline elapses a ProxyTimeoutException with ResponseHeader is raised (and may be converted to HTTP 504 before any response bytes have been committed to the client). Default is 0 (disabled). WebSocket upgrades, Server-Sent Events, raw tunnels, and sessions that already wrote a response status to the client are exempt; those waits use IdleReadTimeoutSeconds when configured. Per-session override: ResponseHeaderTimeout. Declaration public int ResponseHeaderTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ReuseSocket When true (default), SO_REUSEADDR is requested where Titanium.Web.Proxy.Helpers.RunTime.IsSocketReuseAvailable() reports support (always on Windows; on non-Windows, .NET Core 3+ / compatible runtimes). Declaration public bool ReuseSocket { get; set; } Property Value Type Description bool | Edit this page View Source ReverseProxy Optional reverse-proxy route/cluster configuration. When null (default), Core keeps 6.x ForwardHost behavior with zero added cost on the hot path. Declaration public ReverseProxyOptions? ReverseProxy { get; set; } Property Value Type Description ReverseProxyOptions | Edit this page View Source ServerConnectionCount Total number of active server connections (TCP plus upstream QUIC). For HTTP/3-only upstreams see Http3ServerConnectionCount. Declaration public int ServerConnectionCount { get; } Property Value Type Description int | Edit this page View Source ShouldInterceptHttp Optional per-request/stream predicate consulted only when the global interception gate is active. Return true to use the full SessionEventArgs path; return false to use the fast-forward path. null (the default) intercepts every request — preserving today's behavior. Declaration public Func? ShouldInterceptHttp { get; set; } Property Value Type Description Func | Edit this page View Source SupportedServerSslProtocols Ssl versions offered on outbound HTTPS connections to origins (and upstream proxies). Default None means “use SupportedSslProtocols” (typically TLS 1.2 and 1.3). Set an explicit mask to restrict or expand outbound-only independently of inbound client TLS. Older docs described None as “same as the proxy client.” That coupling is incorrect across protocol translations (e.g. inbound QUIC is always TLS 1.3 while outbound TCP SslStream on macOS SecureTransport cannot offer TLS 1.3). Declaration public SslProtocols SupportedServerSslProtocols { get; set; } Property Value Type Description SslProtocols | Edit this page View Source SupportedSslProtocols List of supported Ssl versions. Defaults to TLS 1.2/1.3 only as of 5.0 - a breaking change from 4.x, which also enabled SSL 3.0/TLS 1.0/1.1. Those legacy, broken-by-design protocols require an explicit opt-in by assigning this property directly (e.g. SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13) if a legacy client/server genuinely requires them. Declaration public SslProtocols SupportedSslProtocols { get; set; } Property Value Type Description SslProtocols | Edit this page View Source TcpTimeWaitSeconds SO_LINGER timeout in seconds applied to client and upstream sockets via LingerOption (enabled with this timeout). This is not the kernel TCP TIME_WAIT duration — TIME_WAIT is controlled by the OS. A positive value means Close may block up to that many seconds flushing send buffers; use 0 for an abortive close (RST). Default is 0 so high-churn proxies avoid TIME_WAIT accumulation; the 1-second connection disposal delay already prefers peer-first close. Declaration public int TcpTimeWaitSeconds { get; set; } Property Value Type Description int | Edit this page View Source ThreadPoolWorkerThread Customize the minimum ThreadPool size (increase it on a server). Defaults to max(ProcessorCount * 2, 16) so short loopback/proxy workloads are not starved while the pool is still ramping workers. Declaration public int ThreadPoolWorkerThread { get; set; } Property Value Type Description int | Edit this page View Source UpStreamEndPoint Local adapter/NIC endpoint where proxy makes request via. Defaults via any IP addresses of this machine. When the resolved destination address family does not match this endpoint, it is ignored so dual-stack destinations can still connect (see UpStreamEndPointIPv4 / UpStreamEndPointIPv6). Declaration public IPEndPoint? UpStreamEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv4 Local bind endpoint used when the resolved upstream destination is IPv4. Takes precedence over UpStreamEndPoint for IPv4 destinations. Declaration public IPEndPoint? UpStreamEndPointIPv4 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv6 Local bind endpoint used when the resolved upstream destination is IPv6. Takes precedence over UpStreamEndPoint for IPv6 destinations. Declaration public IPEndPoint? UpStreamEndPointIPv6 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamHttpProxy External proxy used for Http requests. Declaration public IExternalProxy? UpStreamHttpProxy { get; set; } Property Value Type Description IExternalProxy | Edit this page View Source UpStreamHttpsProxy External proxy used for Https requests. Declaration public IExternalProxy? UpStreamHttpsProxy { get; set; } Property Value Type Description IExternalProxy | Edit this page View Source UpstreamProxyConfigurationScript If set, the upstream proxy will be detected by a script that will be loaded from the provided Uri Declaration public Uri? UpstreamProxyConfigurationScript { get; set; } Property Value Type Description Uri | Edit this page View Source ViaHeaderPseudonym Pseudonym used in Via header fields appended to forwarded requests and responses (RFC 9110 §7.6.3). Defaults to \"titanium-web-proxy\". Set to an empty string to disable Via header injection entirely. Loop detection uses this value: a request arriving with this pseudonym already present in Via is refused with 508 Loop Detected. Declaration public string ViaHeaderPseudonym { get; set; } Property Value Type Description string | Edit this page View Source WinAuthCredentialsProvider Optional per-session credential provider for server 401 WinAuth (NTLM/Negotiate/Kerberos). Return null to use the current process identity (legacy behavior). Do not put plaintext passwords on SessionEventArgs — use this callback instead. Windows SSPI only; ignored on non-Windows platforms. Declaration public Func>? WinAuthCredentialsProvider { get; set; } Property Value Type Description Func> Methods | Edit this page View Source AddEndPoint(ProxyEndPoint) Add a proxy end point. Declaration public void AddEndPoint(ProxyEndPoint endPoint) Parameters Type Name Description ProxyEndPoint endPoint The proxy endpoint. | Edit this page View Source ApplyLoggingConfiguration() Rebuilds the active logger/logger factory from the current Logging configuration, disposing any previously owned built-in providers. Called automatically from the constructor (with the default configuration) and from Start(bool). Call this explicitly any time after changing Logging and you want the change to take effect immediately - whether the proxy is stopped (e.g. before using CertificateManager directly) or already running. Declaration public void ApplyLoggingConfiguration() | Edit this page View Source DisableAllSystemProxies() Clear all proxy settings for current machine. Declaration public void DisableAllSystemProxies() | Edit this page View Source DisableSystemHttpProxy() Clear HTTP proxy settings of current machine. Declaration public void DisableSystemHttpProxy() | Edit this page View Source DisableSystemHttpsProxy() Clear HTTPS proxy settings of current machine. Declaration public void DisableSystemHttpsProxy() | Edit this page View Source DisableSystemProxy(ProxyProtocolType) Clear the specified proxy setting for current machine. Declaration public void DisableSystemProxy(ProxyProtocolType protocolType) Parameters Type Name Description ProxyProtocolType protocolType | Edit this page View Source Dispose() Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. Declaration public void Dispose() | Edit this page View Source Dispose(bool) Declaration [SuppressMessage(\"ApiDesign\", \"RS0016:Add public types and members to the declared API\", Justification = \"Protected Dispose(bool) is required by the standard IDisposable pattern but is not public API.\")] protected virtual void Dispose(bool disposing) Parameters Type Name Description bool disposing | Edit this page View Source RemoveEndPoint(ProxyEndPoint) Remove a proxy end point. Will throw error if the end point doesn't exist. Declaration public void RemoveEndPoint(ProxyEndPoint endPoint) Parameters Type Name Description ProxyEndPoint endPoint The existing endpoint to remove. | Edit this page View Source RestoreOriginalProxySettings() Restores the original proxy settings. Declaration public void RestoreOriginalProxySettings() | Edit this page View Source SetAsSystemHttpProxy(ExplicitProxyEndPoint) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. | Edit this page View Source SetAsSystemHttpProxy(ExplicitProxyEndPoint, SystemProxySettings) Set the given explicit end point as the default HTTP proxy server for current machine. Declaration public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. SystemProxySettings settings The Windows system proxy settings. | Edit this page View Source SetAsSystemHttpsProxy(ExplicitProxyEndPoint) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. | Edit this page View Source SetAsSystemHttpsProxy(ExplicitProxyEndPoint, SystemProxySettings) Set the given explicit end point as the default HTTPS proxy server for current machine. Declaration public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. SystemProxySettings settings The Windows system proxy settings. | Edit this page View Source SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. ProxyProtocolType protocolType The proxy protocol type. | Edit this page View Source SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, SystemProxySettings? settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. ProxyProtocolType protocolType The proxy protocol type. SystemProxySettings settings The Windows system proxy settings, or null to preserve the current bypass list. | Edit this page View Source SetHttp3Enabled(bool) Enables or disables EnableHttp3. Enabling still requires MsQuic (IsSupported); disabling is always applied. Safe to call while the proxy is running — new origin connections pick up the change. Existing sessions keep the protocol they already negotiated. Declaration public bool SetHttp3Enabled(bool enabled) Parameters Type Name Description bool enabled Returns Type Description bool true when HTTP/3 is enabled after the call. | Edit this page View Source Start(bool) Start this proxy server instance. Transactional: if any endpoint fails to start, every listener this call already started is stopped, the system-upstream-proxy resolver (if this call created one) is disposed, and ProxyRunning is left false before the exception propagates. A caller that catches the exception is left with an instance in exactly the same state as before calling Start(bool), not a partially-bound proxy with some endpoints silently listening. Declaration public void Start(bool changeSystemProxySettings = true) Parameters Type Name Description bool changeSystemProxySettings Whether or not clear any system proxy settings which is pointing to our own endpoint (causing a cycle). E.g due to ungracious proxy shutdown before. | Edit this page View Source Stop() Stop this proxy server instance. Endpoints remain registered so Start(bool) can re-listen on the same ports. In-flight sessions are cancelled; pooled upstream connections are cleared. The connection factory itself stays usable for a subsequent Start (it is only disposed with the proxy). Declaration public void Stop() | Edit this page View Source StopAsync(TimeSpan?) Asynchronously stop this proxy server, cancel in-flight sessions, and wait briefly for client connection count to drain before clearing the upstream pool. Declaration public Task StopAsync(TimeSpan? drainTimeout = null) Parameters Type Name Description TimeSpan? drainTimeout Maximum time to wait for active client handlers to exit after cancellation. Defaults to 5 seconds. Returns Type Description Task | Edit this page View Source TryEnableHttp3IfSupported() Turns on EnableHttp3 when MsQuic is available (IsSupported). Hosts (CLI, Inspector, examples) should call this instead of setting EnableHttp3 blindly. Returns true when HTTP/3 was enabled. Declaration public bool TryEnableHttp3IfSupported() Returns Type Description bool Events | Edit this page View Source AfterResponse Intercept after response event from server. Declaration public event AsyncEventHandler? AfterResponse Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeRequest Intercept request event to server. Declaration public event AsyncEventHandler? BeforeRequest Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeResponse Intercept response event from server. Declaration public event AsyncEventHandler? BeforeResponse Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeUpStreamConnectRequest Intercept connect request sent to upstream proxy. Declaration public event AsyncEventHandler? BeforeUpStreamConnectRequest Event Type Type Description AsyncEventHandler | Edit this page View Source ClientCertificateSelectionCallback Event to override client certificate selection during mutual SSL authentication. Declaration public event AsyncEventHandler? ClientCertificateSelectionCallback Event Type Type Description AsyncEventHandler | Edit this page View Source ClientConnectionCountChanged Event occurs when client connection count changed. Declaration public event EventHandler? ClientConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source Http3ClientConnectionCountChanged Event occurs when inbound HTTP/3 client connection count changed. Declaration public event EventHandler? Http3ClientConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source Http3ServerConnectionCountChanged Event occurs when upstream HTTP/3 server connection count changed. Declaration public event EventHandler? Http3ServerConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source OnClientConnectionCreate Customize TcpClient used for client connection upon create. Declaration public event AsyncEventHandler? OnClientConnectionCreate Event Type Type Description AsyncEventHandler | Edit this page View Source OnRequestBodyWrite Intercept request body send event to server. Subscribe to inspect or modify the request body chunk-by-chunk as it streams to the server, without buffering the whole body. Do not combine with SessionEventArgs.GetRequestBody (which buffers). Declaration public event AsyncEventHandler? OnRequestBodyWrite Event Type Type Description AsyncEventHandler | Edit this page View Source OnResponseBodyWrite Intercept response body send event to client. Subscribe to inspect or modify the response body chunk-by-chunk as it streams to the client, without buffering the whole body. Do not combine with SessionEventArgs.GetResponseBody (which buffers). Declaration public event AsyncEventHandler? OnResponseBodyWrite Event Type Type Description AsyncEventHandler | Edit this page View Source OnServerConnectionCreate Customize TcpClient used for server connection upon create. Declaration public event AsyncEventHandler? OnServerConnectionCreate Event Type Type Description AsyncEventHandler | Edit this page View Source ServerCertificateValidationCallback Event to override the default verification logic of remote SSL certificate received during authentication. Declaration public event AsyncEventHandler? ServerCertificateValidationCallback Event Type Type Description AsyncEventHandler | Edit this page View Source ServerConnectionCountChanged Event occurs when server connection count changed. Declaration public event EventHandler? ServerConnectionCountChanged Event Type Type Description EventHandler Implements IDisposable" + "summary": "Class ProxyServer Translates an HTTP/1.1 client connection onto an h2-only origin (Http2 with AllowHttpProtocolTranslation enabled - see ResolveHttp2ForClientAsync(SessionEventArgsBase, bool, string, int, string, int?, UpstreamHttpProtocol, bool, bool, CancellationToken, bool)), leasing one h2 stream per HTTP/1.1 request from a shared Titanium.Web.Proxy.Http2.Http2OriginConnection via Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool rather than opening a new TCP/TLS connection for every request. Inheritance object ProxyServer Implements IDisposable Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy Assembly: Titanium.Web.Proxy.dll Syntax public class ProxyServer : IDisposable Remarks This re-implements the HTTP/1.1 client read loop (request line, headers, BeforeRequest, authorization, header preparation, CancelRequest/replaced-response handling) rather than reusing the private HandleHttpSessionRequest/HandleHttpSessionResponse methods, because those methods send/receive over TcpServerConnection.Stream using the raw HTTP/1.1 wire format, which an h2 origin connection cannot speak. This mirrors the precedent set by the h2-to-HTTP/1.1 bridge (Http2ToHttp11BridgeHandler), which similarly bypasses the wire-format-specific machinery for the leg that does not match it. Origin connections are multiplexed across independent HTTP/1.1 clients through Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool (fan-in share). Response bodies are delivered via Titanium.Web.Proxy.Http2.Http2OriginConnection streaming writers where available. Constructors | Edit this page View Source ProxyServer(bool, bool, bool) Initializes a new instance of ProxyServer class with provided parameters. Declaration public ProxyServer(bool userTrustRootCertificate = true, bool machineTrustRootCertificate = false, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description bool userTrustRootCertificate When true (the default), EnsureRootCertificate() installs the MITM root into the current-user Personal and Trusted Root stores. Prefer user-only trust for interactive apps; pass false when trust must be fully opt-in. bool machineTrustRootCertificate When true, also trust in the local-machine stores (needs elevation). Defaults to false — machine trust is opt-in for services/admin installs, not for normal desktop use. bool trustRootCertificateAsAdmin When true, attempt elevated trust via UAC (Windows only). Defaults to false. | Edit this page View Source ProxyServer(string?, string?, bool, bool, bool) Initializes a new instance of ProxyServer class with provided parameters. Declaration public ProxyServer(string? rootCertificateName, string? rootCertificateIssuerName, bool userTrustRootCertificate = true, bool machineTrustRootCertificate = false, bool trustRootCertificateAsAdmin = false) Parameters Type Name Description string rootCertificateName Name of the root certificate. string rootCertificateIssuerName Name of the root certificate issuer. bool userTrustRootCertificate When true (the default), EnsureRootCertificate() installs the MITM root into the current-user Personal and Trusted Root stores. Prefer user-only trust for interactive apps; pass false when trust must be fully opt-in. bool machineTrustRootCertificate When true, also trust in the local-machine stores (needs elevation). Defaults to false — machine trust is opt-in for services/admin installs, not for normal desktop use. bool trustRootCertificateAsAdmin When true, attempt elevated trust via UAC (Windows only). Defaults to false. Properties | Edit this page View Source AdmittedClientConnectionCount Number of client connections currently admitted (accepted and past the admission gate, not yet finished being handled), across all TCP-based endpoints. Unlike ClientConnectionCount, this drops to zero as soon as the handler returns, without the trailing TIME_WAIT delay. Declaration public int AdmittedClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source BlockPrivateNetworkDestinations Outbound destination policy hook: when true, every resolved destination IP address is checked against loopback, private (RFC 1918/4193), link-local (which subsumes the 169.254.169.254 cloud metadata endpoint), and other non-globally-routable ranges before connecting, and the connection attempt is rejected with an OutboundDestinationBlockedException if it matches. Off by default: blocking private destinations would break this library's most common configurations, including upstream-proxy chaining to localhost and interception of local development servers. Only enable this when the proxy accepts requests from untrusted clients (an SSRF-relevant deployment), where those same destinations become an attacker-reachable pivot into the host's private network instead of an operator's own intentional configuration. An explicitly configured upstream proxy address (UpStreamHttpProxy, UpStreamHttpsProxy, or a per-session external proxy) is always exempt - that address is operator intent, not attacker-controlled. Checked against the resolved address actually used to connect (no re-resolution afterward, which would make the check a TOCTOU no-op against DNS rebinding). Not currently enforced for a SOCKS upstream with ProxyDnsRequests enabled, since the proxy never resolves the origin itself in that mode and has no address of its own to validate. Declaration public bool BlockPrivateNetworkDestinations { get; set; } Property Value Type Description bool | Edit this page View Source BufferPool The buffer pool used throughout this proxy instance. Set custom implementations by implementing this interface. By default this uses DefaultBufferPool implementation available in StreamExtended library package. Buffer size should be at least 10 bytes. Declaration public IBufferPool BufferPool { get; set; } Property Value Type Description IBufferPool | Edit this page View Source CertificateManager Manages certificates used by this proxy. Declaration public CertificateManager CertificateManager { get; } Property Value Type Description CertificateManager | Edit this page View Source CheckCertificateRevocation Should we check for certificate revocation during SSL authentication to servers Note: If enabled can reduce performance. Defaults to false. Declaration public X509RevocationMode CheckCertificateRevocation { get; set; } Property Value Type Description X509RevocationMode | Edit this page View Source ClientConnectionCount Total number of active TCP client connections. Does not include inbound HTTP/3 (QUIC) clients; see Http3ClientConnectionCount. Declaration public int ClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source ClientHeaderTimeoutSeconds Seconds to wait for a client to finish sending the request line and headers, from the moment this proxy starts reading a new request on the connection. Enforced with a linked CancellationTokenSource around the request-line and header read, not Socket.ReceiveTimeout: that property only bounds a single blocking Receive call, not the asynchronous reads this proxy actually issues, so without this deadline a client that opens a connection and trickles bytes arbitrarily slowly (or stops sending entirely) after the first byte ties up a read loop indefinitely. Default is 0 (disabled), matching every other deadline in this class - no per-session override exists because there is no SessionEventArgs for this request yet at the point this deadline applies. Declaration public int ClientHeaderTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source CompatibilityMode100Continue When true, the proxy immediately responds with a synthetic 100 Continue to any client request carrying Expect: 100-continue, before forwarding the headers to the origin and without waiting for the origin to respond. This breaks the strict handshake (client → proxy 100 → client body → origin body) but prevents the deadlock that occurs with strict clients when Enable100ContinueBehaviour is false (the default). Has no effect when Enable100ContinueBehaviour is true. Default: false. Declaration public bool CompatibilityMode100Continue { get; set; } Property Value Type Description bool | Edit this page View Source ConnectTimeOutSeconds Seconds server connection are to wait for connection to be established. Default value is 20 seconds. Declaration public int ConnectTimeOutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ConnectionTimeOutSeconds Seconds client/server connection are to be kept alive when waiting for read/write to complete. This will also determine the pool eviction time when connection pool is enabled. Default value is 60 seconds. Declaration public int ConnectionTimeOutSeconds { get; set; } Property Value Type Description int | Edit this page View Source CustomUpStreamProxyFailureFunc A callback to provide a chance for an upstream proxy failure to be handled by a new upstream proxy. User should return the ExternalProxy object with valid credentials or null. Declaration public Func>? CustomUpStreamProxyFailureFunc { get; set; } Property Value Type Description Func> | Edit this page View Source DnsServerEndPoint DNS server endpoint used by Titanium.Web.Proxy.Http3.Dns.UdpSvcbDnsResolver for HTTPS/SVCB queries. Defaults to the first usable OS-configured plain-UDP DNS server discovered via NetworkInterface. This is a best-effort default and does not honor Windows NRPT, DoH, or VPN split-DNS policy. When no OS-configured DNS server can be discovered, the property reports 0.0.0.0:0 and proactive SVCB discovery is skipped (never falls back to a public third-party resolver). Assign an explicit endpoint to override discovery. Declaration [Experimental(\"TWP001\")] public IPEndPoint DnsServerEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source Enable100ContinueBehaviour Does this proxy uses the HTTP protocol 100 continue behaviour strictly? Broken 100 continue implementations on server/client may cause problems if enabled. Defaults to false. Declaration public bool Enable100ContinueBehaviour { get; set; } Property Value Type Description bool | Edit this page View Source EnableConnectionPool Should we enable the server connection pool. Defaults to true. When connection pooling is enabled, instead of creating a new TCP connection to the server for each client TCP connection, we check if an idle server connection is available in our cached pool. If a compatible connection (same destination, scheme, upstream proxy, credentials and negotiated protocol) created from an earlier request is available, we reuse it. Only connections that are safe to reuse under the HTTP protocol are pooled: the response body must be fully received and the connection must be persistent (HTTP/1.1 keep-alive, or an HTTP/1.0 connection that explicitly opted in via \"Connection: keep-alive\"). Connections whose response asked to close, that failed, or that carry connection-oriented authentication state (WinAuth NTLM/Negotiate) or a per-session client certificate are never returned to the shared pool. The ConnectionTimeOutSeconds parameter determines the eviction time for inactive server connections. This reduces TCP (and TLS) connection establishment cost, both in wall clock time and CPU cycles. Set to false to force a fresh server connection for every client connection. Declaration public bool EnableConnectionPool { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttp2 Enable disable HTTP/2 support. Client-facing HTTP/2 is negotiated via TLS ALPN, or as prior-knowledge cleartext h2c on a transparent reverse endpoint (DecryptSsl: false). No Upgrade: h2c. Origin-facing HTTP/2 uses TLS ALPN h2 by default; with ForwardCleartext and Http2, the origin speaks cleartext HTTP/2 prior-knowledge (outbound h2c). A client/server that does not support HTTP/2 transparently falls back to HTTP/1.1 when policy allows. Request/response header and body modification in BeforeRequest/BeforeResponse, chunked trailers, interim (1xx) responses, and the synthetic-response APIs (Ok/Respond/Redirect/GenericResponse/ RespondStreaming) are all supported over HTTP/2, the same as over HTTP/1.x. Not supported: HTTP/2 server push (the wire frames are transcoded but there is no public API to originate a push) and Upgrade: h2c. Explicit-proxy inbound h2c is not implemented. See the protocol support matrix on the wiki for exact, up-to-date HTTP/1.x/HTTP/2 feature coverage. Declaration public bool EnableHttp2 { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttp3 Enable HTTP/3 (QUIC) support. When true: Any TransparentQuicProxyEndPoint is started as a UDP-only QUIC listener for transparent/NAT HTTP/3 interception. Any TransparentProxyEndPoint with EnableHttp3 also listens for HTTP/3 on the same IP:port (TCP H1/H2 + UDP H3) and injects client-facing Alt-Svc. With Auto (default), a cached Alt-Svc / HTTPS/SVCB capability only arms background QUIC warm-up. Outbound HTTP/3 is used once that origin is warm; until then the request stays on HTTP/2 or HTTP/1.1. Forced Http3 skips warm-up gating and fails closed with no TCP fallback. Requires MsQuic native library and a supported operating-system version (IsSupported). Setting to true with no inbound HTTP/3 endpoint is fine when an explicit/SOCKS/transparent TCP endpoint is present (origin-side QUIC only). A warning is emitted only when EnableHttp3 is set with no client-facing endpoints at all. Default: false (opt-in). Experimental: HTTP/3 support has not yet completed the full interop/soak/fuzz gate process. Suppress TWP001 to opt in; the attribute is removed when the feature graduates to stable. Declaration [Experimental(\"TWP001\")] public bool EnableHttp3 { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttpInterception Forces the full interception path (SessionEventArgs, BeforeRequest, etc.) even when no event handlers are subscribed. Set this when consuming SessionEventArgs for timing or metrics without subscribing to any event. Default: false. Declaration public bool EnableHttpInterception { get; set; } Property Value Type Description bool | Edit this page View Source EnableHttpsSvcbDnsDiscovery When true, the proxy queues a background HTTPS/SVCB RR (DNS type 65) lookup after an Auto-mode capability-cache miss. A positive result (ALPN h3 found) warms Titanium.Web.Proxy.Http3.Http3OriginCapabilityCache for subsequent connections; the CONNECT / request path itself never awaits DNS. Negative results are cached for 1 minute; transient failures use a short backoff. Defaults to true whenever EnableHttp3 is true. Set explicitly to false to disable discovery even when HTTP/3 is enabled — for example, when the configured DNS server is untrusted or unreachable. First-connection HTTP/3 adoption then comes from Alt-Svc. Declaration [Experimental(\"TWP001\")] public bool EnableHttpsSvcbDnsDiscovery { get; set; } Property Value Type Description bool | Edit this page View Source EnableIpv6UnreachableSoftSkip When true (default), after one IPv6 connect failure with NetworkUnreachable (or equivalent), temporarily omit IPv6 addresses from the Happy Eyeballs race for 30 seconds. Reduces first-chance SocketException noise on dual-stack hosts with a broken IPv6 path. Disable if operators require strict IPv6 preference even when the path is unreachable. Declaration public bool EnableIpv6UnreachableSoftSkip { get; set; } Property Value Type Description bool | Edit this page View Source EnableQpackDynamicTable When true, enables RFC 9204 QPACK dynamic table encoding and decoding for inbound HTTP/3 connections. Each connection gets its own Titanium.Web.Proxy.Http3.Qpack.QpackContext with two independent 4096-byte tables (one inbound, one outbound). Defaults to false (static-table-only); existing deployments are unaffected. Declaration [Experimental(\"TWP001\")] public bool EnableQpackDynamicTable { get; set; } Property Value Type Description bool | Edit this page View Source EnableRequestTimingCapture Enables structured request/connection timing capture. When false (the default) no timing objects are allocated and no UtcNow calls are made for timing purposes anywhere in the proxy, so there is zero overhead on the hot path. When enabled, every SessionEventArgsBase exposes a populated Timing (per-request phases: client header read, connection wait, request send, time-to-first-byte, response delivery, total), every upstream connection exposes a populated UpstreamConnectionTiming (reachable from a session via UpstreamConnectionTiming, describing DNS, TCP connect, optional upstream-proxy CONNECT, and TLS handshake durations), and a decrypted TunnelConnectSessionEventArgs exposes the client-facing TLS handshake duration via ClientTlsTiming. Can be toggled at any time; it only affects sessions/connections created after the change, never mutating timing objects already handed out. Defaults to false. Declaration public bool EnableRequestTimingCapture { get; set; } Property Value Type Description bool | Edit this page View Source EnableRfc8441 When true, the proxy enables RFC 8441 WebSocket-over-HTTP/2: Accepts extended CONNECT (:protocol = websocket) from h2 clients and advertises SETTINGS_ENABLE_CONNECT_PROTOCOL=1 to them. Per origin: if the origin is HTTP/2 and advertises RFC 8441 support, DATA frames are relayed directly; if the origin is HTTP/2 and does not, the stream is reset with REFUSED_STREAM; if the origin is HTTP/1.1, the h2→h1 WebSocket upgrade bridge is used. On the HTTP/1.1-client-to-h2-origin translation bridge, translates Upgrade: websocket into extended CONNECT when the origin advertises the setting; otherwise falls back to a dedicated HTTP/1.1 origin connection for that WebSocket. When this property is false, that bridge still returns synthetic 501 Not Implemented for WebSocket upgrades (historical default). Default: false (must opt-in). Declaration public bool EnableRfc8441 { get; set; } Property Value Type Description bool | Edit this page View Source EnableTcpKeepAlive Enable TCP KeepAlive on client and server sockets so NAT/firewall mappings for long-lived CONNECT tunnels are refreshed. Default: true. Declaration public bool EnableTcpKeepAlive { get; set; } Property Value Type Description bool | Edit this page View Source EnableTcpServerConnectionPrefetch Should we enable tcp server connection prefetching? When enabled, as soon as we receive a client connection we concurrently initiate corresponding server connection process using CONNECT hostname or SNI hostname on a separate task so that after parsing client request we will have the server connection immediately ready or in the process of getting ready. If a server connection is available in cache then this prefetch task will immediately return with the available connection from cache. Defaults to true. Declaration public bool EnableTcpServerConnectionPrefetch { get; set; } Property Value Type Description bool | Edit this page View Source EnableWinAuth Enable disable Windows Authentication (NTLM/Kerberos). By default SSPI uses the process identity. To authenticate as another user, set WinAuthCredentialsProvider (issue #461). Defaults to false. Declaration public bool EnableWinAuth { get; set; } Property Value Type Description bool | Edit this page View Source EndpointAdmissionRejectionCount Total number of client connections rejected by any endpoint's MaxConcurrentClients since this instance was created. Declaration public long EndpointAdmissionRejectionCount { get; } Property Value Type Description long | Edit this page View Source ForwardToUpstreamGateway Gets or sets a value indicating whether requests will be chained to upstream gateway. Defaults to false. Declaration public bool ForwardToUpstreamGateway { get; set; } Property Value Type Description bool | Edit this page View Source GetCustomUpStreamProxyFunc A callback to provide authentication credentials for up stream proxy this proxy is using for HTTP(S) requests. User should return the ExternalProxy object with valid credentials. Declaration public Func>? GetCustomUpStreamProxyFunc { get; set; } Property Value Type Description Func> | Edit this page View Source GlobalAdmissionRejectionCount Total number of client connections rejected by MaxConcurrentClientConnections since this instance was created. Declaration public long GlobalAdmissionRejectionCount { get; } Property Value Type Description long | Edit this page View Source Http3ClientConnectionCount Total number of active inbound HTTP/3 (QUIC) client connections. Declaration public int Http3ClientConnectionCount { get; } Property Value Type Description int | Edit this page View Source Http3ServerConnectionCount Total number of active upstream HTTP/3 (QUIC) server connections. These are also included in ServerConnectionCount. Declaration public int Http3ServerConnectionCount { get; } Property Value Type Description int | Edit this page View Source IdleReadTimeoutSeconds Seconds of idle time allowed while reading from the origin (stalled header/body waits). Applied via CancelAfter on the active read operation. Default is 0 (disabled). Per-session override: IdleReadTimeout. Declaration public int IdleReadTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source IdleWriteTimeoutSeconds Seconds of idle time allowed while writing to the origin (stalled header/body waits). Applied via CancelAfter on the active write operation. Default is 0 (disabled). Per-session override: IdleWriteTimeout. Declaration public int IdleWriteTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source IgnoreServerCertificateErrors When true, origin TLS certificates that fail OS chain validation are still accepted (MITM of loopback/self-signed/private CAs). Inspector's \"Ignore server certificate errors\" maps here. Default false. A subscribed ServerCertificateValidationCallback still wins. Declaration public bool IgnoreServerCertificateErrors { get; set; } Property Value Type Description bool | Edit this page View Source ListenerBackLog TCP listener accept backlog. Default: 1024 for burst connection handling. Declaration public int ListenerBackLog { get; set; } Property Value Type Description int | Edit this page View Source Logger The live, shared logger used throughout this proxy instance. Reflects the most recent call to ApplyLoggingConfiguration(). Declaration public ILogger Logger { get; } Property Value Type Description ILogger | Edit this page View Source Logging Configuration for this proxy instance's built-in diagnostic logging - the replacement for the removed ExceptionFunc callback. Every exception the proxy catches (even when handled internally and never surfaced to user code) is reported through this logger at an appropriate severity; see ProxyLoggingOptions for the console/file sinks, enable/disable switch, and minimum level. Mutate the returned instance (or assign a new one) at any point; each assignment/mutation you want to take effect must be followed by ApplyLoggingConfiguration() (which Start(bool) also calls automatically, so the configuration active at the moment the proxy starts running is picked up for the run even if you never call it yourself). Calling it again later - including while the proxy is already running - immediately swaps in the new configuration; this is safe because logging never blocks or otherwise affects proxy traffic. Declaration public ProxyLoggingOptions Logging { get; set; } Property Value Type Description ProxyLoggingOptions | Edit this page View Source MaxBufferedBodyBytes Maximum bytes the proxy will buffer for a single request or response body when body buffering is required (body-read hooks, authentication retry, etc.). Bodies larger than this limit are rejected with 413 (upstream request) or connection teardown (upstream response). Set to 0 to disable the limit (not recommended). Default: 4,194,304 (4 MiB). Declaration public int MaxBufferedBodyBytes { get; set; } Property Value Type Description int | Edit this page View Source MaxCachedConnections Maximum number of concurrent connections per remote host in cache. Only meaningful when EnableConnectionPool is true; to disable pooling, set EnableConnectionPool to false rather than setting this to 0 - the pool eviction loop treats a value below 1 as \"evict without limit while holding the pool-wide lock\", which spins indefinitely once the cache for that host is empty and would stall every other connection acquire/release in the process. Rejected outright at assignment so that state cannot be reached. Default value is 128. Declaration public int MaxCachedConnections { get; set; } Property Value Type Description int Exceptions Type Condition ArgumentOutOfRangeException The assigned value is less than 1. | Edit this page View Source MaxConcurrentClientConnections Maximum number of client connections admitted across all TCP-based endpoints at once. null (the default) disables the global admission gate, preserving today's unbounded behavior. When set, a connection beyond this limit is rejected and disposed immediately after accept, before a handler task is even started. Enforced independently of ClientConnectionCount: see Titanium.Web.Proxy.ProxyServer.admittedClientConnectionCount for why. See also MaxConcurrentClients for a per-endpoint cap layered on top of this global one. Declaration public int? MaxConcurrentClientConnections { get; set; } Property Value Type Description int? | Edit this page View Source MaxConcurrentHttp11HttpsOriginCreates Caps concurrent new HTTPS origin TCP/TLS opens on the H2→H1 bridge only (MITM / re-encrypt). Pool hits (warm keep-alive) are uncapped. Cleartext H1 origins are not gated. Default is Clamp(ProcessorCount, 4, 32). Set before the first H2→H1 HTTPS origin open (typically before Start(bool)); changing the value after the create gate has been used has no effect on the live semaphore. Declaration public int MaxConcurrentHttp11HttpsOriginCreates { get; set; } Property Value Type Description int Exceptions Type Condition ArgumentOutOfRangeException The assigned value is less than 1. | Edit this page View Source MaxDecodedHeaderListBytes Maximum decoded HTTP/2 header list size in bytes, using RFC 7541 accounting (name.Length + value.Length + 32 per field). Requests or responses with a decoded header list exceeding this limit will be refused with RST_STREAM(ENHANCE_YOUR_CALM) (code 0xb). Set to 0 to disable the limit (not recommended). Default: 65,536 (64 KiB). Advertised via SETTINGS_MAX_HEADER_LIST_SIZE. Declaration public int MaxDecodedHeaderListBytes { get; set; } Property Value Type Description int | Edit this page View Source MaxWebSocketFramePayloadBytes Maximum WebSocket frame payload size in bytes that the proxy will accept during frame-level interception (i.e. when BeforeWebSocketFrame has at least one subscriber). Frames whose decoded payload exceeds this limit cause the WebSocket connection to be closed with Close code 1009 (Message Too Big). Raw-relay sessions (no BeforeWebSocketFrame subscriber) bypass this check entirely and pass all frames through unvalidated. Default: 16,777,216 (16 MiB). Declaration public int MaxWebSocketFramePayloadBytes { get; set; } Property Value Type Description int | Edit this page View Source NetworkFailureRetryAttempts Number of times to retry upon network failures when connection pool is enabled. Declaration public int NetworkFailureRetryAttempts { get; set; } Property Value Type Description int | Edit this page View Source NoDelay Gets or sets a Boolean value that specifies whether server and client stream Sockets are using the Nagle algorithm. Defaults to true, no nagle algorithm is used. Declaration public bool NoDelay { get; set; } Property Value Type Description bool | Edit this page View Source OriginHttpVersionPolicy Controls which HTTP version is declared to the origin server on the request line, independently of the version the client declared to the proxy. Defaults to PreserveClientVersion, which matches the proxy's historical pass-through behavior exactly. Set to NormalizeToHttp11 to let HTTP/1.0 clients share pooled, persistent origin connections the same way HTTP/1.1 clients already do. This only changes the wire version written to the origin request line - it never changes the client-facing Http.Request.HttpVersion that event handlers observe, nor the version/persistence used to write the response back to the client. Declaration public OriginHttpVersionPolicy OriginHttpVersionPolicy { get; set; } Property Value Type Description OriginHttpVersionPolicy | Edit this page View Source PolicyModes Which resource-bound PolicyFamily is enforced, observed, or disabled, per the plan's rollout section. Read live by each family's enforcement call site - not baked into a per-request snapshot at connection accept time - so assigning a new value here (a whole-object replacement, never a mutation of the previous instance) takes effect for the next check any in-flight or new request makes, without restarting the proxy. This is the \"runtime switch to drop to Observe without redeploying\" the plan requires; see WithAllObservedExceptDisabled() for the one-call way to do that. Defaults to AllEnforce, matching Balanced. Assigning Profile also replaces this value with that profile's bundle; assign PolicyModes afterward to deviate from the selected profile's modes without changing anything else the profile set. Declaration public ProxyPolicyModes PolicyModes { get; set; } Property Value Type Description ProxyPolicyModes | Edit this page View Source Profile The last profile applied via this property's setter, defaulting to Balanced - the profile every field on this instance already starts at, so a fresh new ProxyServer() reports Balanced without needing its setter to run once at construction time. Assigning this property applies its entire ProxyProfileSettings bundle - ResourceLimits, PolicyModes, SupportedSslProtocols, BlockPrivateNetworkDestinations, MaxConcurrentClientConnections and the deadline-seconds properties - as a single atomic assignment, so a reader can never observe a half-applied profile. Assigning any of those properties individually afterward overrides just that one, without reverting the rest of the profile's bundle. Logged once per Start(bool) call, by name only - never with hosts, URLs or secrets, per the plan's rollout section. Declaration public ProxyProfile Profile { get; set; } Property Value Type Description ProxyProfile | Edit this page View Source ProxyAuthenticationRealm Realm used during Proxy Basic Authentication. Declaration public string ProxyAuthenticationRealm { get; set; } Property Value Type Description string | Edit this page View Source ProxyAuthenticationSchemes A collection of scheme types, e.g. basic, NTLM, Kerberos, Negotiate, to return if scheme authentication is required. Works in relation with ProxySchemeAuthenticateFunc. Declaration public IEnumerable ProxyAuthenticationSchemes { get; set; } Property Value Type Description IEnumerable | Edit this page View Source ProxyBasicAuthenticateFunc A callback to authenticate proxy clients via basic authentication. Parameters are username and password as provided by client. Should return true for successful authentication. Declaration public Func>? ProxyBasicAuthenticateFunc { get; set; } Property Value Type Description Func> | Edit this page View Source ProxyEndPoints A list of IpAddress and port this proxy is listening to. Declaration public List ProxyEndPoints { get; set; } Property Value Type Description List | Edit this page View Source ProxyRunning Is the proxy currently running? Declaration public bool ProxyRunning { get; } Property Value Type Description bool | Edit this page View Source ProxySchemeAuthenticateFunc A pluggable callback to authenticate clients by scheme instead of requiring basic authentication through ProxyBasicAuthenticateFunc. Parameters are current working session, schemeType, and token as provided by a calling client. Should return success for successful authentication, continuation if the package requests, or failure. Declaration public Func>? ProxySchemeAuthenticateFunc { get; set; } Property Value Type Description Func> | Edit this page View Source RequestTimeoutSeconds Total seconds allowed for a single request/response exchange after BeforeRequest returns (connect, send, wait for headers, and body copy). Default is 0 (disabled). Per-session override: RequestTimeout. Declaration public int RequestTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ResourceLimits The shared, immutable resource-bound snapshot (concurrent-stream cap, CONTINUATION frame-count/wall-clock bounds, peer-initiated incomplete-stream-reset budget, and the other limits described in ProxyResourceLimits) consulted by the HTTP/2 relay so a single proxy-owned value governs both what is enforced and what is advertised to each peer, rather than admitting purely against whatever the origin advertised. Assign a new ProxyResourceLimits (constructed via Create(long, int, long, long?, long?, double?, int?, int, int?, int, TimeSpan, bool, int, int?)) to override the Default snapshot. There is no artificial upper clamp: high-CPU/RAM hosts may pass larger maxCachedConnectionsPerHost, maxConcurrentStreamsPerConnection, etc. as needed. The live TCP pool depth knob MaxCachedConnections remains independently settable and should usually be kept in sync with MaxCachedConnectionsPerHost. Declaration public ProxyResourceLimits ResourceLimits { get; set; } Property Value Type Description ProxyResourceLimits | Edit this page View Source ResponseHeaderTimeoutSeconds Seconds to wait for the origin to send the response status line and headers after the request has been sent. Enforced with a linked CancellationTokenSource (not Socket receive timeout alone). When the deadline elapses a ProxyTimeoutException with ResponseHeader is raised (and may be converted to HTTP 504 before any response bytes have been committed to the client). Default is 0 (disabled). WebSocket upgrades, Server-Sent Events, raw tunnels, and sessions that already wrote a response status to the client are exempt; those waits use IdleReadTimeoutSeconds when configured. Per-session override: ResponseHeaderTimeout. Declaration public int ResponseHeaderTimeoutSeconds { get; set; } Property Value Type Description int | Edit this page View Source ReuseSocket When true (default), SO_REUSEADDR is requested where Titanium.Web.Proxy.Helpers.RunTime.IsSocketReuseAvailable() reports support (always on Windows; on non-Windows, .NET Core 3+ / compatible runtimes). Declaration public bool ReuseSocket { get; set; } Property Value Type Description bool | Edit this page View Source ReverseProxy Optional reverse-proxy route/cluster configuration. When null (default), Core keeps 6.x ForwardHost behavior with zero added cost on the hot path. Declaration public ReverseProxyOptions? ReverseProxy { get; set; } Property Value Type Description ReverseProxyOptions | Edit this page View Source ServerConnectionCount Total number of active server connections (TCP plus upstream QUIC). For HTTP/3-only upstreams see Http3ServerConnectionCount. Declaration public int ServerConnectionCount { get; } Property Value Type Description int | Edit this page View Source ShouldInterceptHttp Optional per-request/stream predicate consulted only when the global interception gate is active. Return true to use the full SessionEventArgs path; return false to use the fast-forward path. null (the default) intercepts every request — preserving today's behavior. Declaration public Func? ShouldInterceptHttp { get; set; } Property Value Type Description Func | Edit this page View Source SupportedServerSslProtocols Ssl versions offered on outbound HTTPS connections to origins (and upstream proxies). Default None means “use SupportedSslProtocols” (typically TLS 1.2 and 1.3). Set an explicit mask to restrict or expand outbound-only independently of inbound client TLS. Older docs described None as “same as the proxy client.” That coupling is incorrect across protocol translations (e.g. inbound QUIC is always TLS 1.3 while outbound TCP SslStream on macOS SecureTransport cannot offer TLS 1.3). Declaration public SslProtocols SupportedServerSslProtocols { get; set; } Property Value Type Description SslProtocols | Edit this page View Source SupportedSslProtocols List of supported Ssl versions. Defaults to TLS 1.2/1.3 only as of 5.0 - a breaking change from 4.x, which also enabled SSL 3.0/TLS 1.0/1.1. Those legacy, broken-by-design protocols require an explicit opt-in by assigning this property directly (e.g. SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13) if a legacy client/server genuinely requires them. Declaration public SslProtocols SupportedSslProtocols { get; set; } Property Value Type Description SslProtocols | Edit this page View Source TcpTimeWaitSeconds SO_LINGER timeout in seconds applied to client and upstream sockets via LingerOption (enabled with this timeout). This is not the kernel TCP TIME_WAIT duration — TIME_WAIT is controlled by the OS. A positive value means Close may block up to that many seconds flushing send buffers; use 0 for an abortive close (RST). Default is 0 so high-churn proxies avoid TIME_WAIT accumulation; the 1-second connection disposal delay already prefers peer-first close. Declaration public int TcpTimeWaitSeconds { get; set; } Property Value Type Description int | Edit this page View Source ThreadPoolWorkerThread Customize the minimum ThreadPool size (increase it on a server). Defaults to max(ProcessorCount * 2, 16) so short loopback/proxy workloads are not starved while the pool is still ramping workers. Declaration public int ThreadPoolWorkerThread { get; set; } Property Value Type Description int | Edit this page View Source UpStreamEndPoint Local adapter/NIC endpoint where proxy makes request via. Defaults via any IP addresses of this machine. When the resolved destination address family does not match this endpoint, it is ignored so dual-stack destinations can still connect (see UpStreamEndPointIPv4 / UpStreamEndPointIPv6). Declaration public IPEndPoint? UpStreamEndPoint { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv4 Local bind endpoint used when the resolved upstream destination is IPv4. Takes precedence over UpStreamEndPoint for IPv4 destinations. Declaration public IPEndPoint? UpStreamEndPointIPv4 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamEndPointIPv6 Local bind endpoint used when the resolved upstream destination is IPv6. Takes precedence over UpStreamEndPoint for IPv6 destinations. Declaration public IPEndPoint? UpStreamEndPointIPv6 { get; set; } Property Value Type Description IPEndPoint | Edit this page View Source UpStreamHttpProxy External proxy used for Http requests. Declaration public IExternalProxy? UpStreamHttpProxy { get; set; } Property Value Type Description IExternalProxy | Edit this page View Source UpStreamHttpsProxy External proxy used for Https requests. Declaration public IExternalProxy? UpStreamHttpsProxy { get; set; } Property Value Type Description IExternalProxy | Edit this page View Source UpstreamProxyConfigurationScript If set, the upstream proxy will be detected by a script that will be loaded from the provided Uri Declaration public Uri? UpstreamProxyConfigurationScript { get; set; } Property Value Type Description Uri | Edit this page View Source ViaHeaderPseudonym Pseudonym used in Via header fields appended to forwarded requests and responses (RFC 9110 §7.6.3). Defaults to \"titanium-web-proxy\". Set to an empty string to disable Via header injection entirely. Loop detection uses this value: a request arriving with this pseudonym already present in Via is refused with 508 Loop Detected. Declaration public string ViaHeaderPseudonym { get; set; } Property Value Type Description string | Edit this page View Source WinAuthCredentialsProvider Optional per-session credential provider for server 401 WinAuth (NTLM/Negotiate/Kerberos). Return null to use the current process identity (legacy behavior). Do not put plaintext passwords on SessionEventArgs — use this callback instead. Windows SSPI only; ignored on non-Windows platforms. Declaration public Func>? WinAuthCredentialsProvider { get; set; } Property Value Type Description Func> Methods | Edit this page View Source AddEndPoint(ProxyEndPoint) Add a proxy end point. Declaration public void AddEndPoint(ProxyEndPoint endPoint) Parameters Type Name Description ProxyEndPoint endPoint The proxy endpoint. | Edit this page View Source ApplyLoggingConfiguration() Rebuilds the active logger/logger factory from the current Logging configuration, disposing any previously owned built-in providers. Called automatically from the constructor (with the default configuration) and from Start(bool). Call this explicitly any time after changing Logging and you want the change to take effect immediately - whether the proxy is stopped (e.g. before using CertificateManager directly) or already running. Declaration public void ApplyLoggingConfiguration() | Edit this page View Source DisableAllSystemProxies() Clear all proxy settings for current machine. Declaration public void DisableAllSystemProxies() | Edit this page View Source DisableSystemHttpProxy() Clear HTTP proxy settings of current machine. Declaration public void DisableSystemHttpProxy() | Edit this page View Source DisableSystemHttpsProxy() Clear HTTPS proxy settings of current machine. Declaration public void DisableSystemHttpsProxy() | Edit this page View Source DisableSystemProxy(ProxyProtocolType) Clear the specified proxy setting for current machine. Declaration public void DisableSystemProxy(ProxyProtocolType protocolType) Parameters Type Name Description ProxyProtocolType protocolType | Edit this page View Source Dispose() Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. Declaration public void Dispose() | Edit this page View Source Dispose(bool) Declaration [SuppressMessage(\"ApiDesign\", \"RS0016:Add public types and members to the declared API\", Justification = \"Protected Dispose(bool) is required by the standard IDisposable pattern but is not public API.\")] protected virtual void Dispose(bool disposing) Parameters Type Name Description bool disposing | Edit this page View Source RemoveEndPoint(ProxyEndPoint) Remove a proxy end point. Will throw error if the end point doesn't exist. Declaration public void RemoveEndPoint(ProxyEndPoint endPoint) Parameters Type Name Description ProxyEndPoint endPoint The existing endpoint to remove. | Edit this page View Source RestoreOriginalProxySettings() Restores the original proxy settings. Declaration public void RestoreOriginalProxySettings() | Edit this page View Source SetAsSystemHttpProxy(ExplicitProxyEndPoint) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. | Edit this page View Source SetAsSystemHttpProxy(ExplicitProxyEndPoint, SystemProxySettings) Set the given explicit end point as the default HTTP proxy server for current machine. Declaration public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. SystemProxySettings settings The Windows system proxy settings. | Edit this page View Source SetAsSystemHttpsProxy(ExplicitProxyEndPoint) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. | Edit this page View Source SetAsSystemHttpsProxy(ExplicitProxyEndPoint, SystemProxySettings) Set the given explicit end point as the default HTTPS proxy server for current machine. Declaration public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. SystemProxySettings settings The Windows system proxy settings. | Edit this page View Source SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. ProxyProtocolType protocolType The proxy protocol type. | Edit this page View Source SetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) Set the given explicit end point as the default proxy server for current machine. Declaration public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, SystemProxySettings? settings) Parameters Type Name Description ExplicitProxyEndPoint endPoint The explicit endpoint. ProxyProtocolType protocolType The proxy protocol type. SystemProxySettings settings The Windows system proxy settings, or null to preserve the current bypass list. | Edit this page View Source SetHttp3Enabled(bool) Enables or disables EnableHttp3. Enabling still requires MsQuic (IsSupported); disabling is always applied. Safe to call while the proxy is running — new origin connections pick up the change. Existing sessions keep the protocol they already negotiated. Declaration public bool SetHttp3Enabled(bool enabled) Parameters Type Name Description bool enabled Returns Type Description bool true when HTTP/3 is enabled after the call. | Edit this page View Source Start(bool) Start this proxy server instance. Transactional: if any endpoint fails to start, every listener this call already started is stopped, the system-upstream-proxy resolver (if this call created one) is disposed, and ProxyRunning is left false before the exception propagates. A caller that catches the exception is left with an instance in exactly the same state as before calling Start(bool), not a partially-bound proxy with some endpoints silently listening. Declaration public void Start(bool changeSystemProxySettings = true) Parameters Type Name Description bool changeSystemProxySettings Whether or not clear any system proxy settings which is pointing to our own endpoint (causing a cycle). E.g due to ungracious proxy shutdown before. | Edit this page View Source Stop() Stop this proxy server instance. Endpoints remain registered so Start(bool) can re-listen on the same ports. In-flight sessions are cancelled; pooled upstream connections are cleared. The connection factory itself stays usable for a subsequent Start (it is only disposed with the proxy). Declaration public void Stop() | Edit this page View Source StopAsync(TimeSpan?) Asynchronously stop this proxy server, cancel in-flight sessions, and wait briefly for client connection count to drain before clearing the upstream pool. Declaration public Task StopAsync(TimeSpan? drainTimeout = null) Parameters Type Name Description TimeSpan? drainTimeout Maximum time to wait for active client handlers to exit after cancellation. Defaults to 5 seconds. Returns Type Description Task | Edit this page View Source TryDisableAllSystemProxies() Clear all OS proxy settings without throwing. Declaration public SystemProxyChangeResult TryDisableAllSystemProxies() Returns Type Description SystemProxyChangeResult | Edit this page View Source TryDisableSystemProxy(ProxyProtocolType) Clear OS proxy for the given protocols without throwing. Declaration public SystemProxyChangeResult TryDisableSystemProxy(ProxyProtocolType protocolType) Parameters Type Name Description ProxyProtocolType protocolType Returns Type Description SystemProxyChangeResult | Edit this page View Source TryEnableHttp3IfSupported() Turns on EnableHttp3 when MsQuic is available (IsSupported). Hosts (CLI, Inspector, examples) should call this instead of setting EnableHttp3 blindly. Returns true when HTTP/3 was enabled. Declaration public bool TryEnableHttp3IfSupported() Returns Type Description bool | Edit this page View Source TryRestoreOriginalProxySettings() Restore OS proxy without throwing. Declaration public SystemProxyChangeResult TryRestoreOriginalProxySettings() Returns Type Description SystemProxyChangeResult | Edit this page View Source TrySetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) Enable OS system proxy without throwing. Failures are logged and returned so Inspector/CLI can show a status message instead of crashing. Declaration public SystemProxyChangeResult TrySetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, SystemProxySettings? settings = null) Parameters Type Name Description ExplicitProxyEndPoint endPoint ProxyProtocolType protocolType SystemProxySettings settings Returns Type Description SystemProxyChangeResult Events | Edit this page View Source AfterResponse Intercept after response event from server. Declaration public event AsyncEventHandler? AfterResponse Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeRequest Intercept request event to server. Declaration public event AsyncEventHandler? BeforeRequest Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeResponse Intercept response event from server. Declaration public event AsyncEventHandler? BeforeResponse Event Type Type Description AsyncEventHandler | Edit this page View Source BeforeUpStreamConnectRequest Intercept connect request sent to upstream proxy. Declaration public event AsyncEventHandler? BeforeUpStreamConnectRequest Event Type Type Description AsyncEventHandler | Edit this page View Source ClientCertificateSelectionCallback Event to override client certificate selection during mutual SSL authentication. Declaration public event AsyncEventHandler? ClientCertificateSelectionCallback Event Type Type Description AsyncEventHandler | Edit this page View Source ClientConnectionCountChanged Event occurs when client connection count changed. Declaration public event EventHandler? ClientConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source Http3ClientConnectionCountChanged Event occurs when inbound HTTP/3 client connection count changed. Declaration public event EventHandler? Http3ClientConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source Http3ServerConnectionCountChanged Event occurs when upstream HTTP/3 server connection count changed. Declaration public event EventHandler? Http3ServerConnectionCountChanged Event Type Type Description EventHandler | Edit this page View Source OnClientConnectionCreate Customize TcpClient used for client connection upon create. Declaration public event AsyncEventHandler? OnClientConnectionCreate Event Type Type Description AsyncEventHandler | Edit this page View Source OnRequestBodyWrite Intercept request body send event to server. Subscribe to inspect or modify the request body chunk-by-chunk as it streams to the server, without buffering the whole body. Do not combine with SessionEventArgs.GetRequestBody (which buffers). Declaration public event AsyncEventHandler? OnRequestBodyWrite Event Type Type Description AsyncEventHandler | Edit this page View Source OnResponseBodyWrite Intercept response body send event to client. Subscribe to inspect or modify the response body chunk-by-chunk as it streams to the client, without buffering the whole body. Do not combine with SessionEventArgs.GetResponseBody (which buffers). Declaration public event AsyncEventHandler? OnResponseBodyWrite Event Type Type Description AsyncEventHandler | Edit this page View Source OnServerConnectionCreate Customize TcpClient used for server connection upon create. Declaration public event AsyncEventHandler? OnServerConnectionCreate Event Type Type Description AsyncEventHandler | Edit this page View Source ServerCertificateValidationCallback Event to override the default verification logic of remote SSL certificate received during authentication. Declaration public event AsyncEventHandler? ServerCertificateValidationCallback Event Type Type Description AsyncEventHandler | Edit this page View Source ServerConnectionCountChanged Event occurs when server connection count changed. Declaration public event EventHandler? ServerConnectionCountChanged Event Type Type Description EventHandler Implements IDisposable" }, "api/Titanium.Web.Proxy.Routing.ReverseProxyFastPath.html": { "href": "api/Titanium.Web.Proxy.Routing.ReverseProxyFastPath.html", @@ -584,6 +634,11 @@ "title": "Enum SystemProxyBypassRuleMode | Titanium Web Proxy", "summary": "Enum SystemProxyBypassRuleMode Controls how configured bypass rules are combined with the current Windows system proxy bypass list. Namespace: Titanium.Web.Proxy Assembly: Titanium.Web.Proxy.dll Syntax public enum SystemProxyBypassRuleMode Fields Name Description Merge Preserve the current bypass rules and add the configured rules. Replace Replace the current bypass rules with the configured rules." }, + "api/Titanium.Web.Proxy.SystemProxyChangeResult.html": { + "href": "api/Titanium.Web.Proxy.SystemProxyChangeResult.html", + "title": "Struct SystemProxyChangeResult | Titanium Web Proxy", + "summary": "Struct SystemProxyChangeResult Outcome of enabling or disabling OS system proxy. Callers must treat failure as non-fatal: log Message and continue (do not crash the process). Inherited Members ValueType.Equals(object) ValueType.GetHashCode() ValueType.ToString() object.Equals(object, object) object.GetType() object.ReferenceEquals(object, object) Namespace: Titanium.Web.Proxy Assembly: Titanium.Web.Proxy.dll Syntax public readonly struct SystemProxyChangeResult Constructors | Edit this page View Source SystemProxyChangeResult(bool, string) Declaration public SystemProxyChangeResult(bool succeeded, string message) Parameters Type Name Description bool succeeded string message Properties | Edit this page View Source Message Declaration public string Message { get; } Property Value Type Description string | Edit this page View Source Succeeded Declaration public bool Succeeded { get; } Property Value Type Description bool Methods | Edit this page View Source Fail(string) Declaration public static SystemProxyChangeResult Fail(string message) Parameters Type Name Description string message Returns Type Description SystemProxyChangeResult | Edit this page View Source Ok(string) Declaration public static SystemProxyChangeResult Ok(string message) Parameters Type Name Description string message Returns Type Description SystemProxyChangeResult" + }, "api/Titanium.Web.Proxy.SystemProxyLoopbackPlacement.html": { "href": "api/Titanium.Web.Proxy.SystemProxyLoopbackPlacement.html", "title": "Enum SystemProxyLoopbackPlacement | Titanium Web Proxy", @@ -592,17 +647,17 @@ "api/Titanium.Web.Proxy.SystemProxySettings.html": { "href": "api/Titanium.Web.Proxy.SystemProxySettings.html", "title": "Class SystemProxySettings | Titanium Web Proxy", - "summary": "Class SystemProxySettings Options applied when configuring an explicit endpoint as the Windows system proxy. Inheritance object SystemProxySettings Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy Assembly: Titanium.Web.Proxy.dll Syntax public class SystemProxySettings Properties | Edit this page View Source BypassRuleMode Gets or sets how BypassRules are combined with the current Windows system proxy bypass list. Declaration public SystemProxyBypassRuleMode BypassRuleMode { get; set; } Property Value Type Description SystemProxyBypassRuleMode | Edit this page View Source BypassRules Gets additional WinINET host patterns that should bypass the proxy. Declaration public IList BypassRules { get; } Property Value Type Description IList | Edit this page View Source ProxyLoopback Gets or sets whether loopback requests should use the proxy. Declaration public bool ProxyLoopback { get; set; } Property Value Type Description bool Remarks This adds the WinINET <-loopback> rule. It only affects applications that honor compatible Windows system proxy settings and can expose otherwise trusted local traffic to the proxy. | Edit this page View Source ProxyLoopbackPlacement Gets or sets where the <-loopback> rule is placed in the bypass list. Declaration public SystemProxyLoopbackPlacement ProxyLoopbackPlacement { get; set; } Property Value Type Description SystemProxyLoopbackPlacement Remarks Ordering matters because rules are evaluated left-to-right; a subtractive rule such as <-loopback> has a different effect before versus after a contradicting bypass rule." + "summary": "Class SystemProxySettings Options applied when configuring an explicit endpoint as the Windows system proxy. Inheritance object SystemProxySettings Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.MemberwiseClone() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy Assembly: Titanium.Web.Proxy.dll Syntax public class SystemProxySettings Properties | Edit this page View Source BypassRuleMode Gets or sets how BypassRules are combined with the current Windows system proxy bypass list. Declaration public SystemProxyBypassRuleMode BypassRuleMode { get; set; } Property Value Type Description SystemProxyBypassRuleMode | Edit this page View Source BypassRules Gets additional WinINET host patterns that should bypass the proxy. Declaration public IList BypassRules { get; } Property Value Type Description IList | Edit this page View Source ProxyLoopback Gets or sets whether loopback requests should use the proxy. Declaration public bool ProxyLoopback { get; set; } Property Value Type Description bool Remarks This adds the WinINET <-loopback> rule. It only affects applications that honor compatible Windows system proxy settings and can expose otherwise trusted local traffic to the proxy. | Edit this page View Source ProxyLoopbackPlacement Gets or sets where the <-loopback> rule is placed in the bypass list. Declaration public SystemProxyLoopbackPlacement ProxyLoopbackPlacement { get; set; } Property Value Type Description SystemProxyLoopbackPlacement Remarks Ordering matters because rules are evaluated left-to-right; a subtractive rule such as <-loopback> has a different effect before versus after a contradicting bypass rule. Methods | Edit this page View Source BuildProxyOverride(string?) Builds the WinINET-style semicolon-separated bypass list that would be applied to the OS. Declaration public string BuildProxyOverride(string? currentProxyOverride) Parameters Type Name Description string currentProxyOverride Returns Type Description string" }, "api/Titanium.Web.Proxy.Transforms.TransformEngine.html": { "href": "api/Titanium.Web.Proxy.Transforms.TransformEngine.html", "title": "Class TransformEngine | Titanium Web Proxy", - "summary": "Class TransformEngine Applies known transform kinds (path prefix strip/set header). Inheritance object TransformEngine Implements ITransformEngine Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Transforms Assembly: Titanium.Web.Proxy.dll Syntax public sealed class TransformEngine : ITransformEngine Methods | Edit this page View Source ApplyRequestTransforms(IReadOnlyList?, TransformRequestContext) Declaration public void ApplyRequestTransforms(IReadOnlyList? transforms, TransformRequestContext context) Parameters Type Name Description IReadOnlyList transforms TransformRequestContext context Implements Titanium.Web.Proxy.Abstractions.Routing.ITransformEngine" + "summary": "Class TransformEngine Applies known transform kinds to request path/headers/query and staged response headers. Inheritance object TransformEngine Implements ITransformEngine Inherited Members object.Equals(object) object.Equals(object, object) object.GetHashCode() object.GetType() object.ReferenceEquals(object, object) object.ToString() Namespace: Titanium.Web.Proxy.Transforms Assembly: Titanium.Web.Proxy.dll Syntax public sealed class TransformEngine : ITransformEngine Methods | Edit this page View Source ApplyRequestTransforms(IReadOnlyList?, TransformRequestContext) Declaration public void ApplyRequestTransforms(IReadOnlyList? transforms, TransformRequestContext context) Parameters Type Name Description IReadOnlyList transforms TransformRequestContext context Implements Titanium.Web.Proxy.Abstractions.Routing.ITransformEngine" }, "api/Titanium.Web.Proxy.Transforms.html": { "href": "api/Titanium.Web.Proxy.Transforms.html", "title": "Namespace Titanium.Web.Proxy.Transforms | Titanium Web Proxy", - "summary": "Namespace Titanium.Web.Proxy.Transforms Classes TransformEngine Applies known transform kinds (path prefix strip/set header)." + "summary": "Namespace Titanium.Web.Proxy.Transforms Classes TransformEngine Applies known transform kinds to request path/headers/query and staged response headers." }, "api/Titanium.Web.Proxy.WebSocketDecoder.html": { "href": "api/Titanium.Web.Proxy.WebSocketDecoder.html", @@ -637,6 +692,6 @@ "api/Titanium.Web.Proxy.html": { "href": "api/Titanium.Web.Proxy.html", "title": "Namespace Titanium.Web.Proxy | Titanium Web Proxy", - "summary": "Namespace Titanium.Web.Proxy Classes ProxyLimits Reference values for a handful of resource-limit defaults, kept here for documentation and cross-checking purposes. ProxyServer Translates an HTTP/1.1 client connection onto an h2-only origin (Http2 with AllowHttpProtocolTranslation enabled - see ResolveHttp2ForClientAsync(SessionEventArgsBase, bool, string, int, string, int?, UpstreamHttpProtocol, bool, bool, CancellationToken, bool)), leasing one h2 stream per HTTP/1.1 request from a shared Titanium.Web.Proxy.Http2.Http2OriginConnection via Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool rather than opening a new TCP/TLS connection for every request. SystemProxySettings Options applied when configuring an explicit endpoint as the Windows system proxy. WebSocketDecoder Reassembles raw bytes relayed after a WebSocket upgrade (see SessionEventArgs.WebSocketDecoderSend/WebSocketDecoderReceive, fed from the session's DataSent/DataReceived events) into individual WebSocketFrames, handling frames split across multiple reads and unmasking masked (client-to-server) payloads. Use one instance per direction of a single connection - frames may span calls, so state from one call feeds the next. WebSocketFrame A single decoded WebSocket frame, as produced by Decode(byte[], int, int). WebSocketFrameEncoder Encodes WebSocketFrame values to RFC 6455 wire bytes. WebSocketFrameWriter Injects WebSocket frames onto one side of an intercepted tunnel. WebSocketProtocolException Thrown by WebSocketDecoder when a frame's declared payload length violates RFC 6455 section 5.2 (the reserved high bit of a 64-bit extended length is set, or the declared length exceeds MaxValue and can never be buffered as a single in-memory frame), or when it exceeds the caller-configured per-frame payload limit (RFC 6455 section 7.4.1, close code 1009). Raised the moment the declared length is known - before any of that frame's payload bytes are copied into the decoder's reassembly buffer - so an attacker who declares an oversized length and then trickles bytes in slowly cannot force unbounded buffer growth while the decoder waits for a frame that will never legitimately complete. Enums SystemProxyBypassRuleMode Controls how configured bypass rules are combined with the current Windows system proxy bypass list. SystemProxyLoopbackPlacement Controls where the <-loopback> rule is placed within the Windows system proxy bypass list. WebsocketOpCode" + "summary": "Namespace Titanium.Web.Proxy Classes ClientProcessId Capability for resolving the local client process that owns a TCP connection to the proxy. MitmExclusionDefaults Default hostname exclusions for MITM proxies (Microsoft identity / certificate pinning). Use with BypassRules and DecryptSsl. ProxyLimits Reference values for a handful of resource-limit defaults, kept here for documentation and cross-checking purposes. ProxyServer Translates an HTTP/1.1 client connection onto an h2-only origin (Http2 with AllowHttpProtocolTranslation enabled - see ResolveHttp2ForClientAsync(SessionEventArgsBase, bool, string, int, string, int?, UpstreamHttpProtocol, bool, bool, CancellationToken, bool)), leasing one h2 stream per HTTP/1.1 request from a shared Titanium.Web.Proxy.Http2.Http2OriginConnection via Titanium.Web.Proxy.ProxyServer.Http2OriginConnectionPool rather than opening a new TCP/TLS connection for every request. SystemProxySettings Options applied when configuring an explicit endpoint as the Windows system proxy. WebSocketDecoder Reassembles raw bytes relayed after a WebSocket upgrade (see SessionEventArgs.WebSocketDecoderSend/WebSocketDecoderReceive, fed from the session's DataSent/DataReceived events) into individual WebSocketFrames, handling frames split across multiple reads and unmasking masked (client-to-server) payloads. Use one instance per direction of a single connection - frames may span calls, so state from one call feeds the next. WebSocketFrame A single decoded WebSocket frame, as produced by Decode(byte[], int, int). WebSocketFrameEncoder Encodes WebSocketFrame values to RFC 6455 wire bytes. WebSocketFrameWriter Injects WebSocket frames onto one side of an intercepted tunnel. WebSocketProtocolException Thrown by WebSocketDecoder when a frame's declared payload length violates RFC 6455 section 5.2 (the reserved high bit of a 64-bit extended length is set, or the declared length exceeds MaxValue and can never be buffered as a single in-memory frame), or when it exceeds the caller-configured per-frame payload limit (RFC 6455 section 7.4.1, close code 1009). Raised the moment the declared length is known - before any of that frame's payload bytes are copied into the decoder's reassembly buffer - so an attacker who declares an oversized length and then trickles bytes in slowly cannot force unbounded buffer growth while the decoder waits for a frame that will never legitimately complete. Structs SystemProxyChangeResult Outcome of enabling or disabling OS system proxy. Callers must treat failure as non-fatal: log Message and continue (do not crash the process). Enums MitmExclusionMode How factory MITM exclusion defaults interact with caller-supplied host lists. SystemProxyBypassRuleMode Controls how configured bypass rules are combined with the current Windows system proxy bypass list. SystemProxyLoopbackPlacement Controls where the <-loopback> rule is placed within the Windows system proxy bypass list. WebsocketOpCode" } } \ No newline at end of file diff --git a/docs/migration-6-to-7.md b/docs/migration-6-to-7.md index dd5da9f39..e62a0c064 100644 --- a/docs/migration-6-to-7.md +++ b/docs/migration-6-to-7.md @@ -12,7 +12,7 @@ |---------|------| | `Titanium.Web.Proxy` | Engine (MIT) | | `Titanium.Web.Proxy.Abstractions` | Shared route/cluster/middleware/plugin contracts (MIT) | -| `Titanium.Web.Proxy.Configuration` | YAML/JSON + dialect readers (MIT) — **optional** for embedders | +| `Titanium.Web.Proxy.Configuration` | YAML/JSON + dialect readers (MIT) — **optional** for library users | Apps that only construct `ProxyServer` and set `ForwardHost` **do not** need Configuration. @@ -35,7 +35,7 @@ Simple reverse configs in the CLI must **not** set `EnableHttpInterception` or s ## Editions - **Titanium.Cli** (`titanium` / `twp`) — MIT daemon -- **Titanium.Plus** — PolyForm Noncommercial plugin DLL (ALC); not on nuget.org -- **Titanium Inspector** — PolyForm Noncommercial desktop app +- **Titanium.Plus** — plugin DLL (ALC); not on nuget.org +- **Titanium Inspector** — desktop app -See the README Editions table for licenses and distribution channels. +See the README [License](https://github.com/justcoding121/titanium-web-proxy/blob/develop/README.md#license) section for licenses and distribution channels. diff --git a/docs/xrefmap.yml b/docs/xrefmap.yml index 4a1b41ba8..7a56440d8 100644 --- a/docs/xrefmap.yml +++ b/docs/xrefmap.yml @@ -131,6 +131,25 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.Caching.MemoryHttpResponseCache.TryGet nameWithType: MemoryHttpResponseCache.TryGet +- uid: Titanium.Web.Proxy.ClientProcessId + name: ClientProcessId + href: api/Titanium.Web.Proxy.ClientProcessId.html + commentId: T:Titanium.Web.Proxy.ClientProcessId + fullName: Titanium.Web.Proxy.ClientProcessId + nameWithType: ClientProcessId +- uid: Titanium.Web.Proxy.ClientProcessId.IsSupported + name: IsSupported + href: api/Titanium.Web.Proxy.ClientProcessId.html#Titanium_Web_Proxy_ClientProcessId_IsSupported + commentId: P:Titanium.Web.Proxy.ClientProcessId.IsSupported + fullName: Titanium.Web.Proxy.ClientProcessId.IsSupported + nameWithType: ClientProcessId.IsSupported +- uid: Titanium.Web.Proxy.ClientProcessId.IsSupported* + name: IsSupported + href: api/Titanium.Web.Proxy.ClientProcessId.html#Titanium_Web_Proxy_ClientProcessId_IsSupported_ + commentId: Overload:Titanium.Web.Proxy.ClientProcessId.IsSupported + isSpec: "True" + fullName: Titanium.Web.Proxy.ClientProcessId.IsSupported + nameWithType: ClientProcessId.IsSupported - uid: Titanium.Web.Proxy.Clusters name: Titanium.Web.Proxy.Clusters href: api/Titanium.Web.Proxy.Clusters.html @@ -2539,6 +2558,19 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.UpstreamConnectionTiming nameWithType: SessionEventArgsBase.UpstreamConnectionTiming +- uid: Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.UpstreamDestinationId + name: UpstreamDestinationId + href: api/Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.html#Titanium_Web_Proxy_EventArguments_SessionEventArgsBase_UpstreamDestinationId + commentId: P:Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.UpstreamDestinationId + fullName: Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.UpstreamDestinationId + nameWithType: SessionEventArgsBase.UpstreamDestinationId +- uid: Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.UpstreamDestinationId* + name: UpstreamDestinationId + href: api/Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.html#Titanium_Web_Proxy_EventArguments_SessionEventArgsBase_UpstreamDestinationId_ + commentId: Overload:Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.UpstreamDestinationId + isSpec: "True" + fullName: Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.UpstreamDestinationId + nameWithType: SessionEventArgsBase.UpstreamDestinationId - uid: Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.UserData name: UserData href: api/Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.html#Titanium_Web_Proxy_EventArguments_SessionEventArgsBase_UserData @@ -3244,6 +3276,107 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.Exceptions.UpstreamProxyConnectException.StatusDescription nameWithType: UpstreamProxyConnectException.StatusDescription +- uid: Titanium.Web.Proxy.Helpers + name: Titanium.Web.Proxy.Helpers + href: api/Titanium.Web.Proxy.Helpers.html + commentId: N:Titanium.Web.Proxy.Helpers + fullName: Titanium.Web.Proxy.Helpers + nameWithType: Titanium.Web.Proxy.Helpers +- uid: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper + name: UnixProxyBypassMapper + href: api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html + commentId: T:Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper + fullName: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper + nameWithType: UnixProxyBypassMapper +- uid: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.IsLocalHost(System.String) + name: IsLocalHost(string?) + href: api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html#Titanium_Web_Proxy_Helpers_UnixProxyBypassMapper_IsLocalHost_System_String_ + commentId: M:Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.IsLocalHost(System.String) + name.vb: IsLocalHost(String) + fullName: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.IsLocalHost(string?) + fullName.vb: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.IsLocalHost(String) + nameWithType: UnixProxyBypassMapper.IsLocalHost(string?) + nameWithType.vb: UnixProxyBypassMapper.IsLocalHost(String) +- uid: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.IsLocalHost* + name: IsLocalHost + href: api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html#Titanium_Web_Proxy_Helpers_UnixProxyBypassMapper_IsLocalHost_ + commentId: Overload:Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.IsLocalHost + isSpec: "True" + fullName: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.IsLocalHost + nameWithType: UnixProxyBypassMapper.IsLocalHost +- uid: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToCommaSeparated(System.String) + name: ToCommaSeparated(string?) + href: api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html#Titanium_Web_Proxy_Helpers_UnixProxyBypassMapper_ToCommaSeparated_System_String_ + commentId: M:Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToCommaSeparated(System.String) + name.vb: ToCommaSeparated(String) + fullName: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToCommaSeparated(string?) + fullName.vb: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToCommaSeparated(String) + nameWithType: UnixProxyBypassMapper.ToCommaSeparated(string?) + nameWithType.vb: UnixProxyBypassMapper.ToCommaSeparated(String) +- uid: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToCommaSeparated* + name: ToCommaSeparated + href: api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html#Titanium_Web_Proxy_Helpers_UnixProxyBypassMapper_ToCommaSeparated_ + commentId: Overload:Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToCommaSeparated + isSpec: "True" + fullName: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToCommaSeparated + nameWithType: UnixProxyBypassMapper.ToCommaSeparated +- uid: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToGsettingsArray(System.String) + name: ToGsettingsArray(string?) + href: api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html#Titanium_Web_Proxy_Helpers_UnixProxyBypassMapper_ToGsettingsArray_System_String_ + commentId: M:Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToGsettingsArray(System.String) + name.vb: ToGsettingsArray(String) + fullName: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToGsettingsArray(string?) + fullName.vb: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToGsettingsArray(String) + nameWithType: UnixProxyBypassMapper.ToGsettingsArray(string?) + nameWithType.vb: UnixProxyBypassMapper.ToGsettingsArray(String) +- uid: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToGsettingsArray* + name: ToGsettingsArray + href: api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html#Titanium_Web_Proxy_Helpers_UnixProxyBypassMapper_ToGsettingsArray_ + commentId: Overload:Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToGsettingsArray + isSpec: "True" + fullName: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToGsettingsArray + nameWithType: UnixProxyBypassMapper.ToGsettingsArray +- uid: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToNoProxyEnv(System.String) + name: ToNoProxyEnv(string?) + href: api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html#Titanium_Web_Proxy_Helpers_UnixProxyBypassMapper_ToNoProxyEnv_System_String_ + commentId: M:Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToNoProxyEnv(System.String) + name.vb: ToNoProxyEnv(String) + fullName: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToNoProxyEnv(string?) + fullName.vb: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToNoProxyEnv(String) + nameWithType: UnixProxyBypassMapper.ToNoProxyEnv(string?) + nameWithType.vb: UnixProxyBypassMapper.ToNoProxyEnv(String) +- uid: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToNoProxyEnv(System.String,System.Boolean) + name: ToNoProxyEnv(string?, bool) + href: api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html#Titanium_Web_Proxy_Helpers_UnixProxyBypassMapper_ToNoProxyEnv_System_String_System_Boolean_ + commentId: M:Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToNoProxyEnv(System.String,System.Boolean) + name.vb: ToNoProxyEnv(String, Boolean) + fullName: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToNoProxyEnv(string?, bool) + fullName.vb: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToNoProxyEnv(String, Boolean) + nameWithType: UnixProxyBypassMapper.ToNoProxyEnv(string?, bool) + nameWithType.vb: UnixProxyBypassMapper.ToNoProxyEnv(String, Boolean) +- uid: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToNoProxyEnv* + name: ToNoProxyEnv + href: api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html#Titanium_Web_Proxy_Helpers_UnixProxyBypassMapper_ToNoProxyEnv_ + commentId: Overload:Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToNoProxyEnv + isSpec: "True" + fullName: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToNoProxyEnv + nameWithType: UnixProxyBypassMapper.ToNoProxyEnv +- uid: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToUnixBypassHosts(System.String) + name: ToUnixBypassHosts(string?) + href: api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html#Titanium_Web_Proxy_Helpers_UnixProxyBypassMapper_ToUnixBypassHosts_System_String_ + commentId: M:Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToUnixBypassHosts(System.String) + name.vb: ToUnixBypassHosts(String) + fullName: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToUnixBypassHosts(string?) + fullName.vb: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToUnixBypassHosts(String) + nameWithType: UnixProxyBypassMapper.ToUnixBypassHosts(string?) + nameWithType.vb: UnixProxyBypassMapper.ToUnixBypassHosts(String) +- uid: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToUnixBypassHosts* + name: ToUnixBypassHosts + href: api/Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.html#Titanium_Web_Proxy_Helpers_UnixProxyBypassMapper_ToUnixBypassHosts_ + commentId: Overload:Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToUnixBypassHosts + isSpec: "True" + fullName: Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToUnixBypassHosts + nameWithType: UnixProxyBypassMapper.ToUnixBypassHosts - uid: Titanium.Web.Proxy.Http name: Titanium.Web.Proxy.Http href: api/Titanium.Web.Proxy.Http.html @@ -4768,6 +4901,34 @@ references: commentId: F:Titanium.Web.Proxy.Http.TunnelType.Websocket fullName: Titanium.Web.Proxy.Http.TunnelType.Websocket nameWithType: TunnelType.Websocket +- uid: Titanium.Web.Proxy.Http3 + name: Titanium.Web.Proxy.Http3 + href: api/Titanium.Web.Proxy.Http3.html + commentId: N:Titanium.Web.Proxy.Http3 + fullName: Titanium.Web.Proxy.Http3 + nameWithType: Titanium.Web.Proxy.Http3 +- uid: Titanium.Web.Proxy.Http3.Http3NativeBootstrap + name: Http3NativeBootstrap + href: api/Titanium.Web.Proxy.Http3.Http3NativeBootstrap.html + commentId: T:Titanium.Web.Proxy.Http3.Http3NativeBootstrap + fullName: Titanium.Web.Proxy.Http3.Http3NativeBootstrap + nameWithType: Http3NativeBootstrap +- uid: Titanium.Web.Proxy.Http3.Http3NativeBootstrap.EnsureAppLocalMsQuicVisible(System.String[]) + name: EnsureAppLocalMsQuicVisible(string[]?) + href: api/Titanium.Web.Proxy.Http3.Http3NativeBootstrap.html#Titanium_Web_Proxy_Http3_Http3NativeBootstrap_EnsureAppLocalMsQuicVisible_System_String___ + commentId: M:Titanium.Web.Proxy.Http3.Http3NativeBootstrap.EnsureAppLocalMsQuicVisible(System.String[]) + name.vb: EnsureAppLocalMsQuicVisible(String()) + fullName: Titanium.Web.Proxy.Http3.Http3NativeBootstrap.EnsureAppLocalMsQuicVisible(string[]?) + fullName.vb: Titanium.Web.Proxy.Http3.Http3NativeBootstrap.EnsureAppLocalMsQuicVisible(String()) + nameWithType: Http3NativeBootstrap.EnsureAppLocalMsQuicVisible(string[]?) + nameWithType.vb: Http3NativeBootstrap.EnsureAppLocalMsQuicVisible(String()) +- uid: Titanium.Web.Proxy.Http3.Http3NativeBootstrap.EnsureAppLocalMsQuicVisible* + name: EnsureAppLocalMsQuicVisible + href: api/Titanium.Web.Proxy.Http3.Http3NativeBootstrap.html#Titanium_Web_Proxy_Http3_Http3NativeBootstrap_EnsureAppLocalMsQuicVisible_ + commentId: Overload:Titanium.Web.Proxy.Http3.Http3NativeBootstrap.EnsureAppLocalMsQuicVisible + isSpec: "True" + fullName: Titanium.Web.Proxy.Http3.Http3NativeBootstrap.EnsureAppLocalMsQuicVisible + nameWithType: Http3NativeBootstrap.EnsureAppLocalMsQuicVisible - uid: Titanium.Web.Proxy.Logging name: Titanium.Web.Proxy.Logging href: api/Titanium.Web.Proxy.Logging.html @@ -4951,6 +5112,182 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.Middleware.ProxyMiddlewarePipeline.InvokeEmptyAsync nameWithType: ProxyMiddlewarePipeline.InvokeEmptyAsync +- uid: Titanium.Web.Proxy.MitmExclusionDefaults + name: MitmExclusionDefaults + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html + commentId: T:Titanium.Web.Proxy.MitmExclusionDefaults + fullName: Titanium.Web.Proxy.MitmExclusionDefaults + nameWithType: MitmExclusionDefaults +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint,System.Func{System.Boolean}) + name: ApplyDecryptExclusions(ExplicitProxyEndPoint, Func) + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_ApplyDecryptExclusions_Titanium_Web_Proxy_Models_ExplicitProxyEndPoint_System_Func_System_Boolean__ + commentId: M:Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint,System.Func{System.Boolean}) + name.vb: ApplyDecryptExclusions(ExplicitProxyEndPoint, Func(Of Boolean)) + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint, System.Func) + fullName.vb: Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint, System.Func(Of Boolean)) + nameWithType: MitmExclusionDefaults.ApplyDecryptExclusions(ExplicitProxyEndPoint, Func) + nameWithType.vb: MitmExclusionDefaults.ApplyDecryptExclusions(ExplicitProxyEndPoint, Func(Of Boolean)) +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint,System.Func{System.Boolean},System.Collections.Generic.IEnumerable{System.String},System.Collections.Generic.IEnumerable{System.String}) + name: ApplyDecryptExclusions(ExplicitProxyEndPoint, Func, IEnumerable?, IEnumerable?) + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_ApplyDecryptExclusions_Titanium_Web_Proxy_Models_ExplicitProxyEndPoint_System_Func_System_Boolean__System_Collections_Generic_IEnumerable_System_String__System_Collections_Generic_IEnumerable_System_String__ + commentId: M:Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint,System.Func{System.Boolean},System.Collections.Generic.IEnumerable{System.String},System.Collections.Generic.IEnumerable{System.String}) + name.vb: ApplyDecryptExclusions(ExplicitProxyEndPoint, Func(Of Boolean), IEnumerable(Of String), IEnumerable(Of String)) + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint, System.Func, System.Collections.Generic.IEnumerable?, System.Collections.Generic.IEnumerable?) + fullName.vb: Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint, System.Func(Of Boolean), System.Collections.Generic.IEnumerable(Of String), System.Collections.Generic.IEnumerable(Of String)) + nameWithType: MitmExclusionDefaults.ApplyDecryptExclusions(ExplicitProxyEndPoint, Func, IEnumerable?, IEnumerable?) + nameWithType.vb: MitmExclusionDefaults.ApplyDecryptExclusions(ExplicitProxyEndPoint, Func(Of Boolean), IEnumerable(Of String), IEnumerable(Of String)) +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint,System.Func{System.Boolean},System.Collections.Generic.IEnumerable{System.String},System.Collections.Generic.IEnumerable{System.String},Titanium.Web.Proxy.MitmExclusionMode) + name: ApplyDecryptExclusions(ExplicitProxyEndPoint, Func, IEnumerable?, IEnumerable?, MitmExclusionMode) + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_ApplyDecryptExclusions_Titanium_Web_Proxy_Models_ExplicitProxyEndPoint_System_Func_System_Boolean__System_Collections_Generic_IEnumerable_System_String__System_Collections_Generic_IEnumerable_System_String__Titanium_Web_Proxy_MitmExclusionMode_ + commentId: M:Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint,System.Func{System.Boolean},System.Collections.Generic.IEnumerable{System.String},System.Collections.Generic.IEnumerable{System.String},Titanium.Web.Proxy.MitmExclusionMode) + name.vb: ApplyDecryptExclusions(ExplicitProxyEndPoint, Func(Of Boolean), IEnumerable(Of String), IEnumerable(Of String), MitmExclusionMode) + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint, System.Func, System.Collections.Generic.IEnumerable?, System.Collections.Generic.IEnumerable?, Titanium.Web.Proxy.MitmExclusionMode) + fullName.vb: Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint, System.Func(Of Boolean), System.Collections.Generic.IEnumerable(Of String), System.Collections.Generic.IEnumerable(Of String), Titanium.Web.Proxy.MitmExclusionMode) + nameWithType: MitmExclusionDefaults.ApplyDecryptExclusions(ExplicitProxyEndPoint, Func, IEnumerable?, IEnumerable?, MitmExclusionMode) + nameWithType.vb: MitmExclusionDefaults.ApplyDecryptExclusions(ExplicitProxyEndPoint, Func(Of Boolean), IEnumerable(Of String), IEnumerable(Of String), MitmExclusionMode) +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions* + name: ApplyDecryptExclusions + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_ApplyDecryptExclusions_ + commentId: Overload:Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions + isSpec: "True" + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions + nameWithType: MitmExclusionDefaults.ApplyDecryptExclusions +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings + name: CreateSystemProxySettings() + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_CreateSystemProxySettings + commentId: M:Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings() + nameWithType: MitmExclusionDefaults.CreateSystemProxySettings() +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings(System.Boolean) + name: CreateSystemProxySettings(bool) + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_CreateSystemProxySettings_System_Boolean_ + commentId: M:Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings(System.Boolean) + name.vb: CreateSystemProxySettings(Boolean) + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings(bool) + fullName.vb: Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings(Boolean) + nameWithType: MitmExclusionDefaults.CreateSystemProxySettings(bool) + nameWithType.vb: MitmExclusionDefaults.CreateSystemProxySettings(Boolean) +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings(System.Boolean,System.Collections.Generic.IEnumerable{System.String}) + name: CreateSystemProxySettings(bool, IEnumerable?) + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_CreateSystemProxySettings_System_Boolean_System_Collections_Generic_IEnumerable_System_String__ + commentId: M:Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings(System.Boolean,System.Collections.Generic.IEnumerable{System.String}) + name.vb: CreateSystemProxySettings(Boolean, IEnumerable(Of String)) + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings(bool, System.Collections.Generic.IEnumerable?) + fullName.vb: Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings(Boolean, System.Collections.Generic.IEnumerable(Of String)) + nameWithType: MitmExclusionDefaults.CreateSystemProxySettings(bool, IEnumerable?) + nameWithType.vb: MitmExclusionDefaults.CreateSystemProxySettings(Boolean, IEnumerable(Of String)) +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings(System.Boolean,System.Collections.Generic.IEnumerable{System.String},Titanium.Web.Proxy.MitmExclusionMode) + name: CreateSystemProxySettings(bool, IEnumerable?, MitmExclusionMode) + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_CreateSystemProxySettings_System_Boolean_System_Collections_Generic_IEnumerable_System_String__Titanium_Web_Proxy_MitmExclusionMode_ + commentId: M:Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings(System.Boolean,System.Collections.Generic.IEnumerable{System.String},Titanium.Web.Proxy.MitmExclusionMode) + name.vb: CreateSystemProxySettings(Boolean, IEnumerable(Of String), MitmExclusionMode) + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings(bool, System.Collections.Generic.IEnumerable?, Titanium.Web.Proxy.MitmExclusionMode) + fullName.vb: Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings(Boolean, System.Collections.Generic.IEnumerable(Of String), Titanium.Web.Proxy.MitmExclusionMode) + nameWithType: MitmExclusionDefaults.CreateSystemProxySettings(bool, IEnumerable?, MitmExclusionMode) + nameWithType.vb: MitmExclusionDefaults.CreateSystemProxySettings(Boolean, IEnumerable(Of String), MitmExclusionMode) +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings* + name: CreateSystemProxySettings + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_CreateSystemProxySettings_ + commentId: Overload:Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings + isSpec: "True" + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings + nameWithType: MitmExclusionDefaults.CreateSystemProxySettings +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.HostnameMatches(System.String,System.String) + name: HostnameMatches(string, string) + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_HostnameMatches_System_String_System_String_ + commentId: M:Titanium.Web.Proxy.MitmExclusionDefaults.HostnameMatches(System.String,System.String) + name.vb: HostnameMatches(String, String) + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.HostnameMatches(string, string) + fullName.vb: Titanium.Web.Proxy.MitmExclusionDefaults.HostnameMatches(String, String) + nameWithType: MitmExclusionDefaults.HostnameMatches(string, string) + nameWithType.vb: MitmExclusionDefaults.HostnameMatches(String, String) +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.HostnameMatches* + name: HostnameMatches + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_HostnameMatches_ + commentId: Overload:Titanium.Web.Proxy.MitmExclusionDefaults.HostnameMatches + isSpec: "True" + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.HostnameMatches + nameWithType: MitmExclusionDefaults.HostnameMatches +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.IsBuiltInSslBypass(System.String) + name: IsBuiltInSslBypass(string) + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_IsBuiltInSslBypass_System_String_ + commentId: M:Titanium.Web.Proxy.MitmExclusionDefaults.IsBuiltInSslBypass(System.String) + name.vb: IsBuiltInSslBypass(String) + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.IsBuiltInSslBypass(string) + fullName.vb: Titanium.Web.Proxy.MitmExclusionDefaults.IsBuiltInSslBypass(String) + nameWithType: MitmExclusionDefaults.IsBuiltInSslBypass(string) + nameWithType.vb: MitmExclusionDefaults.IsBuiltInSslBypass(String) +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.IsBuiltInSslBypass* + name: IsBuiltInSslBypass + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_IsBuiltInSslBypass_ + commentId: Overload:Titanium.Web.Proxy.MitmExclusionDefaults.IsBuiltInSslBypass + isSpec: "True" + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.IsBuiltInSslBypass + nameWithType: MitmExclusionDefaults.IsBuiltInSslBypass +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt(System.String) + name: ShouldDisableSslDecrypt(string?) + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_ShouldDisableSslDecrypt_System_String_ + commentId: M:Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt(System.String) + name.vb: ShouldDisableSslDecrypt(String) + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt(string?) + fullName.vb: Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt(String) + nameWithType: MitmExclusionDefaults.ShouldDisableSslDecrypt(string?) + nameWithType.vb: MitmExclusionDefaults.ShouldDisableSslDecrypt(String) +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt(System.String,System.Collections.Generic.IEnumerable{System.String},System.Collections.Generic.IEnumerable{System.String}) + name: ShouldDisableSslDecrypt(string?, IEnumerable?, IEnumerable?) + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_ShouldDisableSslDecrypt_System_String_System_Collections_Generic_IEnumerable_System_String__System_Collections_Generic_IEnumerable_System_String__ + commentId: M:Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt(System.String,System.Collections.Generic.IEnumerable{System.String},System.Collections.Generic.IEnumerable{System.String}) + name.vb: ShouldDisableSslDecrypt(String, IEnumerable(Of String), IEnumerable(Of String)) + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt(string?, System.Collections.Generic.IEnumerable?, System.Collections.Generic.IEnumerable?) + fullName.vb: Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt(String, System.Collections.Generic.IEnumerable(Of String), System.Collections.Generic.IEnumerable(Of String)) + nameWithType: MitmExclusionDefaults.ShouldDisableSslDecrypt(string?, IEnumerable?, IEnumerable?) + nameWithType.vb: MitmExclusionDefaults.ShouldDisableSslDecrypt(String, IEnumerable(Of String), IEnumerable(Of String)) +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt(System.String,System.Collections.Generic.IEnumerable{System.String},System.Collections.Generic.IEnumerable{System.String},Titanium.Web.Proxy.MitmExclusionMode) + name: ShouldDisableSslDecrypt(string?, IEnumerable?, IEnumerable?, MitmExclusionMode) + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_ShouldDisableSslDecrypt_System_String_System_Collections_Generic_IEnumerable_System_String__System_Collections_Generic_IEnumerable_System_String__Titanium_Web_Proxy_MitmExclusionMode_ + commentId: M:Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt(System.String,System.Collections.Generic.IEnumerable{System.String},System.Collections.Generic.IEnumerable{System.String},Titanium.Web.Proxy.MitmExclusionMode) + name.vb: ShouldDisableSslDecrypt(String, IEnumerable(Of String), IEnumerable(Of String), MitmExclusionMode) + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt(string?, System.Collections.Generic.IEnumerable?, System.Collections.Generic.IEnumerable?, Titanium.Web.Proxy.MitmExclusionMode) + fullName.vb: Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt(String, System.Collections.Generic.IEnumerable(Of String), System.Collections.Generic.IEnumerable(Of String), Titanium.Web.Proxy.MitmExclusionMode) + nameWithType: MitmExclusionDefaults.ShouldDisableSslDecrypt(string?, IEnumerable?, IEnumerable?, MitmExclusionMode) + nameWithType.vb: MitmExclusionDefaults.ShouldDisableSslDecrypt(String, IEnumerable(Of String), IEnumerable(Of String), MitmExclusionMode) +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt* + name: ShouldDisableSslDecrypt + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_ShouldDisableSslDecrypt_ + commentId: Overload:Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt + isSpec: "True" + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt + nameWithType: MitmExclusionDefaults.ShouldDisableSslDecrypt +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.SystemProxyBypassRules + name: SystemProxyBypassRules + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_SystemProxyBypassRules + commentId: F:Titanium.Web.Proxy.MitmExclusionDefaults.SystemProxyBypassRules + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.SystemProxyBypassRules + nameWithType: MitmExclusionDefaults.SystemProxyBypassRules +- uid: Titanium.Web.Proxy.MitmExclusionDefaults.TunnelOnlyPinningDomains + name: TunnelOnlyPinningDomains + href: api/Titanium.Web.Proxy.MitmExclusionDefaults.html#Titanium_Web_Proxy_MitmExclusionDefaults_TunnelOnlyPinningDomains + commentId: F:Titanium.Web.Proxy.MitmExclusionDefaults.TunnelOnlyPinningDomains + fullName: Titanium.Web.Proxy.MitmExclusionDefaults.TunnelOnlyPinningDomains + nameWithType: MitmExclusionDefaults.TunnelOnlyPinningDomains +- uid: Titanium.Web.Proxy.MitmExclusionMode + name: MitmExclusionMode + href: api/Titanium.Web.Proxy.MitmExclusionMode.html + commentId: T:Titanium.Web.Proxy.MitmExclusionMode + fullName: Titanium.Web.Proxy.MitmExclusionMode + nameWithType: MitmExclusionMode +- uid: Titanium.Web.Proxy.MitmExclusionMode.Merge + name: Merge + href: api/Titanium.Web.Proxy.MitmExclusionMode.html#Titanium_Web_Proxy_MitmExclusionMode_Merge + commentId: F:Titanium.Web.Proxy.MitmExclusionMode.Merge + fullName: Titanium.Web.Proxy.MitmExclusionMode.Merge + nameWithType: MitmExclusionMode.Merge +- uid: Titanium.Web.Proxy.MitmExclusionMode.Replace + name: Replace + href: api/Titanium.Web.Proxy.MitmExclusionMode.html#Titanium_Web_Proxy_MitmExclusionMode_Replace + commentId: F:Titanium.Web.Proxy.MitmExclusionMode.Replace + fullName: Titanium.Web.Proxy.MitmExclusionMode.Replace + nameWithType: MitmExclusionMode.Replace - uid: Titanium.Web.Proxy.Models name: Titanium.Web.Proxy.Models href: api/Titanium.Web.Proxy.Models.html @@ -6339,6 +6676,19 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.Network.CertificateManager.ApplyFastColdStartLeafSettings nameWithType: CertificateManager.ApplyFastColdStartLeafSettings +- uid: Titanium.Web.Proxy.Network.CertificateManager.AreInteractiveRootStoreMutationsSuppressed + name: AreInteractiveRootStoreMutationsSuppressed + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_AreInteractiveRootStoreMutationsSuppressed + commentId: P:Titanium.Web.Proxy.Network.CertificateManager.AreInteractiveRootStoreMutationsSuppressed + fullName: Titanium.Web.Proxy.Network.CertificateManager.AreInteractiveRootStoreMutationsSuppressed + nameWithType: CertificateManager.AreInteractiveRootStoreMutationsSuppressed +- uid: Titanium.Web.Proxy.Network.CertificateManager.AreInteractiveRootStoreMutationsSuppressed* + name: AreInteractiveRootStoreMutationsSuppressed + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_AreInteractiveRootStoreMutationsSuppressed_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateManager.AreInteractiveRootStoreMutationsSuppressed + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateManager.AreInteractiveRootStoreMutationsSuppressed + nameWithType: CertificateManager.AreInteractiveRootStoreMutationsSuppressed - uid: Titanium.Web.Proxy.Network.CertificateManager.CertificateCacheTimeOutMinutes name: CertificateCacheTimeOutMinutes href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_CertificateCacheTimeOutMinutes @@ -6497,6 +6847,19 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.Network.CertificateManager.EnsureRootCertificate nameWithType: CertificateManager.EnsureRootCertificate +- uid: Titanium.Web.Proxy.Network.CertificateManager.InstallNssCertutilAndRetryUserTrust + name: InstallNssCertutilAndRetryUserTrust() + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_InstallNssCertutilAndRetryUserTrust + commentId: M:Titanium.Web.Proxy.Network.CertificateManager.InstallNssCertutilAndRetryUserTrust + fullName: Titanium.Web.Proxy.Network.CertificateManager.InstallNssCertutilAndRetryUserTrust() + nameWithType: CertificateManager.InstallNssCertutilAndRetryUserTrust() +- uid: Titanium.Web.Proxy.Network.CertificateManager.InstallNssCertutilAndRetryUserTrust* + name: InstallNssCertutilAndRetryUserTrust + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_InstallNssCertutilAndRetryUserTrust_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateManager.InstallNssCertutilAndRetryUserTrust + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateManager.InstallNssCertutilAndRetryUserTrust + nameWithType: CertificateManager.InstallNssCertutilAndRetryUserTrust - uid: Titanium.Web.Proxy.Network.CertificateManager.IntermediateCertificates name: IntermediateCertificates href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_IntermediateCertificates @@ -6510,6 +6873,19 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.Network.CertificateManager.IntermediateCertificates nameWithType: CertificateManager.IntermediateCertificates +- uid: Titanium.Web.Proxy.Network.CertificateManager.IsOsRootStillPresent + name: IsOsRootStillPresent() + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_IsOsRootStillPresent + commentId: M:Titanium.Web.Proxy.Network.CertificateManager.IsOsRootStillPresent + fullName: Titanium.Web.Proxy.Network.CertificateManager.IsOsRootStillPresent() + nameWithType: CertificateManager.IsOsRootStillPresent() +- uid: Titanium.Web.Proxy.Network.CertificateManager.IsOsRootStillPresent* + name: IsOsRootStillPresent + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_IsOsRootStillPresent_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateManager.IsOsRootStillPresent + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateManager.IsOsRootStillPresent + nameWithType: CertificateManager.IsOsRootStillPresent - uid: Titanium.Web.Proxy.Network.CertificateManager.IsRootCertificateMachineTrusted name: IsRootCertificateMachineTrusted() href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_IsRootCertificateMachineTrusted @@ -6536,6 +6912,32 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.Network.CertificateManager.IsRootCertificateUserTrusted nameWithType: CertificateManager.IsRootCertificateUserTrusted +- uid: Titanium.Web.Proxy.Network.CertificateManager.IsRootInLoginKeychain + name: IsRootInLoginKeychain() + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_IsRootInLoginKeychain + commentId: M:Titanium.Web.Proxy.Network.CertificateManager.IsRootInLoginKeychain + fullName: Titanium.Web.Proxy.Network.CertificateManager.IsRootInLoginKeychain() + nameWithType: CertificateManager.IsRootInLoginKeychain() +- uid: Titanium.Web.Proxy.Network.CertificateManager.IsRootInLoginKeychain* + name: IsRootInLoginKeychain + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_IsRootInLoginKeychain_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateManager.IsRootInLoginKeychain + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateManager.IsRootInLoginKeychain + nameWithType: CertificateManager.IsRootInLoginKeychain +- uid: Titanium.Web.Proxy.Network.CertificateManager.LastOsTrustResult + name: LastOsTrustResult + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_LastOsTrustResult + commentId: P:Titanium.Web.Proxy.Network.CertificateManager.LastOsTrustResult + fullName: Titanium.Web.Proxy.Network.CertificateManager.LastOsTrustResult + nameWithType: CertificateManager.LastOsTrustResult +- uid: Titanium.Web.Proxy.Network.CertificateManager.LastOsTrustResult* + name: LastOsTrustResult + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_LastOsTrustResult_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateManager.LastOsTrustResult + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateManager.LastOsTrustResult + nameWithType: CertificateManager.LastOsTrustResult - uid: Titanium.Web.Proxy.Network.CertificateManager.LeafCertificateKeyAlgorithm name: LeafCertificateKeyAlgorithm href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_LeafCertificateKeyAlgorithm @@ -6584,6 +6986,19 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.Network.CertificateManager.LoadRootCertificate nameWithType: CertificateManager.LoadRootCertificate +- uid: Titanium.Web.Proxy.Network.CertificateManager.OpenMacKeychainGuidance + name: OpenMacKeychainGuidance() + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_OpenMacKeychainGuidance + commentId: M:Titanium.Web.Proxy.Network.CertificateManager.OpenMacKeychainGuidance + fullName: Titanium.Web.Proxy.Network.CertificateManager.OpenMacKeychainGuidance() + nameWithType: CertificateManager.OpenMacKeychainGuidance() +- uid: Titanium.Web.Proxy.Network.CertificateManager.OpenMacKeychainGuidance* + name: OpenMacKeychainGuidance + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_OpenMacKeychainGuidance_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateManager.OpenMacKeychainGuidance + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateManager.OpenMacKeychainGuidance + nameWithType: CertificateManager.OpenMacKeychainGuidance - uid: Titanium.Web.Proxy.Network.CertificateManager.OverwritePfxFile name: OverwritePfxFile href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_OverwritePfxFile @@ -6765,6 +7180,201 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.Network.CertificateManager.TrustRootCertificateAsAdmin nameWithType: CertificateManager.TrustRootCertificateAsAdmin +- uid: Titanium.Web.Proxy.Network.CertificateManager.VerifyOsUserSslTrust + name: VerifyOsUserSslTrust() + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_VerifyOsUserSslTrust + commentId: M:Titanium.Web.Proxy.Network.CertificateManager.VerifyOsUserSslTrust + fullName: Titanium.Web.Proxy.Network.CertificateManager.VerifyOsUserSslTrust() + nameWithType: CertificateManager.VerifyOsUserSslTrust() +- uid: Titanium.Web.Proxy.Network.CertificateManager.VerifyOsUserSslTrust* + name: VerifyOsUserSslTrust + href: api/Titanium.Web.Proxy.Network.CertificateManager.html#Titanium_Web_Proxy_Network_CertificateManager_VerifyOsUserSslTrust_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateManager.VerifyOsUserSslTrust + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateManager.VerifyOsUserSslTrust + nameWithType: CertificateManager.VerifyOsUserSslTrust +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustKind + name: CertificateOsTrustKind + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html + commentId: T:Titanium.Web.Proxy.Network.CertificateOsTrustKind + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustKind + nameWithType: CertificateOsTrustKind +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustKind.Cancelled + name: Cancelled + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html#Titanium_Web_Proxy_Network_CertificateOsTrustKind_Cancelled + commentId: F:Titanium.Web.Proxy.Network.CertificateOsTrustKind.Cancelled + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustKind.Cancelled + nameWithType: CertificateOsTrustKind.Cancelled +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustKind.CertutilMissing + name: CertutilMissing + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html#Titanium_Web_Proxy_Network_CertificateOsTrustKind_CertutilMissing + commentId: F:Titanium.Web.Proxy.Network.CertificateOsTrustKind.CertutilMissing + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustKind.CertutilMissing + nameWithType: CertificateOsTrustKind.CertutilMissing +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustKind.Failed + name: Failed + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html#Titanium_Web_Proxy_Network_CertificateOsTrustKind_Failed + commentId: F:Titanium.Web.Proxy.Network.CertificateOsTrustKind.Failed + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustKind.Failed + nameWithType: CertificateOsTrustKind.Failed +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustKind.HomebrewMissing + name: HomebrewMissing + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html#Titanium_Web_Proxy_Network_CertificateOsTrustKind_HomebrewMissing + commentId: F:Titanium.Web.Proxy.Network.CertificateOsTrustKind.HomebrewMissing + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustKind.HomebrewMissing + nameWithType: CertificateOsTrustKind.HomebrewMissing +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustKind.MacKeychainFailed + name: MacKeychainFailed + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html#Titanium_Web_Proxy_Network_CertificateOsTrustKind_MacKeychainFailed + commentId: F:Titanium.Web.Proxy.Network.CertificateOsTrustKind.MacKeychainFailed + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustKind.MacKeychainFailed + nameWithType: CertificateOsTrustKind.MacKeychainFailed +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustKind.MacNeedsManualTrustConfirm + name: MacNeedsManualTrustConfirm + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html#Titanium_Web_Proxy_Network_CertificateOsTrustKind_MacNeedsManualTrustConfirm + commentId: F:Titanium.Web.Proxy.Network.CertificateOsTrustKind.MacNeedsManualTrustConfirm + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustKind.MacNeedsManualTrustConfirm + nameWithType: CertificateOsTrustKind.MacNeedsManualTrustConfirm +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustKind.NssFailed + name: NssFailed + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html#Titanium_Web_Proxy_Network_CertificateOsTrustKind_NssFailed + commentId: F:Titanium.Web.Proxy.Network.CertificateOsTrustKind.NssFailed + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustKind.NssFailed + nameWithType: CertificateOsTrustKind.NssFailed +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustKind.Succeeded + name: Succeeded + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html#Titanium_Web_Proxy_Network_CertificateOsTrustKind_Succeeded + commentId: F:Titanium.Web.Proxy.Network.CertificateOsTrustKind.Succeeded + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustKind.Succeeded + nameWithType: CertificateOsTrustKind.Succeeded +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustKind.Unsupported + name: Unsupported + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustKind.html#Titanium_Web_Proxy_Network_CertificateOsTrustKind_Unsupported + commentId: F:Titanium.Web.Proxy.Network.CertificateOsTrustKind.Unsupported + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustKind.Unsupported + nameWithType: CertificateOsTrustKind.Unsupported +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult + name: CertificateOsTrustResult + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html + commentId: T:Titanium.Web.Proxy.Network.CertificateOsTrustResult + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult + nameWithType: CertificateOsTrustResult +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult.#ctor(Titanium.Web.Proxy.Network.CertificateOsTrustKind,System.String,System.String,System.Boolean) + name: CertificateOsTrustResult(CertificateOsTrustKind, string, string?, bool) + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html#Titanium_Web_Proxy_Network_CertificateOsTrustResult__ctor_Titanium_Web_Proxy_Network_CertificateOsTrustKind_System_String_System_String_System_Boolean_ + commentId: M:Titanium.Web.Proxy.Network.CertificateOsTrustResult.#ctor(Titanium.Web.Proxy.Network.CertificateOsTrustKind,System.String,System.String,System.Boolean) + name.vb: New(CertificateOsTrustKind, String, String, Boolean) + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult.CertificateOsTrustResult(Titanium.Web.Proxy.Network.CertificateOsTrustKind, string, string?, bool) + fullName.vb: Titanium.Web.Proxy.Network.CertificateOsTrustResult.New(Titanium.Web.Proxy.Network.CertificateOsTrustKind, String, String, Boolean) + nameWithType: CertificateOsTrustResult.CertificateOsTrustResult(CertificateOsTrustKind, string, string?, bool) + nameWithType.vb: CertificateOsTrustResult.New(CertificateOsTrustKind, String, String, Boolean) +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult.#ctor* + name: CertificateOsTrustResult + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html#Titanium_Web_Proxy_Network_CertificateOsTrustResult__ctor_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateOsTrustResult.#ctor + isSpec: "True" + name.vb: New + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult.CertificateOsTrustResult + fullName.vb: Titanium.Web.Proxy.Network.CertificateOsTrustResult.New + nameWithType: CertificateOsTrustResult.CertificateOsTrustResult + nameWithType.vb: CertificateOsTrustResult.New +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult.BrewAvailable + name: BrewAvailable + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html#Titanium_Web_Proxy_Network_CertificateOsTrustResult_BrewAvailable + commentId: P:Titanium.Web.Proxy.Network.CertificateOsTrustResult.BrewAvailable + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult.BrewAvailable + nameWithType: CertificateOsTrustResult.BrewAvailable +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult.BrewAvailable* + name: BrewAvailable + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html#Titanium_Web_Proxy_Network_CertificateOsTrustResult_BrewAvailable_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateOsTrustResult.BrewAvailable + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult.BrewAvailable + nameWithType: CertificateOsTrustResult.BrewAvailable +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Fail(Titanium.Web.Proxy.Network.CertificateOsTrustKind,System.String,System.String,System.Boolean) + name: Fail(CertificateOsTrustKind, string, string?, bool) + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html#Titanium_Web_Proxy_Network_CertificateOsTrustResult_Fail_Titanium_Web_Proxy_Network_CertificateOsTrustKind_System_String_System_String_System_Boolean_ + commentId: M:Titanium.Web.Proxy.Network.CertificateOsTrustResult.Fail(Titanium.Web.Proxy.Network.CertificateOsTrustKind,System.String,System.String,System.Boolean) + name.vb: Fail(CertificateOsTrustKind, String, String, Boolean) + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Fail(Titanium.Web.Proxy.Network.CertificateOsTrustKind, string, string?, bool) + fullName.vb: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Fail(Titanium.Web.Proxy.Network.CertificateOsTrustKind, String, String, Boolean) + nameWithType: CertificateOsTrustResult.Fail(CertificateOsTrustKind, string, string?, bool) + nameWithType.vb: CertificateOsTrustResult.Fail(CertificateOsTrustKind, String, String, Boolean) +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Fail* + name: Fail + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html#Titanium_Web_Proxy_Network_CertificateOsTrustResult_Fail_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateOsTrustResult.Fail + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Fail + nameWithType: CertificateOsTrustResult.Fail +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Kind + name: Kind + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html#Titanium_Web_Proxy_Network_CertificateOsTrustResult_Kind + commentId: P:Titanium.Web.Proxy.Network.CertificateOsTrustResult.Kind + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Kind + nameWithType: CertificateOsTrustResult.Kind +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Kind* + name: Kind + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html#Titanium_Web_Proxy_Network_CertificateOsTrustResult_Kind_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateOsTrustResult.Kind + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Kind + nameWithType: CertificateOsTrustResult.Kind +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Message + name: Message + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html#Titanium_Web_Proxy_Network_CertificateOsTrustResult_Message + commentId: P:Titanium.Web.Proxy.Network.CertificateOsTrustResult.Message + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Message + nameWithType: CertificateOsTrustResult.Message +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Message* + name: Message + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html#Titanium_Web_Proxy_Network_CertificateOsTrustResult_Message_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateOsTrustResult.Message + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Message + nameWithType: CertificateOsTrustResult.Message +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Ok(System.String) + name: Ok(string) + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html#Titanium_Web_Proxy_Network_CertificateOsTrustResult_Ok_System_String_ + commentId: M:Titanium.Web.Proxy.Network.CertificateOsTrustResult.Ok(System.String) + name.vb: Ok(String) + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Ok(string) + fullName.vb: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Ok(String) + nameWithType: CertificateOsTrustResult.Ok(string) + nameWithType.vb: CertificateOsTrustResult.Ok(String) +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Ok* + name: Ok + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html#Titanium_Web_Proxy_Network_CertificateOsTrustResult_Ok_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateOsTrustResult.Ok + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Ok + nameWithType: CertificateOsTrustResult.Ok +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult.PackageHint + name: PackageHint + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html#Titanium_Web_Proxy_Network_CertificateOsTrustResult_PackageHint + commentId: P:Titanium.Web.Proxy.Network.CertificateOsTrustResult.PackageHint + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult.PackageHint + nameWithType: CertificateOsTrustResult.PackageHint +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult.PackageHint* + name: PackageHint + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html#Titanium_Web_Proxy_Network_CertificateOsTrustResult_PackageHint_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateOsTrustResult.PackageHint + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult.PackageHint + nameWithType: CertificateOsTrustResult.PackageHint +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Succeeded + name: Succeeded + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html#Titanium_Web_Proxy_Network_CertificateOsTrustResult_Succeeded + commentId: P:Titanium.Web.Proxy.Network.CertificateOsTrustResult.Succeeded + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Succeeded + nameWithType: CertificateOsTrustResult.Succeeded +- uid: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Succeeded* + name: Succeeded + href: api/Titanium.Web.Proxy.Network.CertificateOsTrustResult.html#Titanium_Web_Proxy_Network_CertificateOsTrustResult_Succeeded_ + commentId: Overload:Titanium.Web.Proxy.Network.CertificateOsTrustResult.Succeeded + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.CertificateOsTrustResult.Succeeded + nameWithType: CertificateOsTrustResult.Succeeded - uid: Titanium.Web.Proxy.Network.DefaultCertificateDiskCache name: DefaultCertificateDiskCache href: api/Titanium.Web.Proxy.Network.DefaultCertificateDiskCache.html @@ -6877,6 +7487,138 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.Network.DefaultCertificateDiskCache.SaveRootCertificate nameWithType: DefaultCertificateDiskCache.SaveRootCertificate +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust + name: FirefoxCertificateTrust + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html + commentId: T:Titanium.Web.Proxy.Network.FirefoxCertificateTrust + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust + nameWithType: FirefoxCertificateTrust +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.IsFirefoxProcessRunning + name: IsFirefoxProcessRunning() + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_IsFirefoxProcessRunning + commentId: M:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.IsFirefoxProcessRunning + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.IsFirefoxProcessRunning() + nameWithType: FirefoxCertificateTrust.IsFirefoxProcessRunning() +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.IsFirefoxProcessRunning* + name: IsFirefoxProcessRunning + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_IsFirefoxProcessRunning_ + commentId: Overload:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.IsFirefoxProcessRunning + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.IsFirefoxProcessRunning + nameWithType: FirefoxCertificateTrust.IsFirefoxProcessRunning +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.IsFirefoxProfilePresent + name: IsFirefoxProfilePresent() + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_IsFirefoxProfilePresent + commentId: M:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.IsFirefoxProfilePresent + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.IsFirefoxProfilePresent() + nameWithType: FirefoxCertificateTrust.IsFirefoxProfilePresent() +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.IsFirefoxProfilePresent* + name: IsFirefoxProfilePresent + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_IsFirefoxProfilePresent_ + commentId: Overload:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.IsFirefoxProfilePresent + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.IsFirefoxProfilePresent + nameWithType: FirefoxCertificateTrust.IsFirefoxProfilePresent +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TrustDefaultProfile(System.Security.Cryptography.X509Certificates.X509Certificate2,System.String) + name: TrustDefaultProfile(X509Certificate2, string) + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_TrustDefaultProfile_System_Security_Cryptography_X509Certificates_X509Certificate2_System_String_ + commentId: M:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TrustDefaultProfile(System.Security.Cryptography.X509Certificates.X509Certificate2,System.String) + name.vb: TrustDefaultProfile(X509Certificate2, String) + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TrustDefaultProfile(System.Security.Cryptography.X509Certificates.X509Certificate2, string) + fullName.vb: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TrustDefaultProfile(System.Security.Cryptography.X509Certificates.X509Certificate2, String) + nameWithType: FirefoxCertificateTrust.TrustDefaultProfile(X509Certificate2, string) + nameWithType.vb: FirefoxCertificateTrust.TrustDefaultProfile(X509Certificate2, String) +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TrustDefaultProfile* + name: TrustDefaultProfile + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_TrustDefaultProfile_ + commentId: Overload:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TrustDefaultProfile + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TrustDefaultProfile + nameWithType: FirefoxCertificateTrust.TrustDefaultProfile +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryClearWindowsEnterpriseRoots + name: TryClearWindowsEnterpriseRoots() + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_TryClearWindowsEnterpriseRoots + commentId: M:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryClearWindowsEnterpriseRoots + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryClearWindowsEnterpriseRoots() + nameWithType: FirefoxCertificateTrust.TryClearWindowsEnterpriseRoots() +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryClearWindowsEnterpriseRoots* + name: TryClearWindowsEnterpriseRoots + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_TryClearWindowsEnterpriseRoots_ + commentId: Overload:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryClearWindowsEnterpriseRoots + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryClearWindowsEnterpriseRoots + nameWithType: FirefoxCertificateTrust.TryClearWindowsEnterpriseRoots +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryEnableEnterpriseRootsUserPref + name: TryEnableEnterpriseRootsUserPref() + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_TryEnableEnterpriseRootsUserPref + commentId: M:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryEnableEnterpriseRootsUserPref + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryEnableEnterpriseRootsUserPref() + nameWithType: FirefoxCertificateTrust.TryEnableEnterpriseRootsUserPref() +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryEnableEnterpriseRootsUserPref* + name: TryEnableEnterpriseRootsUserPref + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_TryEnableEnterpriseRootsUserPref_ + commentId: Overload:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryEnableEnterpriseRootsUserPref + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryEnableEnterpriseRootsUserPref + nameWithType: FirefoxCertificateTrust.TryEnableEnterpriseRootsUserPref +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryEnableWindowsEnterpriseRoots + name: TryEnableWindowsEnterpriseRoots() + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_TryEnableWindowsEnterpriseRoots + commentId: M:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryEnableWindowsEnterpriseRoots + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryEnableWindowsEnterpriseRoots() + nameWithType: FirefoxCertificateTrust.TryEnableWindowsEnterpriseRoots() +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryEnableWindowsEnterpriseRoots* + name: TryEnableWindowsEnterpriseRoots + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_TryEnableWindowsEnterpriseRoots_ + commentId: Overload:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryEnableWindowsEnterpriseRoots + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryEnableWindowsEnterpriseRoots + nameWithType: FirefoxCertificateTrust.TryEnableWindowsEnterpriseRoots +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryRequestFirefoxQuit(System.Nullable{System.TimeSpan}) + name: TryRequestFirefoxQuit(TimeSpan?) + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_TryRequestFirefoxQuit_System_Nullable_System_TimeSpan__ + commentId: M:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryRequestFirefoxQuit(System.Nullable{System.TimeSpan}) + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryRequestFirefoxQuit(System.TimeSpan?) + nameWithType: FirefoxCertificateTrust.TryRequestFirefoxQuit(TimeSpan?) +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryRequestFirefoxQuit* + name: TryRequestFirefoxQuit + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_TryRequestFirefoxQuit_ + commentId: Overload:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryRequestFirefoxQuit + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryRequestFirefoxQuit + nameWithType: FirefoxCertificateTrust.TryRequestFirefoxQuit +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryResolveDefaultProfileDirectory(System.String@,System.String@) + name: TryResolveDefaultProfileDirectory(out string, out string?) + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_TryResolveDefaultProfileDirectory_System_String__System_String__ + commentId: M:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryResolveDefaultProfileDirectory(System.String@,System.String@) + name.vb: TryResolveDefaultProfileDirectory(String, String) + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryResolveDefaultProfileDirectory(out string, out string?) + fullName.vb: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryResolveDefaultProfileDirectory(String, String) + nameWithType: FirefoxCertificateTrust.TryResolveDefaultProfileDirectory(out string, out string?) + nameWithType.vb: FirefoxCertificateTrust.TryResolveDefaultProfileDirectory(String, String) +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryResolveDefaultProfileDirectory* + name: TryResolveDefaultProfileDirectory + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_TryResolveDefaultProfileDirectory_ + commentId: Overload:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryResolveDefaultProfileDirectory + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryResolveDefaultProfileDirectory + nameWithType: FirefoxCertificateTrust.TryResolveDefaultProfileDirectory +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.UntrustDefaultProfile(System.String) + name: UntrustDefaultProfile(string) + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_UntrustDefaultProfile_System_String_ + commentId: M:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.UntrustDefaultProfile(System.String) + name.vb: UntrustDefaultProfile(String) + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.UntrustDefaultProfile(string) + fullName.vb: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.UntrustDefaultProfile(String) + nameWithType: FirefoxCertificateTrust.UntrustDefaultProfile(string) + nameWithType.vb: FirefoxCertificateTrust.UntrustDefaultProfile(String) +- uid: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.UntrustDefaultProfile* + name: UntrustDefaultProfile + href: api/Titanium.Web.Proxy.Network.FirefoxCertificateTrust.html#Titanium_Web_Proxy_Network_FirefoxCertificateTrust_UntrustDefaultProfile_ + commentId: Overload:Titanium.Web.Proxy.Network.FirefoxCertificateTrust.UntrustDefaultProfile + isSpec: "True" + fullName: Titanium.Web.Proxy.Network.FirefoxCertificateTrust.UntrustDefaultProfile + nameWithType: FirefoxCertificateTrust.UntrustDefaultProfile - uid: Titanium.Web.Proxy.Network.ICertificateCache name: ICertificateCache href: api/Titanium.Web.Proxy.Network.ICertificateCache.html @@ -8539,6 +9281,19 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.ProxyServer.IdleWriteTimeoutSeconds nameWithType: ProxyServer.IdleWriteTimeoutSeconds +- uid: Titanium.Web.Proxy.ProxyServer.IgnoreServerCertificateErrors + name: IgnoreServerCertificateErrors + href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_IgnoreServerCertificateErrors + commentId: P:Titanium.Web.Proxy.ProxyServer.IgnoreServerCertificateErrors + fullName: Titanium.Web.Proxy.ProxyServer.IgnoreServerCertificateErrors + nameWithType: ProxyServer.IgnoreServerCertificateErrors +- uid: Titanium.Web.Proxy.ProxyServer.IgnoreServerCertificateErrors* + name: IgnoreServerCertificateErrors + href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_IgnoreServerCertificateErrors_ + commentId: Overload:Titanium.Web.Proxy.ProxyServer.IgnoreServerCertificateErrors + isSpec: "True" + fullName: Titanium.Web.Proxy.ProxyServer.IgnoreServerCertificateErrors + nameWithType: ProxyServer.IgnoreServerCertificateErrors - uid: Titanium.Web.Proxy.ProxyServer.ListenerBackLog name: ListenerBackLog href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_ListenerBackLog @@ -9122,6 +9877,32 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.ProxyServer.ThreadPoolWorkerThread nameWithType: ProxyServer.ThreadPoolWorkerThread +- uid: Titanium.Web.Proxy.ProxyServer.TryDisableAllSystemProxies + name: TryDisableAllSystemProxies() + href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_TryDisableAllSystemProxies + commentId: M:Titanium.Web.Proxy.ProxyServer.TryDisableAllSystemProxies + fullName: Titanium.Web.Proxy.ProxyServer.TryDisableAllSystemProxies() + nameWithType: ProxyServer.TryDisableAllSystemProxies() +- uid: Titanium.Web.Proxy.ProxyServer.TryDisableAllSystemProxies* + name: TryDisableAllSystemProxies + href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_TryDisableAllSystemProxies_ + commentId: Overload:Titanium.Web.Proxy.ProxyServer.TryDisableAllSystemProxies + isSpec: "True" + fullName: Titanium.Web.Proxy.ProxyServer.TryDisableAllSystemProxies + nameWithType: ProxyServer.TryDisableAllSystemProxies +- uid: Titanium.Web.Proxy.ProxyServer.TryDisableSystemProxy(Titanium.Web.Proxy.Models.ProxyProtocolType) + name: TryDisableSystemProxy(ProxyProtocolType) + href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_TryDisableSystemProxy_Titanium_Web_Proxy_Models_ProxyProtocolType_ + commentId: M:Titanium.Web.Proxy.ProxyServer.TryDisableSystemProxy(Titanium.Web.Proxy.Models.ProxyProtocolType) + fullName: Titanium.Web.Proxy.ProxyServer.TryDisableSystemProxy(Titanium.Web.Proxy.Models.ProxyProtocolType) + nameWithType: ProxyServer.TryDisableSystemProxy(ProxyProtocolType) +- uid: Titanium.Web.Proxy.ProxyServer.TryDisableSystemProxy* + name: TryDisableSystemProxy + href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_TryDisableSystemProxy_ + commentId: Overload:Titanium.Web.Proxy.ProxyServer.TryDisableSystemProxy + isSpec: "True" + fullName: Titanium.Web.Proxy.ProxyServer.TryDisableSystemProxy + nameWithType: ProxyServer.TryDisableSystemProxy - uid: Titanium.Web.Proxy.ProxyServer.TryEnableHttp3IfSupported name: TryEnableHttp3IfSupported() href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_TryEnableHttp3IfSupported @@ -9135,6 +9916,35 @@ references: isSpec: "True" fullName: Titanium.Web.Proxy.ProxyServer.TryEnableHttp3IfSupported nameWithType: ProxyServer.TryEnableHttp3IfSupported +- uid: Titanium.Web.Proxy.ProxyServer.TryRestoreOriginalProxySettings + name: TryRestoreOriginalProxySettings() + href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_TryRestoreOriginalProxySettings + commentId: M:Titanium.Web.Proxy.ProxyServer.TryRestoreOriginalProxySettings + fullName: Titanium.Web.Proxy.ProxyServer.TryRestoreOriginalProxySettings() + nameWithType: ProxyServer.TryRestoreOriginalProxySettings() +- uid: Titanium.Web.Proxy.ProxyServer.TryRestoreOriginalProxySettings* + name: TryRestoreOriginalProxySettings + href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_TryRestoreOriginalProxySettings_ + commentId: Overload:Titanium.Web.Proxy.ProxyServer.TryRestoreOriginalProxySettings + isSpec: "True" + fullName: Titanium.Web.Proxy.ProxyServer.TryRestoreOriginalProxySettings + nameWithType: ProxyServer.TryRestoreOriginalProxySettings +- uid: Titanium.Web.Proxy.ProxyServer.TrySetAsSystemProxy(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint,Titanium.Web.Proxy.Models.ProxyProtocolType,Titanium.Web.Proxy.SystemProxySettings) + name: TrySetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) + href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_TrySetAsSystemProxy_Titanium_Web_Proxy_Models_ExplicitProxyEndPoint_Titanium_Web_Proxy_Models_ProxyProtocolType_Titanium_Web_Proxy_SystemProxySettings_ + commentId: M:Titanium.Web.Proxy.ProxyServer.TrySetAsSystemProxy(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint,Titanium.Web.Proxy.Models.ProxyProtocolType,Titanium.Web.Proxy.SystemProxySettings) + name.vb: TrySetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings) + fullName: Titanium.Web.Proxy.ProxyServer.TrySetAsSystemProxy(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint, Titanium.Web.Proxy.Models.ProxyProtocolType, Titanium.Web.Proxy.SystemProxySettings?) + fullName.vb: Titanium.Web.Proxy.ProxyServer.TrySetAsSystemProxy(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint, Titanium.Web.Proxy.Models.ProxyProtocolType, Titanium.Web.Proxy.SystemProxySettings) + nameWithType: ProxyServer.TrySetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings?) + nameWithType.vb: ProxyServer.TrySetAsSystemProxy(ExplicitProxyEndPoint, ProxyProtocolType, SystemProxySettings) +- uid: Titanium.Web.Proxy.ProxyServer.TrySetAsSystemProxy* + name: TrySetAsSystemProxy + href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_TrySetAsSystemProxy_ + commentId: Overload:Titanium.Web.Proxy.ProxyServer.TrySetAsSystemProxy + isSpec: "True" + fullName: Titanium.Web.Proxy.ProxyServer.TrySetAsSystemProxy + nameWithType: ProxyServer.TrySetAsSystemProxy - uid: Titanium.Web.Proxy.ProxyServer.UpStreamEndPoint name: UpStreamEndPoint href: api/Titanium.Web.Proxy.ProxyServer.html#Titanium_Web_Proxy_ProxyServer_UpStreamEndPoint @@ -10072,6 +10882,89 @@ references: commentId: F:Titanium.Web.Proxy.SystemProxyBypassRuleMode.Replace fullName: Titanium.Web.Proxy.SystemProxyBypassRuleMode.Replace nameWithType: SystemProxyBypassRuleMode.Replace +- uid: Titanium.Web.Proxy.SystemProxyChangeResult + name: SystemProxyChangeResult + href: api/Titanium.Web.Proxy.SystemProxyChangeResult.html + commentId: T:Titanium.Web.Proxy.SystemProxyChangeResult + fullName: Titanium.Web.Proxy.SystemProxyChangeResult + nameWithType: SystemProxyChangeResult +- uid: Titanium.Web.Proxy.SystemProxyChangeResult.#ctor(System.Boolean,System.String) + name: SystemProxyChangeResult(bool, string) + href: api/Titanium.Web.Proxy.SystemProxyChangeResult.html#Titanium_Web_Proxy_SystemProxyChangeResult__ctor_System_Boolean_System_String_ + commentId: M:Titanium.Web.Proxy.SystemProxyChangeResult.#ctor(System.Boolean,System.String) + name.vb: New(Boolean, String) + fullName: Titanium.Web.Proxy.SystemProxyChangeResult.SystemProxyChangeResult(bool, string) + fullName.vb: Titanium.Web.Proxy.SystemProxyChangeResult.New(Boolean, String) + nameWithType: SystemProxyChangeResult.SystemProxyChangeResult(bool, string) + nameWithType.vb: SystemProxyChangeResult.New(Boolean, String) +- uid: Titanium.Web.Proxy.SystemProxyChangeResult.#ctor* + name: SystemProxyChangeResult + href: api/Titanium.Web.Proxy.SystemProxyChangeResult.html#Titanium_Web_Proxy_SystemProxyChangeResult__ctor_ + commentId: Overload:Titanium.Web.Proxy.SystemProxyChangeResult.#ctor + isSpec: "True" + name.vb: New + fullName: Titanium.Web.Proxy.SystemProxyChangeResult.SystemProxyChangeResult + fullName.vb: Titanium.Web.Proxy.SystemProxyChangeResult.New + nameWithType: SystemProxyChangeResult.SystemProxyChangeResult + nameWithType.vb: SystemProxyChangeResult.New +- uid: Titanium.Web.Proxy.SystemProxyChangeResult.Fail(System.String) + name: Fail(string) + href: api/Titanium.Web.Proxy.SystemProxyChangeResult.html#Titanium_Web_Proxy_SystemProxyChangeResult_Fail_System_String_ + commentId: M:Titanium.Web.Proxy.SystemProxyChangeResult.Fail(System.String) + name.vb: Fail(String) + fullName: Titanium.Web.Proxy.SystemProxyChangeResult.Fail(string) + fullName.vb: Titanium.Web.Proxy.SystemProxyChangeResult.Fail(String) + nameWithType: SystemProxyChangeResult.Fail(string) + nameWithType.vb: SystemProxyChangeResult.Fail(String) +- uid: Titanium.Web.Proxy.SystemProxyChangeResult.Fail* + name: Fail + href: api/Titanium.Web.Proxy.SystemProxyChangeResult.html#Titanium_Web_Proxy_SystemProxyChangeResult_Fail_ + commentId: Overload:Titanium.Web.Proxy.SystemProxyChangeResult.Fail + isSpec: "True" + fullName: Titanium.Web.Proxy.SystemProxyChangeResult.Fail + nameWithType: SystemProxyChangeResult.Fail +- uid: Titanium.Web.Proxy.SystemProxyChangeResult.Message + name: Message + href: api/Titanium.Web.Proxy.SystemProxyChangeResult.html#Titanium_Web_Proxy_SystemProxyChangeResult_Message + commentId: P:Titanium.Web.Proxy.SystemProxyChangeResult.Message + fullName: Titanium.Web.Proxy.SystemProxyChangeResult.Message + nameWithType: SystemProxyChangeResult.Message +- uid: Titanium.Web.Proxy.SystemProxyChangeResult.Message* + name: Message + href: api/Titanium.Web.Proxy.SystemProxyChangeResult.html#Titanium_Web_Proxy_SystemProxyChangeResult_Message_ + commentId: Overload:Titanium.Web.Proxy.SystemProxyChangeResult.Message + isSpec: "True" + fullName: Titanium.Web.Proxy.SystemProxyChangeResult.Message + nameWithType: SystemProxyChangeResult.Message +- uid: Titanium.Web.Proxy.SystemProxyChangeResult.Ok(System.String) + name: Ok(string) + href: api/Titanium.Web.Proxy.SystemProxyChangeResult.html#Titanium_Web_Proxy_SystemProxyChangeResult_Ok_System_String_ + commentId: M:Titanium.Web.Proxy.SystemProxyChangeResult.Ok(System.String) + name.vb: Ok(String) + fullName: Titanium.Web.Proxy.SystemProxyChangeResult.Ok(string) + fullName.vb: Titanium.Web.Proxy.SystemProxyChangeResult.Ok(String) + nameWithType: SystemProxyChangeResult.Ok(string) + nameWithType.vb: SystemProxyChangeResult.Ok(String) +- uid: Titanium.Web.Proxy.SystemProxyChangeResult.Ok* + name: Ok + href: api/Titanium.Web.Proxy.SystemProxyChangeResult.html#Titanium_Web_Proxy_SystemProxyChangeResult_Ok_ + commentId: Overload:Titanium.Web.Proxy.SystemProxyChangeResult.Ok + isSpec: "True" + fullName: Titanium.Web.Proxy.SystemProxyChangeResult.Ok + nameWithType: SystemProxyChangeResult.Ok +- uid: Titanium.Web.Proxy.SystemProxyChangeResult.Succeeded + name: Succeeded + href: api/Titanium.Web.Proxy.SystemProxyChangeResult.html#Titanium_Web_Proxy_SystemProxyChangeResult_Succeeded + commentId: P:Titanium.Web.Proxy.SystemProxyChangeResult.Succeeded + fullName: Titanium.Web.Proxy.SystemProxyChangeResult.Succeeded + nameWithType: SystemProxyChangeResult.Succeeded +- uid: Titanium.Web.Proxy.SystemProxyChangeResult.Succeeded* + name: Succeeded + href: api/Titanium.Web.Proxy.SystemProxyChangeResult.html#Titanium_Web_Proxy_SystemProxyChangeResult_Succeeded_ + commentId: Overload:Titanium.Web.Proxy.SystemProxyChangeResult.Succeeded + isSpec: "True" + fullName: Titanium.Web.Proxy.SystemProxyChangeResult.Succeeded + nameWithType: SystemProxyChangeResult.Succeeded - uid: Titanium.Web.Proxy.SystemProxyLoopbackPlacement name: SystemProxyLoopbackPlacement href: api/Titanium.Web.Proxy.SystemProxyLoopbackPlacement.html @@ -10096,6 +10989,22 @@ references: commentId: T:Titanium.Web.Proxy.SystemProxySettings fullName: Titanium.Web.Proxy.SystemProxySettings nameWithType: SystemProxySettings +- uid: Titanium.Web.Proxy.SystemProxySettings.BuildProxyOverride(System.String) + name: BuildProxyOverride(string?) + href: api/Titanium.Web.Proxy.SystemProxySettings.html#Titanium_Web_Proxy_SystemProxySettings_BuildProxyOverride_System_String_ + commentId: M:Titanium.Web.Proxy.SystemProxySettings.BuildProxyOverride(System.String) + name.vb: BuildProxyOverride(String) + fullName: Titanium.Web.Proxy.SystemProxySettings.BuildProxyOverride(string?) + fullName.vb: Titanium.Web.Proxy.SystemProxySettings.BuildProxyOverride(String) + nameWithType: SystemProxySettings.BuildProxyOverride(string?) + nameWithType.vb: SystemProxySettings.BuildProxyOverride(String) +- uid: Titanium.Web.Proxy.SystemProxySettings.BuildProxyOverride* + name: BuildProxyOverride + href: api/Titanium.Web.Proxy.SystemProxySettings.html#Titanium_Web_Proxy_SystemProxySettings_BuildProxyOverride_ + commentId: Overload:Titanium.Web.Proxy.SystemProxySettings.BuildProxyOverride + isSpec: "True" + fullName: Titanium.Web.Proxy.SystemProxySettings.BuildProxyOverride + nameWithType: SystemProxySettings.BuildProxyOverride - uid: Titanium.Web.Proxy.SystemProxySettings.BypassRuleMode name: BypassRuleMode href: api/Titanium.Web.Proxy.SystemProxySettings.html#Titanium_Web_Proxy_SystemProxySettings_BypassRuleMode diff --git a/examples/Titanium.Web.Proxy.Examples.Shared/KnownMitmExclusions.cs b/examples/Titanium.Web.Proxy.Examples.Shared/KnownMitmExclusions.cs index c14a2e8ca..698a1cf05 100644 --- a/examples/Titanium.Web.Proxy.Examples.Shared/KnownMitmExclusions.cs +++ b/examples/Titanium.Web.Proxy.Examples.Shared/KnownMitmExclusions.cs @@ -1,88 +1,34 @@ using System; +using System.Linq; using Titanium.Web.Proxy; namespace Titanium.Web.Proxy.Examples.Shared; /// -/// Shared demo exclusions for hosts that commonly break under MITM when the examples are -/// installed as the Windows system proxy. +/// Shared demo exclusions — delegates to . /// -/// -/// -/// System proxy bypass is used for Microsoft identity endpoints (Entra / WAM / RDP -/// auth error 0xcaa30194): those clients often fail even with an opaque CONNECT tunnel. -/// -/// -/// DecryptSsl = false (passthrough) is used for classic certificate-pinning demos -/// (Dropbox, Webex) and also for identity hosts if a client still CONNECTs through the proxy. -/// Passthrough keeps the tunnel visible in example traffic logs. -/// -/// public static class KnownMitmExclusions { - /// - /// WinINET bypass patterns for Microsoft identity endpoints. - /// - public static readonly string[] SystemProxyBypassRules = - { - "*.microsoftonline.com", - "*.microsoftonline-p.com", - "login.windows.net", - "*.login.microsoft.com", - "login.live.com", - "account.live.com", - "*.msauth.net", - "*.msftauth.net", - "enterpriseregistration.windows.net" - }; + public static string[] SystemProxyBypassRules => MitmExclusionDefaults.SystemProxyBypassRules; - /// - /// Builds with identity bypass rules, then runs - /// for example-specific options (e.g. ProxyLoopback). - /// public static SystemProxySettings CreateSystemProxySettings(Action? configure = null) { - var settings = new SystemProxySettings(); - foreach (var rule in SystemProxyBypassRules) - settings.BypassRules.Add(rule); - + var settings = MitmExclusionDefaults.CreateSystemProxySettings(); configure?.Invoke(settings); return settings; } - /// - /// Returns true when CONNECT tunnels for should use SSL - /// passthrough (DecryptSsl = false) instead of MITM. - /// - public static bool ShouldDisableSslDecrypt(string? hostname) - { - if (string.IsNullOrEmpty(hostname)) - return false; - - return IsMicrosoftIdentityHost(hostname) || - HostMatchesDomain(hostname, "dropbox.com") || - HostMatchesDomain(hostname, "webex.com"); - } + public static bool ShouldDisableSslDecrypt(string? hostname) => + MitmExclusionDefaults.ShouldDisableSslDecrypt(hostname); public static bool IsMicrosoftIdentityHost(string? hostname) { if (string.IsNullOrEmpty(hostname)) + { return false; + } - return HostMatchesDomain(hostname, "microsoftonline.com") || - HostMatchesDomain(hostname, "microsoftonline-p.com") || - hostname.Equals("login.windows.net", StringComparison.OrdinalIgnoreCase) || - HostMatchesDomain(hostname, "login.microsoft.com") || - hostname.Equals("login.live.com", StringComparison.OrdinalIgnoreCase) || - hostname.Equals("account.live.com", StringComparison.OrdinalIgnoreCase) || - HostMatchesDomain(hostname, "msauth.net") || - HostMatchesDomain(hostname, "msftauth.net") || - hostname.Equals("enterpriseregistration.windows.net", StringComparison.OrdinalIgnoreCase); - } - - private static bool HostMatchesDomain(string hostname, string domain) - { - return hostname.Equals(domain, StringComparison.OrdinalIgnoreCase) || - hostname.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase); + return MitmExclusionDefaults.SystemProxyBypassRules.Any(rule => + MitmExclusionDefaults.HostnameMatches(hostname, rule)); } } diff --git a/examples/Titanium.Web.Proxy.Examples.WindowsService/ProxyWorker.cs b/examples/Titanium.Web.Proxy.Examples.WindowsService/ProxyWorker.cs index 4216ff453..1b922a33b 100644 --- a/examples/Titanium.Web.Proxy.Examples.WindowsService/ProxyWorker.cs +++ b/examples/Titanium.Web.Proxy.Examples.WindowsService/ProxyWorker.cs @@ -161,17 +161,17 @@ public override Task StartAsync(CancellationToken cancellationToken) if (settings.SetAsSystemProxy) { - try + var result = proxyServer.TrySetAsSystemProxy(explicitEndPointV4, ProxyProtocolType.AllHttp, + KnownMitmExclusions.CreateSystemProxySettings()); + if (result.Succeeded) { - proxyServer.SetAsSystemProxy(explicitEndPointV4, ProxyProtocolType.AllHttp, - KnownMitmExclusions.CreateSystemProxySettings()); logger.LogInformation( "Registered as Windows system proxy on port {ListeningPort} with identity host bypass (cleared on stop)", explicitEndPointV4.Port); } - catch (NotSupportedException ex) + else { - logger.LogWarning(ex, "SetAsSystemProxy is enabled but system proxy is not supported on this platform"); + logger.LogWarning("SetAsSystemProxy failed: {Message}", result.Message); } } diff --git a/src/Titanium.Cli/AccessLog/JsonAccessLogWriter.cs b/src/Titanium.Cli/AccessLog/JsonAccessLogWriter.cs new file mode 100644 index 000000000..94d42b620 --- /dev/null +++ b/src/Titanium.Cli/AccessLog/JsonAccessLogWriter.cs @@ -0,0 +1,113 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using Titanium.Web.Proxy.EventArguments; + +namespace Titanium.Cli.AccessLog; + +/// Appends one JSON object per completed session (opt-in; null writer = no-op). +public sealed class JsonAccessLogWriter : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false }; + private readonly StreamWriter _writer; + private readonly double _sampleRate; + private readonly object _gate = new(); + + public JsonAccessLogWriter(string path, double sampleRate = 1.0) + { + var dir = Path.GetDirectoryName(Path.GetFullPath(path)); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + _writer = new StreamWriter( + new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)) + { + AutoFlush = true, + }; + _sampleRate = sampleRate <= 0 ? 0 : Math.Clamp(sampleRate, 0, 1); + } + + public static string FormatRecord( + string? method, + string? url, + string? host, + int status, + double? durationMs, + string? clientIp) + { + var record = new Dictionary + { + ["ts"] = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture), + ["method"] = method, + ["url"] = url, + ["host"] = host, + ["status"] = status, + ["durationMs"] = durationMs.HasValue ? Math.Round(durationMs.Value, 3) : null, + ["clientIp"] = clientIp, + }; + return JsonSerializer.Serialize(record, JsonOptions); + } + + public static string FormatLine(SessionEventArgs session) + { + var req = session.HttpClient.Request; + var resp = session.HttpClient.Response; + var durationMs = session.Timing?.TotalDuration.TotalMilliseconds; + return FormatRecord( + req.Method, + req.RequestUri?.ToString() ?? req.Url, + req.Host ?? req.RequestUri?.Host, + resp.StatusCode, + durationMs, + session.ClientRemoteEndPoint.Address.ToString()); + } + + public void TryWrite(SessionEventArgs session) + { + if (!ShouldSample()) + { + return; + } + + WriteLine(FormatLine(session)); + } + + public void TryWriteRecord( + string? method, + string? url, + string? host, + int status, + double? durationMs, + string? clientIp) + { + if (!ShouldSample()) + { + return; + } + + WriteLine(FormatRecord(method, url, host, status, durationMs, clientIp)); + } + + private bool ShouldSample() + { + if (_sampleRate <= 0) + { + return false; + } + + return _sampleRate >= 1.0 || Random.Shared.NextDouble() <= _sampleRate; + } + + private void WriteLine(string line) + { + lock (_gate) + { + _writer.WriteLine(line); + } + } + + public void Dispose() => _writer.Dispose(); +} diff --git a/src/Titanium.Cli/Certificates/CertificateBootstrap.cs b/src/Titanium.Cli/Certificates/CertificateBootstrap.cs index aa9f4b5b7..fa90b72c0 100644 --- a/src/Titanium.Cli/Certificates/CertificateBootstrap.cs +++ b/src/Titanium.Cli/Certificates/CertificateBootstrap.cs @@ -294,16 +294,23 @@ private static bool TryLoadLeaf(string certPath, string? keyPath, out X509Certif ext.Equals(".p12", StringComparison.OrdinalIgnoreCase)) { var password = Environment.GetEnvironmentVariable("TITANIUM_CERT_PASSWORD"); + // Exportable (not EphemeralKeySet): Windows Schannel needs a usable private key + // for SslStream.AuthenticateAsServer on TLS-terminate listeners. leaf = X509CertificateLoader.LoadPkcs12( File.ReadAllBytes(certPath), password, - X509KeyStorageFlags.EphemeralKeySet); + X509KeyStorageFlags.Exportable); return true; } if (!string.IsNullOrEmpty(keyPath) && File.Exists(keyPath)) { - leaf = X509Certificate2.CreateFromPemFile(certPath, keyPath); + using var pem = X509Certificate2.CreateFromPemFile(certPath, keyPath); + // Re-wrap as PKCS#12 with Exportable so Schannel can present the leaf. + leaf = X509CertificateLoader.LoadPkcs12( + pem.Export(X509ContentType.Pfx), + password: null, + X509KeyStorageFlags.Exportable); return true; } diff --git a/src/Titanium.Cli/CliHelp.cs b/src/Titanium.Cli/CliHelp.cs new file mode 100644 index 000000000..9ff4d00ff --- /dev/null +++ b/src/Titanium.Cli/CliHelp.cs @@ -0,0 +1,27 @@ +namespace Titanium.Cli; + +/// Shared argv helpers for nested help / -h / --help. +internal static class CliHelp +{ + public const string DocsUrl = "https://titaniumproxy.com/docs/cli"; // NOSONAR S1075 -- Published CLI docs URL. + + public static bool IsHelpToken(string? arg) => + arg is "help" or "-h" or "--help"; + + /// True when any remaining argv token is help (before required-flag parsing). + public static bool RequestsHelp(ReadOnlySpan args) + { + foreach (var a in args) + { + if (IsHelpToken(a)) + { + return true; + } + } + + return false; + } + + public static void WriteDocsFooter() => + AsyncConsole.WriteLine($"Docs: {DocsUrl}"); +} diff --git a/src/Titanium.Cli/Config/PlusLoader.cs b/src/Titanium.Cli/Config/PlusLoader.cs index 8e999218a..05a931de0 100644 --- a/src/Titanium.Cli/Config/PlusLoader.cs +++ b/src/Titanium.Cli/Config/PlusLoader.cs @@ -36,6 +36,12 @@ internal static class PlusLoader continue; } + // Only modules with a public parameterless ctor (skip accidental assignable types). + if (type.GetConstructor(Type.EmptyTypes) is null) + { + continue; + } + if (Activator.CreateInstance(type) is not ITitaniumPlusModule module) { continue; diff --git a/src/Titanium.Cli/Config/RunCommand.cs b/src/Titanium.Cli/Config/RunCommand.cs index abf482de7..d73e87da7 100644 --- a/src/Titanium.Cli/Config/RunCommand.cs +++ b/src/Titanium.Cli/Config/RunCommand.cs @@ -1,5 +1,7 @@ using System.Net; +using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; +using Titanium.Cli.AccessLog; using Titanium.Cli; using Titanium.Cli.Certificates; using Titanium.Cli.Parsers; @@ -22,7 +24,41 @@ namespace Titanium.Cli.Config; internal static class RunCommand { - public static async Task ExecuteAsync(string configPath, bool verbose = false) + private static readonly string[] PlusRelativePathKeys = + [ + "grpc.transcode.descriptorSet", + "waf.rulesFile", + "discovery.file", + ]; + + public static async Task ExecuteAsync(string[] args) + { + if (CliHelp.RequestsHelp(args.AsSpan(1))) + { + return PrintHelp(); + } + + var configPath = ParseConfigPath(args); + var verbose = ParseVerbose(args); + var serviceMode = ParseServiceMode(args); + var serviceName = ParseServiceName(args) ?? Service.ServiceDefaults.DefaultServiceName; + + if (serviceMode && OperatingSystem.IsWindows()) + { + return await WindowsProxyServiceHost.RunAsync(configPath, verbose, serviceName) + .ConfigureAwait(false); + } + + return await ExecuteCoreAsync(configPath, verbose, serviceMode, CancellationToken.None) + .ConfigureAwait(false); + } + + /// Shared proxy lifecycle for foreground run and Windows Service hosted mode. + internal static async Task ExecuteCoreAsync( // NOSONAR S3776 -- CLI run lifecycle (load, apply, wait, reload) shares the hosted proxy instance. + string configPath, + bool verbose, + bool serviceMode, + CancellationToken stoppingToken) { var loaded = ConfigLoader.Load(configPath); var errors = TwpConfigValidator.Validate(loaded.Config); @@ -36,11 +72,24 @@ public static async Task ExecuteAsync(string configPath, bool verbose = fal return 1; } + // When launched as a service, resolve relative paths against the config directory + // (SCM / systemd / launchd cwd is typically System32 or /). + var configDir = Path.GetDirectoryName(Path.GetFullPath(configPath)); + if (!string.IsNullOrEmpty(configDir)) + { + Directory.SetCurrentDirectory(configDir); + } + var requiresSessionPath = ConfigNeedsSessionPath(loaded.Config); // CLI is non-interactive: do not install the MITM root into the user trust store // (Windows can block on a security prompt and hang headless CI / services). using var proxy = new ProxyServer(userTrustRootCertificate: false); ApplyLogging(proxy, loaded.Config.Logging, verbose); + if (serviceMode) + { + ApplyServiceLoggingDefaults(proxy, loaded.Config.Logging); + } + // Fast leaf cold-start before server.certificateManager overlays (which may override engine/algo). proxy.CertificateManager.ApplyFastColdStartLeafSettings(); ServerConfigApplier.Apply(proxy, loaded.Config.Server); @@ -49,7 +98,7 @@ public static async Task ExecuteAsync(string configPath, bool verbose = fal var clusterManager = new ClusterManager(); if (loaded.Config.Clusters.Count > 0) { - await clusterManager.ApplyAsync(loaded.Config.Clusters.ToList()); + await clusterManager.ApplyAsync(loaded.Config.Clusters.ToList(), stoppingToken).ConfigureAwait(false); } foreach (var listener in loaded.Config.Listeners) @@ -57,6 +106,8 @@ public static async Task ExecuteAsync(string configPath, bool verbose = fal AddListener(proxy, listener); } + ServerConfigApplier.ApplyIgnoreServerCertificateErrorsAfterListeners(proxy, loaded.Config.Server); + if (loaded.Config.Listeners.Count == 0) { proxy.AddEndPoint(new ExplicitProxyEndPoint(IPAddress.Loopback, 8000, false)); @@ -72,25 +123,32 @@ public static async Task ExecuteAsync(string configPath, bool verbose = fal var plusOptions = loaded.Config.Plus is not null ? BuildPlusOptions(loaded.Config.Plus) : new Dictionary(StringComparer.OrdinalIgnoreCase); + ResolvePlusRelativePaths(plusOptions, configDir); ConfigureResponseCache(proxy, middleware, responseCache, plusOptions); + IGrpcJsonTranscoder? grpcJsonTranscoder = null; + void RefreshReverseProxy() { + // Prefer ClusterManager snapshot so SIGHUP reload picks up new destinations + // (do not close over the initial ConfigLoader result). + var snapClusters = clusterManager.Snapshot.Clusters; proxy.ReverseProxy = new ReverseProxyOptions { Routes = routes.Count > 0 ? routes : null, - Clusters = loaded.Config.Clusters.Count > 0 ? loaded.Config.Clusters.ToList() : null, + Clusters = snapClusters.Count > 0 ? snapClusters.Values.ToList() : null, ClusterManager = clusterManager, RouteMatcher = new RouteMatcher(), LoadBalancer = loadBalancer, TransformEngine = new TransformEngine(), Middleware = middleware.Count > 0 ? middleware : null, LatencyRecorder = loadBalancer, + GrpcJsonTranscoder = grpcJsonTranscoder, }; } - await TryActivatePlusAsync(loaded.Config, new PlusActivationContext + var plusContext = new PlusActivationContext { ProxyServer = proxy, ClusterManager = clusterManager, @@ -101,29 +159,342 @@ void RefreshReverseProxy() ResponseCache = responseCache, LatencyRecorder = loadBalancer, Logger = proxy.Logger, - }); + }; + await TryActivatePlusAsync(loaded.Config, plusContext).ConfigureAwait(false); + grpcJsonTranscoder = plusContext.GrpcJsonTranscoder; RefreshReverseProxy(); proxy.Start(); StartAcmeIfConfigured(proxy, loaded.Config); - AsyncConsole.WriteLine("Titanium proxy running. Press Ctrl+C to stop."); - await AsyncConsole.FlushAsync(); - await WaitForCtrlCAsync(); - await proxy.StopAsync(); + JsonAccessLogWriter? accessLog = null; + try + { + accessLog = TryStartAccessLog(proxy, loaded.Config.Server); + + if (serviceMode) + { + AsyncConsole.WriteLine("Titanium proxy running (service mode)."); + } + else + { + AsyncConsole.WriteLine("Titanium proxy running. Press Ctrl+C to stop."); + } + + await AsyncConsole.FlushAsync().ConfigureAwait(false); + Console.WriteLine("awaiting-shutdown-or-reload"); + await Console.Out.FlushAsync(stoppingToken).ConfigureAwait(false); + await WaitForShutdownOrReloadAsync( + stoppingToken, + onReload: async () => + { + try + { + await ReloadConfigAsync( + configPath, + proxy, + clusterManager, + routes, + middleware, + loadBalancer, + responseCache, + plusOptions, + () => grpcJsonTranscoder, + t => grpcJsonTranscoder = t, + RefreshReverseProxy, + stoppingToken).ConfigureAwait(false); + AsyncConsole.WriteLine("Config reloaded."); + await AsyncConsole.FlushAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + AsyncConsole.WriteError("Config reload failed: " + ex.Message); + } + }).ConfigureAwait(false); + await proxy.StopAsync().ConfigureAwait(false); + return 0; + } + finally + { + accessLog?.Dispose(); + } + } + + private static JsonAccessLogWriter? TryStartAccessLog(ProxyServer proxy, ServerConfig? server) + { + var cfg = server?.AccessLog; + if (cfg is null || string.IsNullOrWhiteSpace(cfg.Path)) + { + return null; + } + + var sample = cfg.SampleRate ?? 1.0; + if (sample <= 0) + { + return null; + } + + proxy.EnableHttpInterception = true; + proxy.EnableRequestTimingCapture = true; + var writer = new JsonAccessLogWriter(cfg.Path, sample); + proxy.AfterResponse += (_, e) => + { + try + { + writer.TryWrite(e); + } + catch + { + // Access log is best-effort. + } + + return Task.CompletedTask; + }; + AsyncConsole.WriteLine($"Access log: {cfg.Path} (sample={sample:0.###})"); + return writer; + } + + /// + /// Reloads routes/clusters (and server settings) from . + /// Validation failures throw before mutating or the cluster manager. + /// + internal static async Task ReloadConfigAsync( // NOSONAR S107 -- Reload keeps established config wiring without a context bag. + string configPath, + ProxyServer proxy, + ClusterManager clusterManager, + List routes, + List middleware, + LoadBalancer loadBalancer, + MemoryHttpResponseCache responseCache, + Dictionary plusOptions, + Func getGrpc, + Action setGrpc, + Action refreshReverseProxy, + CancellationToken stoppingToken = default) + { + var loaded = ConfigLoader.Load(configPath); + var errors = TwpConfigValidator.Validate(loaded.Config); + if (errors.Count > 0) + { + throw new InvalidOperationException(string.Join("; ", errors)); + } + + ServerConfigApplier.Apply(proxy, loaded.Config.Server); + if (loaded.Config.Clusters.Count > 0) + { + await clusterManager.ApplyAsync(loaded.Config.Clusters.ToList(), stoppingToken).ConfigureAwait(false); + } + else + { + await clusterManager.ApplyAsync([], stoppingToken).ConfigureAwait(false); + } + + ReplaceRoutes(routes, loaded.Config.Routes); + + // Keep existing middleware; Plus control plane remains. Refresh reverse options only. + _ = getGrpc(); + refreshReverseProxy(); + _ = middleware; + _ = loadBalancer; + _ = responseCache; + _ = plusOptions; + _ = setGrpc; + } + + /// In-place route list swap used by SIGHUP reload (and unit tests). + internal static void ReplaceRoutes(List routes, IEnumerable next) + { + routes.Clear(); + foreach (var r in next) + { + routes.Add(r); + } + } + + internal static int PrintHelp() + { + AsyncConsole.WriteLine(""" + titanium run -c [-v|--verbose] [--service] [--name ] + + -c, --config Path to twp.yaml / .json / .twp / .conf (required). + -v, --verbose Enable debug console logging. + --service Run as an OS service worker (used by `titanium service install`). + --name Windows SCM service name when --service is set (default: titanium). + + Starts the proxy and blocks until Ctrl+C, SIGTERM, or the service manager stops it. + On Unix, SIGHUP reloads routes/clusters from the config file without dropping the + process or in-flight connections (listeners stay bound). + """); + CliHelp.WriteDocsFooter(); return 0; } - private static async Task WaitForCtrlCAsync() + internal static string ParseConfigPath(string[] args) { - var tcs = new TaskCompletionSource(); - Console.CancelKeyPress += (_, e) => + for (var i = 1; i < args.Length; i++) + { + if ((args[i] is "-c" or "--config") && i + 1 < args.Length) + { + return args[i + 1]; + } + } + + throw new ArgumentException("Missing required -c ."); + } + + internal static bool ParseVerbose(string[] args) + { + for (var i = 1; i < args.Length; i++) + { + if (args[i] is "-v" or "--verbose") + { + return true; + } + } + + return false; + } + + internal static bool ParseServiceMode(string[] args) + { + for (var i = 1; i < args.Length; i++) + { + if (args[i] is "--service") + { + return true; + } + } + + return false; + } + + internal static string? ParseServiceName(string[] args) + { + for (var i = 1; i < args.Length; i++) + { + if (args[i] is "--name" && i + 1 < args.Length) + { + return args[i + 1]; + } + } + + return null; + } + + /// + /// When YAML has no file log, enable a default file sink on Windows (SCM has no console) + /// and keep console on Linux/macOS so journald / launchd capture stdout. + /// + internal static void ApplyServiceLoggingDefaults(ProxyServer proxy, LoggingConfig? logging) + { + var hasFile = logging is { EnableFile: true } && !string.IsNullOrWhiteSpace(logging.FilePath); + if (hasFile) + { + return; + } + + if (OperatingSystem.IsWindows()) + { + var dir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), + "Titanium", + "logs"); + Directory.CreateDirectory(dir); + proxy.Logging.Enabled = true; + proxy.Logging.EnableFile = true; + proxy.Logging.FilePath = Path.Combine(dir, "titanium.log"); + if (proxy.Logging.MinimumLevel > Microsoft.Extensions.Logging.LogLevel.Information) + { + proxy.Logging.MinimumLevel = Microsoft.Extensions.Logging.LogLevel.Information; + } + + proxy.ApplyLoggingConfiguration(); + return; + } + + // Linux journald / macOS launchd StandardOutPath: ensure console is on. + if (!proxy.Logging.Enabled || !proxy.Logging.EnableConsole) + { + proxy.Logging.Enabled = true; + proxy.Logging.EnableConsole = true; + if (proxy.Logging.MinimumLevel > Microsoft.Extensions.Logging.LogLevel.Information) + { + proxy.Logging.MinimumLevel = Microsoft.Extensions.Logging.LogLevel.Information; + } + + proxy.ApplyLoggingConfiguration(); + } + } + +#pragma warning disable CA1068 // Token stays first so POSIX signal registration can observe the run CTS. + private static async Task WaitForShutdownOrReloadAsync(CancellationToken stoppingToken, Func? onReload) // NOSONAR CA1068 -- Token stays first so POSIX signal registration can observe the run CTS. + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void RequestStop() => tcs.TrySetResult(); + + ConsoleCancelEventHandler? cancelHandler = (_, e) => { e.Cancel = true; - tcs.TrySetResult(); + RequestStop(); }; - await tcs.Task; + Console.CancelKeyPress += cancelHandler; + + using var reg = stoppingToken.CanBeCanceled + ? stoppingToken.Register(RequestStop) + : default; + + PosixSignalRegistration? sigTerm = null; + PosixSignalRegistration? sigInt = null; + PosixSignalRegistration? sigHup = null; + try + { + if (!OperatingSystem.IsWindows()) + { + sigTerm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx => + { + ctx.Cancel = true; + RequestStop(); + }); + sigInt = PosixSignalRegistration.Create(PosixSignal.SIGINT, ctx => + { + ctx.Cancel = true; + RequestStop(); + }); + if (onReload is not null) + { + sigHup = PosixSignalRegistration.Create(PosixSignal.SIGHUP, ctx => + { + // Cancel default terminate-on-HUP so reload can complete. + ctx.Cancel = true; + _ = Task.Run(async () => + { + try + { + await onReload().ConfigureAwait(false); + } + catch + { + // Reload errors are logged by caller. + } + }, stoppingToken); + }); + Console.WriteLine("sighup-handler-registered"); + await Console.Out.FlushAsync(stoppingToken).ConfigureAwait(false); + } + } + + await tcs.Task.ConfigureAwait(false); + } + finally + { + Console.CancelKeyPress -= cancelHandler; + sigTerm?.Dispose(); + sigInt?.Dispose(); + sigHup?.Dispose(); + } } +#pragma warning restore CA1068 private static void StartAcmeIfConfigured(ProxyServer proxy, TwpConfig config) { @@ -201,23 +572,41 @@ private static void ConfigureResponseCache( var cacheMiddleware = new HttpResponseCacheMiddleware(responseCache); middleware.Add(cacheMiddleware); - proxy.AfterResponse += async (_, e) => + + // Buffer in BeforeResponse so fill does not depend on MITM session-lite coalescing. + // After the body is streamed, IsBodyReceived is set without IsBodyRead and + // AfterResponse GetResponseBody throws — perpetual misses (~0.64× CLI vs ~0.99× hits). + proxy.BeforeResponse += async (_, e) => { try { - if (e.HttpClient.Response.StatusCode == 200 && - !e.HttpClient.Response.IsBodyRead && - e.HttpClient.Response.HasBody) + var response = e.HttpClient.Response; + if (response.StatusCode == 200 && + response.HasBody && + !response.IsBodyRead) { + response.KeepBody = true; await e.GetResponseBody().ConfigureAwait(false); } + } + catch + { + // Cache best-effort only. + } + }; + proxy.AfterResponse += (_, e) => + { + try + { cacheMiddleware.TryCacheCurrentResponse(e); } catch { // Cache best-effort only. } + + return Task.CompletedTask; }; } @@ -234,7 +623,51 @@ private static async Task TryActivatePlusAsync(TwpConfig config, PlusActivationC AsyncConsole.WriteError(warning); } - plus?.Apply(context); + if (plus is null) + { + return; + } + + try + { + plus.Apply(context); + } + catch (Exception ex) + { + // Surface plugin failures (missing gRPC descriptor, bad JWKS URL, …) instead of + // continuing as a half-activated edge with silent Plus drop. + AsyncConsole.WriteError("Plus activation failed: " + ex.Message); + throw; + } + } + + /// + /// Resolves Plus file paths (e.g. gRPC descriptor sets) against the config directory so + /// relative paths keep working after . + /// + internal static void ResolvePlusRelativePaths( + Dictionary plusOptions, + string? configDir) + { + if (string.IsNullOrEmpty(configDir) || plusOptions.Count == 0) + { + return; + } + + foreach (var key in PlusRelativePathKeys) + { + if (!plusOptions.TryGetValue(key, out var raw) || string.IsNullOrWhiteSpace(raw)) + { + continue; + } + + if (Path.IsPathRooted(raw)) + { + continue; + } + + plusOptions[key] = Path.GetFullPath(Path.Combine(configDir, raw)); + } } internal static void ApplyLogging(ProxyServer proxy, LoggingConfig? logging, bool verbose) @@ -504,6 +937,21 @@ internal static bool ConfigNeedsSessionPath(TwpConfig config) return true; } + if (config.Plus is not null && + config.Plus.Options is not null && + config.Plus.Options.TryGetValue("grpc.transcode.enabled", out var grpcEnabled) && + (grpcEnabled.Equals("true", StringComparison.OrdinalIgnoreCase) || + grpcEnabled.Equals("1", StringComparison.OrdinalIgnoreCase) || + grpcEnabled.Equals("yes", StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + if (config.Server?.AccessLog is { Path: not null and not "" }) + { + return true; + } + return config.Routes.Any(r => r.Transforms is { Count: > 0 }); } } diff --git a/src/Titanium.Cli/Config/ServerConfigApplier.cs b/src/Titanium.Cli/Config/ServerConfigApplier.cs index 884afdfa6..d4f4a14ef 100644 --- a/src/Titanium.Cli/Config/ServerConfigApplier.cs +++ b/src/Titanium.Cli/Config/ServerConfigApplier.cs @@ -37,6 +37,70 @@ public static void Apply(ProxyServer proxy, ServerConfig? server) ApplyTls(proxy, server.Tls); ApplyUpstream(proxy, server.Upstream); ApplyCertificateManager(proxy, server.CertificateManager); + ApplyDecryptExclusions(proxy, server); + } + + /// + /// After listeners exist: honor config, else default ignore-upstream-cert for explicit MITM. + /// macOS denies CurrentUser\Root writes (Keychain UI); without this, decryptSsl cannot + /// complete HTTPS to loopback/self-signed origins. + /// + public static void ApplyIgnoreServerCertificateErrorsAfterListeners(ProxyServer proxy, ServerConfig? server) + { + if (server?.IgnoreServerCertificateErrors is bool configured) + { + proxy.IgnoreServerCertificateErrors = configured; + return; + } + + if (proxy.ProxyEndPoints.OfType().Any(endPoint => endPoint.DecryptSsl)) + { + proxy.IgnoreServerCertificateErrors = true; + } + } + + /// + /// Builds system-proxy settings from config. Null + /// merges factory identity hosts; a present list (including empty) is authoritative (Replace). + /// + public static SystemProxySettings CreateSystemProxySettings(ServerConfig? server) + { + var loopback = server?.ProxyLoopback ?? true; + if (server?.SystemProxyBypassHosts is null) + { + return MitmExclusionDefaults.CreateSystemProxySettings(loopback); + } + + return MitmExclusionDefaults.CreateSystemProxySettings( + loopback, + server.SystemProxyBypassHosts, + MitmExclusionMode.Replace); + } + + private static void ApplyDecryptExclusions(ProxyServer proxy, ServerConfig server) + { + if (server.DecryptSkipHosts is null && server.DecryptOnlyHosts is null) + { + return; + } + + var skip = server.DecryptSkipHosts ?? []; + var only = server.DecryptOnlyHosts ?? []; + foreach (var endPoint in proxy.ProxyEndPoints) + { + if (endPoint is not ExplicitProxyEndPoint explicitEp) + { + continue; + } + + // Present lists are authoritative — do not re-inject factory SSO/pinning hosts. + MitmExclusionDefaults.ApplyDecryptExclusions( + explicitEp, + () => explicitEp.DecryptSsl, + skip, + only, + MitmExclusionMode.Replace); + } } private static void ApplyProtocolFlags(ProxyServer proxy, ServerConfig server) @@ -81,6 +145,8 @@ private static void ApplyProtocolFlags(ProxyServer proxy, ServerConfig server) proxy.CheckCertificateRevocation = revocation; } + ApplyBool(server.IgnoreServerCertificateErrors, v => proxy.IgnoreServerCertificateErrors = v); + if (!string.IsNullOrWhiteSpace(server.DnsServerEndPoint) && TryParseEndPoint(server.DnsServerEndPoint, out var dns)) { diff --git a/src/Titanium.Cli/Config/TestCommand.cs b/src/Titanium.Cli/Config/TestCommand.cs index 295f9694e..4da47f511 100644 --- a/src/Titanium.Cli/Config/TestCommand.cs +++ b/src/Titanium.Cli/Config/TestCommand.cs @@ -6,8 +6,14 @@ namespace Titanium.Cli.Config; internal static class TestCommand { - public static Task ExecuteAsync(string configPath) + public static Task ExecuteAsync(string[] args) { + if (CliHelp.RequestsHelp(args.AsSpan(1))) + { + return Task.FromResult(PrintHelp()); + } + + var configPath = RunCommand.ParseConfigPath(args); var loaded = ConfigLoader.Load(configPath); var errors = TwpConfigValidator.Validate(loaded.Config); if (errors.Count > 0) @@ -27,4 +33,17 @@ public static Task ExecuteAsync(string configPath) AsyncConsole.WriteLine($"EnableRequestTimingCapture would be: {RunCommand.ConfigNeedsRequestTimingCapture(loaded.Config)} (auto when LeastTime LB)"); return Task.FromResult(0); } + + internal static int PrintHelp() + { + AsyncConsole.WriteLine(""" + titanium test -c + + -c, --config Path to twp.yaml / .json / .twp / .conf (required). + + Validates the config without opening listeners or serving traffic. + """); + CliHelp.WriteDocsFooter(); + return 0; + } } diff --git a/src/Titanium.Cli/Config/WindowsProxyServiceHost.cs b/src/Titanium.Cli/Config/WindowsProxyServiceHost.cs new file mode 100644 index 000000000..5f1d8a3bf --- /dev/null +++ b/src/Titanium.Cli/Config/WindowsProxyServiceHost.cs @@ -0,0 +1,59 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting.WindowsServices; + +namespace Titanium.Cli.Config; + +/// +/// Windows SCM host for titanium run … --service. Uses the same proxy bootstrap as foreground run. +/// +internal static class WindowsProxyServiceHost +{ + public static async Task RunAsync(string configPath, bool verbose, string serviceName) + { + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddWindowsService(options => + { + options.ServiceName = serviceName; + }); + builder.Services.AddSingleton(new ProxyRunOptions(configPath, verbose, ServiceMode: true)); + builder.Services.AddHostedService(); + + try + { + await builder.Build().RunAsync().ConfigureAwait(false); + return 0; + } + catch (Exception ex) + { + AsyncConsole.WriteError(ex.Message); + return 1; + } + } +} + +internal sealed record ProxyRunOptions(string ConfigPath, bool Verbose, bool ServiceMode); + +/// Hosts for the lifetime of the Windows Service / generic host. +internal sealed class ProxyRunBackgroundService : BackgroundService +{ + private readonly ProxyRunOptions options; + + public ProxyRunBackgroundService(ProxyRunOptions options) + { + this.options = options; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var code = await RunCommand.ExecuteCoreAsync( + options.ConfigPath, + options.Verbose, + serviceMode: true, + stoppingToken).ConfigureAwait(false); + if (code != 0) + { + throw new InvalidOperationException($"Proxy run exited with code {code}."); + } + } +} diff --git a/src/Titanium.Cli/Http3/Http3DepsCommand.cs b/src/Titanium.Cli/Http3/Http3DepsCommand.cs index 11b6649c2..45da963a6 100644 --- a/src/Titanium.Cli/Http3/Http3DepsCommand.cs +++ b/src/Titanium.Cli/Http3/Http3DepsCommand.cs @@ -17,7 +17,17 @@ internal static class Http3DepsCommand public static async Task ExecuteAsync(string[] args) { + if (args.Length >= 2 && CliHelp.IsHelpToken(args[1])) + { + return PrintHelp(); + } + var sub = args.Length > 1 ? args[1].ToLowerInvariant() : "status"; + if (args.Length > 2 && CliHelp.RequestsHelp(args.AsSpan(2))) + { + return PrintHelp(); + } + return sub switch { "status" => Status(), @@ -39,6 +49,7 @@ Prefer the matching CLI RID zip (linux-x64, linux-musl-x64, osx-arm64, …) whic bundles MsQuic + OpenSSL (MIT/Apache). Zips do NOT ship libnuma / lttng-ust (LGPL/GPL); those stay host packages. Use install for empty/distroless images or when Quic is false. """); + CliHelp.WriteDocsFooter(); return 0; } @@ -71,6 +82,13 @@ private static int Status() AsyncConsole.WriteLine(" Ubuntu/Debian: libnuma1"); AsyncConsole.WriteLine(" Alpine: numactl lttng-ust"); AsyncConsole.WriteLine($" 2) Or run: titanium http3-deps {InstallSubcommand}"); + if (OperatingSystem.IsMacOS()) + { + AsyncConsole.WriteLine(" macOS: brew install libmsquic openssl@3, then rebuild (copies natives beside the binary)."); + AsyncConsole.WriteLine(" Framework-dependent Debug also needs those libs on DYLD_FALLBACK_LIBRARY_PATH;"); + AsyncConsole.WriteLine(" Inspector/CLI re-launch with that automatically (or use `dotnet run` launchSettings)."); + AsyncConsole.WriteLine(" Manual: export DYLD_FALLBACK_LIBRARY_PATH=\"$(brew --prefix)/opt/libmsquic/lib:$(brew --prefix)/opt/openssl@3/lib\""); + } if (OperatingSystem.IsWindows()) { AsyncConsole.WriteLine(" Windows requires Windows 11 or Windows Server 2022+ (OS MsQuic)."); @@ -97,9 +115,10 @@ private static async Task InstallAsync() if (OperatingSystem.IsMacOS()) { return await RunPackageInstallAsync( - "brew", + ResolveBrewCommand(), [InstallSubcommand, LibMsQuicPackage], - $"Homebrew is required: https://brew.sh — then: brew {InstallSubcommand} {LibMsQuicPackage}"); + $"Homebrew is required: https://brew.sh — then: brew {InstallSubcommand} {LibMsQuicPackage} openssl@3. " + + "For local Debug builds, rebuild Inspector/CLI so natives are copied beside the binary."); } if (File.Exists("/etc/alpine-release") || LooksLikeMusl()) @@ -176,13 +195,18 @@ private static async Task InstallDebianAsync() private static async Task RunPackageInstallAsync(string fileName, string[] args, string hint) { - if (!HasCommand(fileName) && fileName != "brew") + var isBrew = fileName == "brew" || + fileName.EndsWith("/brew", StringComparison.Ordinal) || + fileName.EndsWith("\\brew", StringComparison.Ordinal); + if (isBrew) { - AsyncConsole.WriteError(hint); - return 1; + if (!HasCommand("brew") && !File.Exists(fileName)) + { + AsyncConsole.WriteError(hint); + return 1; + } } - - if (fileName == "brew" && !HasCommand("brew")) + else if (!HasCommand(fileName)) { AsyncConsole.WriteError(hint); return 1; @@ -197,6 +221,22 @@ private static async Task RunPackageInstallAsync(string fileName, string[] return await RunAsync(fileName, args); } + /// Prefer PATH brew, then common prefixes (including user installs under ~/.homebrew). + private static string ResolveBrewCommand() + { + if (HasCommand("brew")) + { + return "brew"; + } + + return new[] + { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".homebrew", "bin", "brew"), + "/opt/homebrew/bin/brew", + "/usr/local/bin/brew", + }.FirstOrDefault(File.Exists) ?? "brew"; + } + private static async Task RunAsync(string fileName, IReadOnlyList args) { AsyncConsole.WriteLine($"> {fileName} {string.Join(' ', args)}"); diff --git a/src/Titanium.Cli/Parsers/ConfigLoader.cs b/src/Titanium.Cli/Parsers/ConfigLoader.cs index d4d5fbbfc..278f52f3d 100644 --- a/src/Titanium.Cli/Parsers/ConfigLoader.cs +++ b/src/Titanium.Cli/Parsers/ConfigLoader.cs @@ -1,4 +1,4 @@ -using Titanium.Cli.Parsers; +using System.Text.Json; using Titanium.Web.Proxy.Configuration; using Titanium.Web.Proxy.Configuration.Models; using Titanium.Web.Proxy.Configuration.Parsers; @@ -47,9 +47,12 @@ public static LoadedConfig Load(string path) } if (ext.Equals(".json", StringComparison.OrdinalIgnoreCase) && - !name.StartsWith("twp", StringComparison.OrdinalIgnoreCase)) + !name.StartsWith("twp", StringComparison.OrdinalIgnoreCase) && + !LooksLikeNativeTwpJson(path)) { - // Prefer reverse-proxy document dialect for generic *.json; native twp.json uses TwpConfigLoader. + // Prefer reverse-proxy document dialect for generic *.json that are only + // listeners/routes/clusters. Native TwpConfig (plus/server/logging/…) must not + // silently parse as reverse-proxy — that dialect drops Plus and other sections. try { return new LoadedConfig @@ -72,4 +75,33 @@ public static LoadedConfig Load(string path) Config = TwpConfigLoader.LoadFile(path), }; } + + /// + /// True when the JSON root looks like a native (not a bare + /// reverse-proxy document). Filename conventions alone are not enough — users often + /// name configs config.json / edge.json while still including plus:. + /// + internal static bool LooksLikeNativeTwpJson(string path) + { + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + return doc.RootElement.EnumerateObject().Any(prop => + prop.NameEquals("schemaVersion") || + prop.NameEquals("plus") || + prop.NameEquals("server") || + prop.NameEquals("logging") || + prop.NameEquals("certificates") || + prop.NameEquals("staticFiles")); + } + catch (JsonException) + { + return false; + } + } } diff --git a/src/Titanium.Cli/Program.cs b/src/Titanium.Cli/Program.cs index c99e758a2..124e1fc98 100644 --- a/src/Titanium.Cli/Program.cs +++ b/src/Titanium.Cli/Program.cs @@ -1,6 +1,8 @@ using Titanium.Cli.Config; using Titanium.Cli.Http3; +using Titanium.Cli.Service; using Titanium.Cli.Updates; +using Titanium.Web.Proxy.Http3; namespace Titanium.Cli; @@ -8,6 +10,12 @@ internal static class Program { public static async Task Main(string[] args) { + args = PrivilegePrompt.TakeInternalArgs(args); + PrivilegePrompt.TryAttachParentConsole(); + + // Framework-dependent macOS Debug: make app-local libmsquic visible to QuicListener. + Http3NativeBootstrap.EnsureAppLocalMsQuicVisible(args); + try { if (args.Length == 0) @@ -21,11 +29,12 @@ public static async Task Main(string[] args) { return command switch { - "run" => await RunCommand.ExecuteAsync(ParseConfigPath(args), ParseVerbose(args)), - "test" => await TestCommand.ExecuteAsync(ParseConfigPath(args)), + "run" => await RunCommand.ExecuteAsync(args), + "test" => await TestCommand.ExecuteAsync(args), "version" => await VersionCommand.ExecuteAsync(args), "update" => await UpdateCommand.ExecuteAsync(args), "http3-deps" => await Http3DepsCommand.ExecuteAsync(args), + "service" => await ServiceCommand.ExecuteAsync(args), "help" or "-h" or "--help" => PrintHelp(), _ => await UnknownAsync(command), }; @@ -42,44 +51,22 @@ public static async Task Main(string[] args) } } - private static string ParseConfigPath(string[] args) - { - for (var i = 1; i < args.Length; i++) - { - if ((args[i] is "-c" or "--config") && i + 1 < args.Length) - { - return args[i + 1]; - } - } - - throw new ArgumentException("Missing required -c ."); - } - - private static bool ParseVerbose(string[] args) - { - for (var i = 1; i < args.Length; i++) - { - if (args[i] is "-v" or "--verbose") - { - return true; - } - } - - return false; - } - private static int PrintHelp() { AsyncConsole.WriteLine(""" Titanium Web Proxy CLI Usage: - titanium run -c [-v|--verbose] + titanium run -c [-v|--verbose] [--service] titanium test -c titanium version [--check] [--plus] [--channel beta] - titanium update [--plus] [--channel beta] + titanium update [--plus] [--remove-plus] [--channel beta] titanium http3-deps status|install + titanium service install|uninstall|start|stop|restart|status + + Nested help: titanium --help """); + CliHelp.WriteDocsFooter(); return 0; } diff --git a/src/Titanium.Cli/Service/IOsServiceManager.cs b/src/Titanium.Cli/Service/IOsServiceManager.cs new file mode 100644 index 000000000..99de9b488 --- /dev/null +++ b/src/Titanium.Cli/Service/IOsServiceManager.cs @@ -0,0 +1,151 @@ +namespace Titanium.Cli.Service; + +internal static class ServiceDefaults +{ + public const string DefaultServiceName = "titanium"; + public const string DisplayName = "Titanium Web Proxy"; + public const string Description = "Titanium Web Proxy reverse / edge proxy"; + public const string MacOsLabelPrefix = "com.justcoding121."; + + public static string ResolveMacOsLabel(string serviceName) => + serviceName.StartsWith("com.", StringComparison.OrdinalIgnoreCase) + ? serviceName + : MacOsLabelPrefix + serviceName; + + public static string ResolveExePath() + { + var prefix = ResolveProgramPrefix(); + return prefix[0]; + } + + /// + /// Command prefix written into the OS service unit. A published apphost is + /// titanium/titanium.exe. dotnet titanium.dll must not become + /// dotnet run -c … (that looks for a project in the config directory). + /// + public static string[] ResolveProgramPrefix() => + ResolveProgramPrefix( + Environment.ProcessPath, + Environment.GetCommandLineArgs(), + AppContext.BaseDirectory); + + internal static string[] ResolveProgramPrefix( + string? processPath, + string[] commandLineArgs, + string baseDirectory) + { + if (string.IsNullOrWhiteSpace(processPath) || !File.Exists(processPath)) + { + throw new InvalidOperationException( + "Unable to resolve the titanium executable path (Environment.ProcessPath)."); + } + + var host = Path.GetFullPath(processPath); + if (!IsDotnetMuxer(host)) + return [host]; + + var entry = ResolveEntryDll(commandLineArgs, baseDirectory); + var dllDir = Path.GetDirectoryName(entry) ?? baseDirectory; + var apphost = Path.Combine( + dllDir, + OperatingSystem.IsWindows() ? "titanium.exe" : "titanium"); + if (File.Exists(apphost)) + return [Path.GetFullPath(apphost)]; + + return [host, entry]; + } + + /// + /// Process + optional titanium.dll args when relaunching under UAC/sudo + /// while still hosted as dotnet titanium.dll (no adjacent apphost). + /// + internal static (string FileName, string[] PrefixArgs) ResolveRelaunchTarget() + { + var prefix = ResolveProgramPrefix(); + return (prefix[0], prefix.Length > 1 ? prefix[1..] : []); + } + + public static Dictionary ResolveServiceEnvironment() + { + var env = new Dictionary(StringComparer.Ordinal) + { + ["DOTNET_NOLOGO"] = "1", + ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", + ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1", + }; + + var root = Environment.GetEnvironmentVariable("DOTNET_ROOT"); + if (string.IsNullOrEmpty(root) && + !string.IsNullOrWhiteSpace(Environment.ProcessPath) && + IsDotnetMuxer(Environment.ProcessPath)) + { + root = Path.GetDirectoryName(Path.GetFullPath(Environment.ProcessPath)); + } + + if (!string.IsNullOrEmpty(root) && Directory.Exists(root)) + env["DOTNET_ROOT"] = root; + + return env; + } + + internal static bool IsDotnetMuxer(string processPath) + { + var name = Path.GetFileNameWithoutExtension(processPath); + return name.Equals("dotnet", StringComparison.OrdinalIgnoreCase); + } + + /// Alias for used by elevation relaunch paths. + internal static bool IsDotnetHostPath(string path) => IsDotnetMuxer(path); + + private static string ResolveEntryDll(string[] commandLineArgs, string baseDirectory) + { + if (commandLineArgs.Length > 0) + { + var candidate = commandLineArgs[0]; + if (!Path.IsPathRooted(candidate)) + candidate = Path.Combine(baseDirectory, candidate); + candidate = Path.GetFullPath(candidate); + if (candidate.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && File.Exists(candidate)) + return candidate; + } + + var fallback = Path.GetFullPath(Path.Combine(baseDirectory, "titanium.dll")); + if (File.Exists(fallback)) + return fallback; + + throw new InvalidOperationException( + "Unable to resolve titanium.dll while hosted by the dotnet muxer."); + } +} + +internal enum ServiceStatusKind +{ + NotInstalled, + Stopped, + Running, + Other, +} + +internal sealed record ServiceStatusResult( + ServiceStatusKind Kind, + string Name, + string? Detail = null); + +internal interface IOsServiceManager +{ + Task InstallAsync(ServiceInstallRequest request); + Task UninstallAsync(string name, bool user); + Task StartAsync(string name, bool user); + Task StopAsync(string name, bool user); + Task RestartAsync(string name, bool user); + Task StatusAsync(string name, bool user); +} + +internal sealed record ServiceInstallRequest( + string Name, + string ConfigPath, + bool User, + bool StartAfterInstall, + IReadOnlyList ProgramPrefix, + string WorkingDirectory, + IReadOnlyDictionary? EnvironmentVariables = null); diff --git a/src/Titanium.Cli/Service/LaunchdServiceManager.cs b/src/Titanium.Cli/Service/LaunchdServiceManager.cs new file mode 100644 index 000000000..c7e10697b --- /dev/null +++ b/src/Titanium.Cli/Service/LaunchdServiceManager.cs @@ -0,0 +1,318 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; + +namespace Titanium.Cli.Service; + +[SupportedOSPlatform("macos")] +internal sealed partial class LaunchdServiceManager : IOsServiceManager +{ + public async Task InstallAsync(ServiceInstallRequest request) + { + if (!request.User) + { + EnsureRoot(); + } + + var label = ServiceDefaults.ResolveMacOsLabel(request.Name); + var userHome = request.User ? ResolveUserHome() : null; + var logDir = ServiceUnitFactory.ResolveLaunchdLogDirectory(request.User, userHome); + Directory.CreateDirectory(logDir); + var outPath = Path.Combine(logDir, request.Name + ".out.log"); + var errPath = Path.Combine(logDir, request.Name + ".err.log"); + + var programPrefix = request.ProgramPrefix; + if (!request.User) + { + var sourceDir = ServicePayload.DiscoverAppDirectory(programPrefix); + if (!string.IsNullOrEmpty(sourceDir) && Directory.Exists(sourceDir)) + { + var destDir = ServicePayload.MacOsDaemonPayloadDirectory(request.Name); + ServicePayload.CopyDirectory(sourceDir, destDir); + programPrefix = ServicePayload.RemapPrefix(programPrefix, sourceDir, destDir); + TryChmodExecute(programPrefix[0]); + } + } + + var plist = ServiceUnitFactory.BuildLaunchdPlist( + label, + programPrefix, + request.ConfigPath, + request.WorkingDirectory, + outPath, + errPath, + request.EnvironmentVariables); + + var plistPath = ServiceUnitFactory.ResolveLaunchdPlistPath(label, request.User, userHome); + var dir = Path.GetDirectoryName(plistPath)!; + Directory.CreateDirectory(dir); + await File.WriteAllTextAsync(plistPath, plist, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)) + .ConfigureAwait(false); + AsyncConsole.WriteLine($"Wrote {plistPath}"); + + var domain = ResolveDomain(request.User); + // bootout first so reinstall is idempotent. + await TryBootoutAsync(domain, plistPath, label).ConfigureAwait(false); + + if (!request.StartAfterInstall) + { + // Leave the plist on disk for boot/login; do not bootstrap now. + // bootstrap + RunAtLoad would start immediately and ignore --no-start. + return; + } + + await LaunchctlAsync("bootstrap", domain, plistPath).ConfigureAwait(false); + await LaunchctlAsync("kickstart", "-k", domain + "/" + label).ConfigureAwait(false); + AsyncConsole.WriteLine($"Service '{label}' started."); + } + + public async Task UninstallAsync(string name, bool user) + { + if (!user) + { + EnsureRoot(); + } + + var label = ServiceDefaults.ResolveMacOsLabel(name); + var userHome = user ? ResolveUserHome() : null; + var plistPath = ServiceUnitFactory.ResolveLaunchdPlistPath(label, user, userHome); + var domain = ResolveDomain(user); + await TryBootoutAsync(domain, plistPath, label).ConfigureAwait(false); + + if (File.Exists(plistPath)) + { + File.Delete(plistPath); + AsyncConsole.WriteLine($"Removed {plistPath}"); + } + + if (!user) + ServicePayload.TryDeleteDirectory(ServicePayload.MacOsDaemonPayloadDirectory(name)); + } + + public async Task StartAsync(string name, bool user) + { + if (!user) + { + EnsureRoot(); + } + + var label = ServiceDefaults.ResolveMacOsLabel(name); + var userHome = user ? ResolveUserHome() : null; + var plistPath = ServiceUnitFactory.ResolveLaunchdPlistPath(label, user, userHome); + if (!File.Exists(plistPath)) + { + throw new InvalidOperationException($"Service '{label}' is not installed."); + } + + var domain = ResolveDomain(user); + if (!await IsLoadedAsync(domain, label).ConfigureAwait(false)) + { + await LaunchctlAsync("bootstrap", domain, plistPath).ConfigureAwait(false); + } + + await LaunchctlAsync("kickstart", "-k", domain + "/" + label).ConfigureAwait(false); + AsyncConsole.WriteLine($"Service '{label}' started."); + } + + public async Task StopAsync(string name, bool user) + { + if (!user) + { + EnsureRoot(); + } + + var label = ServiceDefaults.ResolveMacOsLabel(name); + var userHome = user ? ResolveUserHome() : null; + var plistPath = ServiceUnitFactory.ResolveLaunchdPlistPath(label, user, userHome); + var domain = ResolveDomain(user); + // KeepAlive=true restarts after SIGTERM. bootout unloads the job and leaves the plist. + await TryBootoutAsync(domain, plistPath, label).ConfigureAwait(false); + AsyncConsole.WriteLine($"Service '{label}' stopped."); + } + + public async Task RestartAsync(string name, bool user) + { + await StopAsync(name, user).ConfigureAwait(false); + await StartAsync(name, user).ConfigureAwait(false); + } + + public async Task StatusAsync(string name, bool user) + { + var label = ServiceDefaults.ResolveMacOsLabel(name); + var userHome = user ? ResolveUserHome() : null; + var plistPath = ServiceUnitFactory.ResolveLaunchdPlistPath(label, user, userHome); + if (!File.Exists(plistPath)) + { + return new ServiceStatusResult(ServiceStatusKind.NotInstalled, label); + } + + var domain = ResolveDomain(user); + var (code, stdout, stderr) = await RunLaunchctlAsync("print", domain + "/" + label) + .ConfigureAwait(false); + var text = stdout + stderr; + if (code != 0 && text.Contains("Could not find", StringComparison.OrdinalIgnoreCase)) + { + return new ServiceStatusResult(ServiceStatusKind.Stopped, label, "not loaded"); + } + + // print output includes "state = running" when active. + if (text.Contains("state = running", StringComparison.OrdinalIgnoreCase) || + text.Contains("\"PID\" =", StringComparison.Ordinal)) + { + return new ServiceStatusResult(ServiceStatusKind.Running, label, "running"); + } + + return new ServiceStatusResult(ServiceStatusKind.Stopped, label, "loaded"); + } + + private static string ResolveDomain(bool user) + { + if (!user) + return "system"; + + var uid = ResolveTargetUid(); + if (uid == 0) + { + throw new InvalidOperationException( + "Cannot install a LaunchAgent as root (gui/0). Run without sudo, or invoke sudo from a logged-in user so SUDO_UID is set."); + } + + return $"gui/{uid}"; + } + + internal static uint ResolveTargetUid() + { + if (GetEuid() == 0) + { + var sudoUid = Environment.GetEnvironmentVariable("SUDO_UID"); + if (uint.TryParse(sudoUid, out var uid) && uid != 0) + return uid; + } + + return GetUid(); + } + + internal static string ResolveUserHome() + { + if (GetEuid() == 0) + { + var sudoUser = Environment.GetEnvironmentVariable("SUDO_USER"); + if (!string.IsNullOrEmpty(sudoUser)) + { + var home = new[] { "/Users/" + sudoUser, "/home/" + sudoUser }.FirstOrDefault(Directory.Exists); + if (home is not null) + return home; + } + } + + return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } + + private static void TryChmodExecute(string path) + { + try + { + if (!File.Exists(path)) + return; + var mode = File.GetUnixFileMode(path); + File.SetUnixFileMode( + path, + mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); + } + catch + { + // Best-effort; launchd will fail clearly if the binary is not executable. + } + } + + private static void EnsureRoot() + { + if (GetEuid() != 0) + { + throw new InvalidOperationException( + "Root privileges required for a LaunchDaemon. Re-run with sudo (or use --user for a LaunchAgent)."); + } + } + + [LibraryImport("libc", EntryPoint = "geteuid", SetLastError = true)] + private static partial uint GetEuid(); + + [LibraryImport("libc", EntryPoint = "getuid", SetLastError = true)] + private static partial uint GetUid(); + + private static async Task IsLoadedAsync(string domain, string label) + { + var (code, stdout, stderr) = await RunLaunchctlAsync("print", domain + "/" + label) + .ConfigureAwait(false); + if (code == 0) + return true; + var text = stdout + stderr; + return !text.Contains("Could not find", StringComparison.OrdinalIgnoreCase); + } + + private static async Task TryBootoutAsync(string domain, string plistPath, string label) + { + try + { + await LaunchctlAsync("bootout", domain, plistPath).ConfigureAwait(false); + return; + } + catch + { + // Not loaded, or launchctl wants domain/label. + } + + try + { + await LaunchctlAsync("bootout", domain + "/" + label).ConfigureAwait(false); + } + catch + { + // Already unloaded. + } + } + + private static async Task LaunchctlAsync(params string[] args) + { + var (code, stdout, stderr) = await RunLaunchctlAsync(args).ConfigureAwait(false); + if (code != 0) + { + var msg = (stdout + stderr).Trim(); + throw new InvalidOperationException( + string.IsNullOrEmpty(msg) + ? $"launchctl exited with code {code}." + : msg); + } + } + + private static async Task<(int Code, string Stdout, string Stderr)> RunLaunchctlAsync( + params string[] args) + { + const string launchctl = "/bin/launchctl"; + if (!File.Exists(launchctl)) + { + throw new InvalidOperationException($"{launchctl} not found."); + } + + var psi = new ProcessStartInfo + { + FileName = launchctl, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (var a in args) + { + psi.ArgumentList.Add(a); + } + + using var proc = Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start launchctl."); + var stdout = await proc.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + var stderr = await proc.StandardError.ReadToEndAsync().ConfigureAwait(false); + await proc.WaitForExitAsync().ConfigureAwait(false); + return (proc.ExitCode, stdout, stderr); + } +} diff --git a/src/Titanium.Cli/Service/PrivilegePrompt.cs b/src/Titanium.Cli/Service/PrivilegePrompt.cs new file mode 100644 index 000000000..84b960c19 --- /dev/null +++ b/src/Titanium.Cli/Service/PrivilegePrompt.cs @@ -0,0 +1,288 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Titanium.Cli.Service; + +/// +/// Interactive elevation for machine-service commands: Windows UAC or Unix sudo. +/// Non-interactive sessions (CI, redirected IO, TITANIUM_NO_ELEVATE=1) skip the +/// prompt so the existing Administrator/sudo error can be printed. +/// +internal static partial class PrivilegePrompt +{ + internal const string RelaunchFlag = "--internal-elevated-relaunch"; + internal const string ParentPidFlag = "--internal-parent-pid"; + internal const string NoElevateEnv = "TITANIUM_NO_ELEVATE"; + private static readonly string[] SudoCandidates = ["/usr/bin/sudo", "/usr/local/bin/sudo"]; + + internal static bool HasRelaunchFlag { get; private set; } + internal static uint? ParentPid { get; private set; } + + internal static void ResetForTests() + { + HasRelaunchFlag = false; + ParentPid = null; + } + + internal static bool IsElevated() => Environment.IsPrivilegedProcess; + + /// Strip hidden relaunch flags so command parsers never see them. + internal static string[] TakeInternalArgs(string[] args) + { + var list = new List(args.Length); + var i = 0; + while (i < args.Length) + { + if (args[i] == RelaunchFlag) + { + HasRelaunchFlag = true; + i++; + continue; + } + + if (args[i] == ParentPidFlag && i + 1 < args.Length + && uint.TryParse(args[i + 1], out var pid)) + { + ParentPid = pid; + i += 2; + continue; + } + + list.Add(args[i]); + i++; + } + + return list.ToArray(); + } + + /// + /// After a Windows UAC relaunch, attach to the original console so output stays + /// in the user's terminal instead of a new window. + /// + internal static void TryAttachParentConsole() + { + if (!OperatingSystem.IsWindows() || ParentPid is not { } pid) + { + return; + } + + try + { + FreeConsole(); + if (!AttachConsole(pid)) + { + return; + } + + Console.SetOut(new StreamWriter(Console.OpenStandardOutput()) { AutoFlush = true }); + Console.SetError(new StreamWriter(Console.OpenStandardError()) { AutoFlush = true }); + } + catch + { + // Keep the elevated console Windows allocated. + } + } + + /// + /// when this process should continue (already elevated, or + /// not interactive — caller prints the fallback). An is a final + /// exit code (relaunched child, cancelled prompt, or failed relaunch). + /// + internal static async Task EnsureOrRelaunchAsync(string[] commandArgs) + { + if (IsElevated()) + { + return null; + } + + if (HasRelaunchFlag) + { + AsyncConsole.WriteError(FallbackMessage()); + return 1; + } + + if (!CanPromptInteractively()) + { + return null; + } + + var relaunchArgs = AbsolutizeConfigArgs(commandArgs); + AsyncConsole.WriteLine(InteractivePromptMessage()); + await AsyncConsole.FlushAsync().ConfigureAwait(false); + + return OperatingSystem.IsWindows() + ? await RelaunchWindowsAsync(relaunchArgs).ConfigureAwait(false) + : await RelaunchSudoAsync(relaunchArgs).ConfigureAwait(false); + } + + internal static bool CanPromptInteractively() + { + if (string.Equals(Environment.GetEnvironmentVariable(NoElevateEnv), "1", StringComparison.Ordinal)) + { + return false; + } + + try + { + return !Console.IsInputRedirected && !Console.IsOutputRedirected; + } + catch + { + return false; + } + } + + internal static string InteractivePromptMessage() + { + if (OperatingSystem.IsWindows()) + { + return "Administrator permission is required. Approve the Windows security prompt to continue."; + } + + return "Root permission is required. Enter your password if asked (sudo)."; + } + + internal static string FallbackMessage() + { + if (OperatingSystem.IsWindows()) + { + return "Administrator privileges required. Re-run from an elevated prompt (Run as Administrator)."; + } + + return "Root privileges required. Re-run with sudo (or use --user)."; + } + + internal static string[] AbsolutizeConfigArgs(string[] args) + { + var copy = args.ToArray(); + for (var i = 0; i < copy.Length - 1; i++) + { + if (copy[i] is "-c" or "--config") + { + try + { + copy[i + 1] = Path.GetFullPath(copy[i + 1]); + } + catch + { + // Leave as-is; install validation will report the path error. + } + } + } + + return copy; + } + + internal static string JoinWindowsArguments(IEnumerable args) => + string.Join(' ', args.Select(ServiceUnitFactory.QuoteWindowsArg)); + + [SupportedOSPlatform("windows")] + private static async Task RelaunchWindowsAsync(string[] commandArgs) + { + var (fileName, prefix) = ServiceDefaults.ResolveRelaunchTarget(); + var forwarded = new List(prefix); + forwarded.AddRange(commandArgs); + forwarded.Add(RelaunchFlag); + forwarded.Add(ParentPidFlag); + forwarded.Add(Environment.ProcessId.ToString()); + + var psi = new ProcessStartInfo + { + FileName = fileName, + Arguments = JoinWindowsArguments(forwarded), + UseShellExecute = true, + Verb = "runas", + WorkingDirectory = Environment.CurrentDirectory, + ErrorDialog = false, + }; + + try + { + using var proc = Process.Start(psi); + if (proc is null) + { + AsyncConsole.WriteError("Permission prompt cancelled."); + return 1; + } + + await proc.WaitForExitAsync().ConfigureAwait(false); + return proc.ExitCode; + } + catch (Win32Exception ex) when (ex.NativeErrorCode == 1223) + { + AsyncConsole.WriteError("Permission prompt cancelled."); + return 1; + } + catch (Win32Exception ex) + { + AsyncConsole.WriteError(ex.Message); + AsyncConsole.WriteError(FallbackMessage()); + return 1; + } + } + + private static async Task RelaunchSudoAsync(string[] commandArgs) + { + var sudo = ResolveSudoPath(); + if (sudo is null) + { + AsyncConsole.WriteError(FallbackMessage()); + return 1; + } + + var (fileName, prefix) = ServiceDefaults.ResolveRelaunchTarget(); + var psi = new ProcessStartInfo + { + FileName = sudo, + UseShellExecute = false, + WorkingDirectory = Environment.CurrentDirectory, + }; + psi.ArgumentList.Add("--"); + psi.ArgumentList.Add(fileName); + foreach (var a in prefix) + { + psi.ArgumentList.Add(a); + } + + foreach (var a in commandArgs) + { + psi.ArgumentList.Add(a); + } + + psi.ArgumentList.Add(RelaunchFlag); + + try + { + using var proc = Process.Start(psi); + if (proc is null) + { + AsyncConsole.WriteError(FallbackMessage()); + return 1; + } + + await proc.WaitForExitAsync().ConfigureAwait(false); + return proc.ExitCode; + } + catch (Exception ex) + { + AsyncConsole.WriteError(ex.Message); + AsyncConsole.WriteError(FallbackMessage()); + return 1; + } + } + + internal static string? ResolveSudoPath() => + SudoCandidates.FirstOrDefault(File.Exists); + + [SupportedOSPlatform("windows")] + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool AttachConsole(uint dwProcessId); + + [SupportedOSPlatform("windows")] + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool FreeConsole(); +} diff --git a/src/Titanium.Cli/Service/ServiceCommand.cs b/src/Titanium.Cli/Service/ServiceCommand.cs new file mode 100644 index 000000000..68ace89da --- /dev/null +++ b/src/Titanium.Cli/Service/ServiceCommand.cs @@ -0,0 +1,332 @@ +using Titanium.Cli.Config; +using Titanium.Cli.Parsers; +using Titanium.Web.Proxy.Configuration; + +namespace Titanium.Cli.Service; + +internal static class ServiceCommand +{ + public static async Task ExecuteAsync(string[] args) + { + // args[0] == "service" + if (args.Length < 2 || CliHelp.IsHelpToken(args[1])) + { + return PrintHelp(); + } + + var sub = args[1].ToLowerInvariant(); + var rest = args.AsSpan(2); + if (CliHelp.RequestsHelp(rest)) + { + return PrintSubHelp(sub); + } + + try + { + return sub switch + { + "install" => await InstallAsync(args).ConfigureAwait(false), + "uninstall" => await UninstallAsync(args).ConfigureAwait(false), + "start" => await StartAsync(args).ConfigureAwait(false), + "stop" => await StopAsync(args).ConfigureAwait(false), + "restart" => await RestartAsync(args).ConfigureAwait(false), + "status" => await StatusAsync(args).ConfigureAwait(false), + "help" or "-h" or "--help" => PrintHelp(), + _ => Unknown(sub), + }; + } + catch (Exception ex) + { + AsyncConsole.WriteError(ex.Message); + return 1; + } + } + + internal static int PrintHelp() + { + AsyncConsole.WriteLine(""" + titanium service install|uninstall|start|stop|restart|status + + install Register the proxy as an OS service (starts at boot). + uninstall Remove the OS service registration. + start Start the service. + stop Stop the service. + restart Stop then start. + status Print installed / running / stopped. + + Flags (most subcommands): + --name Service name (default: titanium). macOS label becomes + com.justcoding121. unless name already starts with com. + --user Per-user service (systemd --user / LaunchAgent). No root; + ports 80/443 usually fail. Default is a machine service. + + install also requires: + -c, --config Config file used by the service. + --no-start Install and enable, but do not start immediately. + + Machine services need Administrator (Windows) or root (Linux/macOS). + In a terminal, Titanium asks the OS for permission (UAC / sudo). + """); + CliHelp.WriteDocsFooter(); + return 0; + } + + internal static int PrintSubHelp(string sub) => + sub.ToLowerInvariant() switch + { + "install" => PrintInstallHelp(), + "uninstall" => PrintSimpleHelp("uninstall", "Remove the OS service."), + "start" => PrintSimpleHelp("start", "Start the OS service."), + "stop" => PrintSimpleHelp("stop", "Stop the OS service."), + "restart" => PrintSimpleHelp("restart", "Restart the OS service."), + "status" => PrintSimpleHelp("status", "Show whether the service is installed and running."), + _ => Unknown(sub), + }; + + private static int PrintInstallHelp() + { + AsyncConsole.WriteLine(""" + titanium service install -c [--name titanium] [--user] [--no-start] + + -c, --config Config path (validated before install; stored as an absolute path). + --name Service / unit name (default: titanium). + --user Per-user systemd unit or LaunchAgent (no elevation). + --no-start Do not start immediately after install. + + The unit runs: titanium run -c --service + Working directory is the config file's directory. + Machine install asks the OS for permission when needed (UAC / sudo). + """); + CliHelp.WriteDocsFooter(); + return 0; + } + + private static int PrintSimpleHelp(string sub, string purpose) + { + AsyncConsole.WriteLine($""" + titanium service {sub} [--name titanium] [--user] + + {purpose} + --name Service name (default: titanium). + --user Target the per-user service instead of the machine service. + """); + CliHelp.WriteDocsFooter(); + return 0; + } + + private static int Unknown(string sub) + { + AsyncConsole.WriteError($"Unknown service subcommand: {sub}"); + PrintHelp(); + return 1; + } + + private static async Task InstallAsync(string[] args) + { + var configPath = ParseConfigPathRequired(args); + var name = ParseName(args); + var user = ParseUser(args); + var noStart = args.Contains("--no-start", StringComparer.OrdinalIgnoreCase); + + if (user && OperatingSystem.IsWindows()) + { + throw new ArgumentException( + "--user is not supported on Windows (machine Windows Service only). Omit --user."); + } + + // Validate config before writing any unit (and before an OS permission prompt). + var loaded = ConfigLoader.Load(configPath); + var errors = TwpConfigValidator.Validate(loaded.Config); + if (errors.Count > 0) + { + foreach (var e in errors) + { + AsyncConsole.WriteError(e); + } + + return 1; + } + + if (!user) + { + var elevation = await PrivilegePrompt.EnsureOrRelaunchAsync(args).ConfigureAwait(false); + if (elevation is int elevatedCode) + { + return elevatedCode; + } + } + + var absConfig = Path.GetFullPath(configPath); + var workDir = Path.GetDirectoryName(absConfig) + ?? throw new InvalidOperationException("Unable to resolve config directory."); + var manager = CreateManager(); + await manager.InstallAsync(new ServiceInstallRequest( + name, + absConfig, + user, + StartAfterInstall: !noStart, + ServiceDefaults.ResolveProgramPrefix(), + workDir, + ServiceDefaults.ResolveServiceEnvironment())).ConfigureAwait(false); + AsyncConsole.WriteLine($"Service '{name}' installed."); + return 0; + } + + private static async Task UninstallAsync(string[] args) + { + if (await ElevateMachineServiceAsync(args).ConfigureAwait(false) is int code) + { + return code; + } + + var manager = CreateManager(); + await manager.UninstallAsync(ParseName(args), ParseUser(args)).ConfigureAwait(false); + return 0; + } + + private static async Task StartAsync(string[] args) + { + if (await ElevateMachineServiceAsync(args).ConfigureAwait(false) is int code) + { + return code; + } + + var manager = CreateManager(); + await manager.StartAsync(ParseName(args), ParseUser(args)).ConfigureAwait(false); + return 0; + } + + private static async Task StopAsync(string[] args) + { + if (await ElevateMachineServiceAsync(args).ConfigureAwait(false) is int code) + { + return code; + } + + var manager = CreateManager(); + await manager.StopAsync(ParseName(args), ParseUser(args)).ConfigureAwait(false); + return 0; + } + + private static async Task RestartAsync(string[] args) + { + if (await ElevateMachineServiceAsync(args).ConfigureAwait(false) is int code) + { + return code; + } + + var manager = CreateManager(); + await manager.RestartAsync(ParseName(args), ParseUser(args)).ConfigureAwait(false); + return 0; + } + + /// + /// Machine services need admin/root. --user (Linux/macOS) does not. + /// Returns an exit code when this process should stop (OS prompt finished). + /// + private static Task ElevateMachineServiceAsync(string[] args) => + ParseUser(args) + ? Task.FromResult(null) + : PrivilegePrompt.EnsureOrRelaunchAsync(args); + + private static async Task StatusAsync(string[] args) + { + var name = ParseName(args); + var user = ParseUser(args); + var manager = CreateManager(); + var status = await manager.StatusAsync(name, user).ConfigureAwait(false); + var label = status.Kind switch + { + ServiceStatusKind.NotInstalled => "not installed", + ServiceStatusKind.Running => "running", + ServiceStatusKind.Stopped => "stopped", + _ => status.Detail ?? "unknown", + }; + AsyncConsole.WriteLine($"Service '{status.Name}': {label}"); + return status.Kind == ServiceStatusKind.NotInstalled ? 1 : 0; + } + + internal static IOsServiceManager CreateManager() + { + if (OperatingSystem.IsWindows()) + { + return new WindowsServiceManager(); + } + + if (OperatingSystem.IsLinux()) + { + return new SystemdServiceManager(); + } + + if (OperatingSystem.IsMacOS()) + { + return new LaunchdServiceManager(); + } + + throw new PlatformNotSupportedException( + "OS service install is supported on Windows, Linux (systemd), and macOS (launchd) only."); + } + + /// Best-effort check used by titanium update to warn about a locked exe. + internal static async Task IsDefaultServiceRunningAsync() + { + try + { + var manager = CreateManager(); + var status = await manager.StatusAsync(ServiceDefaults.DefaultServiceName, user: false) + .ConfigureAwait(false); + if (status.Kind == ServiceStatusKind.Running) + { + return true; + } + + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + var userStatus = await manager.StatusAsync(ServiceDefaults.DefaultServiceName, user: true) + .ConfigureAwait(false); + return userStatus.Kind == ServiceStatusKind.Running; + } + } + catch + { + // Ignore probe failures during update. + } + + return false; + } + + internal static string ParseName(string[] args) + { + for (var i = 0; i < args.Length; i++) + { + if (args[i] is "--name" && i + 1 < args.Length) + { + var n = args[i + 1].Trim(); + if (string.IsNullOrEmpty(n)) + { + throw new ArgumentException("--name requires a non-empty value."); + } + + return n; + } + } + + return ServiceDefaults.DefaultServiceName; + } + + internal static bool ParseUser(string[] args) => + args.Contains("--user", StringComparer.OrdinalIgnoreCase); + + private static string ParseConfigPathRequired(string[] args) + { + for (var i = 0; i < args.Length; i++) + { + if ((args[i] is "-c" or "--config") && i + 1 < args.Length) + { + return args[i + 1]; + } + } + + throw new ArgumentException("Missing required -c for service install."); + } +} diff --git a/src/Titanium.Cli/Service/ServicePayload.cs b/src/Titanium.Cli/Service/ServicePayload.cs new file mode 100644 index 000000000..9ba2d2334 --- /dev/null +++ b/src/Titanium.Cli/Service/ServicePayload.cs @@ -0,0 +1,81 @@ +namespace Titanium.Cli.Service; + +/// +/// macOS LaunchDaemons cannot read TCC-protected locations (Documents, Desktop, Downloads), +/// even as root. Machine-service install copies the CLI payload to a system path. +/// +internal static class ServicePayload +{ + public static string MacOsDaemonPayloadDirectory(string serviceName) => + "/Library/Application Support/Titanium/services/" + serviceName; + + public static string? DiscoverAppDirectory(IReadOnlyList programPrefix) + { + var dll = programPrefix.FirstOrDefault(p => p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)); + if (dll is not null) + return Path.GetDirectoryName(Path.GetFullPath(dll)); + + if (programPrefix.Count > 0) + return Path.GetDirectoryName(Path.GetFullPath(programPrefix[0])); + + return null; + } + + public static string[] RemapPrefix(IReadOnlyList programPrefix, string sourceDir, string destDir) + { + var src = TrimSep(Path.GetFullPath(sourceDir)); + var dst = TrimSep(Path.GetFullPath(destDir)); + var result = new string[programPrefix.Count]; + for (var i = 0; i < programPrefix.Count; i++) + { + var full = Path.GetFullPath(programPrefix[i]); + if (full.StartsWith(src + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || + full.Equals(src, StringComparison.OrdinalIgnoreCase)) + { + result[i] = dst + full[src.Length..]; + } + else + { + result[i] = full; + } + } + + return result; + } + + public static void CopyDirectory(string sourceDir, string destDir) + { + sourceDir = Path.GetFullPath(sourceDir); + destDir = Path.GetFullPath(destDir); + if (sourceDir.Equals(destDir, StringComparison.OrdinalIgnoreCase)) + return; + + Directory.CreateDirectory(destDir); + foreach (var file in Directory.EnumerateFiles(sourceDir)) + { + var destFile = Path.Combine(destDir, Path.GetFileName(file)); + File.Copy(file, destFile, overwrite: true); + } + + foreach (var dir in Directory.EnumerateDirectories(sourceDir)) + { + CopyDirectory(dir, Path.Combine(destDir, Path.GetFileName(dir))); + } + } + + public static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch + { + // Best-effort uninstall. + } + } + + private static string TrimSep(string path) => + path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); +} diff --git a/src/Titanium.Cli/Service/ServiceUnitFactory.cs b/src/Titanium.Cli/Service/ServiceUnitFactory.cs new file mode 100644 index 000000000..2977768a6 --- /dev/null +++ b/src/Titanium.Cli/Service/ServiceUnitFactory.cs @@ -0,0 +1,204 @@ +using System.Text; +using System.Xml.Linq; + +namespace Titanium.Cli.Service; + +/// Pure builders for Windows binPath, systemd unit, and launchd plist text. +internal static class ServiceUnitFactory +{ + private const string PlistStringElement = "string"; + public static string BuildWindowsBinPath(string exePath, string configPath, string serviceName) => + BuildWindowsBinPath([exePath], configPath, serviceName); + + public static string BuildWindowsBinPath( + IReadOnlyList programPrefix, + string configPath, + string serviceName) + { + // sc.exe binPath= expects a single string; quote exe and config when they contain spaces. + var prefix = string.Join(" ", programPrefix.Select(QuoteWindowsArg)); + var cfg = QuoteWindowsArg(configPath); + var name = QuoteWindowsArg(serviceName); + return $"{prefix} run -c {cfg} --service --name {name}"; + } + + public static string BuildSystemdUnit( + string exePath, + string configPath, + string workingDirectory, + bool user) => + BuildSystemdUnit([exePath], configPath, workingDirectory, user); + + public static string BuildSystemdUnit( + IReadOnlyList programPrefix, + string configPath, + string workingDirectory, + bool user, + IReadOnlyDictionary? environment = null) + { + var wantedBy = user ? "default.target" : "multi-user.target"; + var exec = string.Join(" ", programPrefix.Select(EscapeSystemdArg)); + var sb = new StringBuilder(); + sb.AppendLine("[Unit]"); + sb.AppendLine($"Description={ServiceDefaults.Description}"); + sb.AppendLine("After=network-online.target"); + sb.AppendLine("Wants=network-online.target"); + sb.AppendLine(); + sb.AppendLine("[Service]"); + sb.AppendLine("Type=simple"); + sb.AppendLine($"ExecStart={exec} run -c {EscapeSystemdArg(configPath)} --service"); + sb.AppendLine($"WorkingDirectory={EscapeSystemdArg(workingDirectory)}"); + sb.AppendLine("Restart=on-failure"); + sb.AppendLine("RestartSec=5"); + if (environment is not null) + { + foreach (var kv in environment) + sb.AppendLine($"Environment={kv.Key}={EscapeSystemdArg(kv.Value)}"); + } + + sb.AppendLine(); + sb.AppendLine("[Install]"); + sb.AppendLine($"WantedBy={wantedBy}"); + return sb.ToString(); + } + + public static string BuildLaunchdPlist( + string label, + string exePath, + string configPath, + string workingDirectory, + string standardOutPath, + string standardErrorPath) => + BuildLaunchdPlist( + label, + [exePath], + configPath, + workingDirectory, + standardOutPath, + standardErrorPath); + + public static string BuildLaunchdPlist( + string label, + IReadOnlyList programPrefix, + string configPath, + string workingDirectory, + string standardOutPath, + string standardErrorPath, + IReadOnlyDictionary? environment = null) + { + var args = new XElement("array"); + foreach (var part in programPrefix) + args.Add(new XElement(PlistStringElement, part)); + args.Add(new XElement(PlistStringElement, "run")); + args.Add(new XElement(PlistStringElement, "-c")); + args.Add(new XElement(PlistStringElement, configPath)); + args.Add(new XElement(PlistStringElement, "--service")); + + var dict = new XElement("dict", + new XElement("key", "Label"), + new XElement(PlistStringElement, label), + new XElement("key", "ProgramArguments"), + args, + new XElement("key", "WorkingDirectory"), + new XElement(PlistStringElement, workingDirectory), + new XElement("key", "RunAtLoad"), + new XElement("true"), + new XElement("key", "KeepAlive"), + new XElement("true"), + new XElement("key", "StandardOutPath"), + new XElement(PlistStringElement, standardOutPath), + new XElement("key", "StandardErrorPath"), + new XElement(PlistStringElement, standardErrorPath)); + + if (environment is { Count: > 0 }) + { + var envDict = new XElement("dict"); + foreach (var kv in environment) + { + envDict.Add(new XElement("key", kv.Key)); + envDict.Add(new XElement(PlistStringElement, kv.Value)); + } + + dict.Add(new XElement("key", "EnvironmentVariables")); + dict.Add(envDict); + } + + var doc = new XDocument( + new XDeclaration("1.0", "UTF-8", null), + new XDocumentType("plist", "-//Apple//DTD PLIST 1.0//EN", + "http://www.apple.com/DTDs/PropertyList-1.0.dtd", null), // NOSONAR S5332 -- Apple PLIST DTD public identifier is http. + new XElement("plist", + new XAttribute("version", "1.0"), + dict)); + + return doc.Declaration + Environment.NewLine + doc.ToString(SaveOptions.None) + Environment.NewLine; + } + + public static string ResolveSystemdUnitPath(string name, bool user) + { + if (user) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return home.Replace('\\', '/') + "/.config/systemd/user/" + name + ".service"; + } + + return "/etc/systemd/system/" + name + ".service"; + } + + public static string ResolveLaunchdPlistPath(string label, bool user, string? userHome = null) + { + if (user) + { + var home = userHome ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return home.Replace('\\', '/') + "/Library/LaunchAgents/" + label + ".plist"; + } + + return "/Library/LaunchDaemons/" + label + ".plist"; + } + + public static string ResolveLaunchdLogDirectory(bool user, string? userHome = null) + { + if (user) + { + var home = userHome ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return home.Replace('\\', '/') + "/Library/Logs/Titanium"; + } + + return "/Library/Logs/Titanium"; + } + + internal static string QuoteWindowsArg(string value) + { + if (value.Length == 0) + { + return "\"\""; + } + + if (value.Contains(' ', StringComparison.Ordinal) || + value.Contains('\t', StringComparison.Ordinal) || + value.Contains('"', StringComparison.Ordinal)) + { + return "\"" + value.Replace("\"", "\\\"", StringComparison.Ordinal) + "\""; + } + + return value; + } + + /// systemd ExecStart: quote if needed; escape `$`, `%`, and `\`. + internal static string EscapeSystemdArg(string value) + { + var escaped = value + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("%", "%%", StringComparison.Ordinal) + .Replace("$", "$$", StringComparison.Ordinal); + + if (escaped.Contains(' ', StringComparison.Ordinal) || + escaped.Contains('\t', StringComparison.Ordinal) || + escaped.Contains('"', StringComparison.Ordinal)) + { + return "\"" + escaped.Replace("\"", "\\\"", StringComparison.Ordinal) + "\""; + } + + return escaped; + } +} diff --git a/src/Titanium.Cli/Service/SystemdServiceManager.cs b/src/Titanium.Cli/Service/SystemdServiceManager.cs new file mode 100644 index 000000000..bc5ead19b --- /dev/null +++ b/src/Titanium.Cli/Service/SystemdServiceManager.cs @@ -0,0 +1,209 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Text; + +namespace Titanium.Cli.Service; + +[SupportedOSPlatform("linux")] +internal sealed partial class SystemdServiceManager : IOsServiceManager +{ + private const string UnitSuffix = ".service"; + + private static string UnitName(string name) => name + UnitSuffix; + + public async Task InstallAsync(ServiceInstallRequest request) + { + if (!request.User) + { + EnsureRoot(); + } + + var unitPath = ServiceUnitFactory.ResolveSystemdUnitPath(request.Name, request.User); + var dir = Path.GetDirectoryName(unitPath)!; + Directory.CreateDirectory(dir); + + var unit = ServiceUnitFactory.BuildSystemdUnit( + request.ProgramPrefix, + request.ConfigPath, + request.WorkingDirectory, + request.User, + request.EnvironmentVariables); + await File.WriteAllTextAsync(unitPath, unit, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)) + .ConfigureAwait(false); + AsyncConsole.WriteLine($"Wrote {unitPath}"); + + await SystemctlAsync(request.User, "daemon-reload").ConfigureAwait(false); + await SystemctlAsync(request.User, "enable", UnitName(request.Name)).ConfigureAwait(false); + + if (request.User) + { + AsyncConsole.WriteLine( + "Note: for start-at-boot without an interactive login, run: loginctl enable-linger $USER"); + } + + if (request.StartAfterInstall) + { + await StartAsync(request.Name, request.User).ConfigureAwait(false); + } + } + + public async Task UninstallAsync(string name, bool user) + { + if (!user) + { + EnsureRoot(); + } + + var unit = UnitName(name); + try + { + await SystemctlAsync(user, "stop", unit).ConfigureAwait(false); + } + catch + { + // May already be stopped / missing. + } + + try + { + await SystemctlAsync(user, "disable", unit).ConfigureAwait(false); + } + catch + { + // May not be enabled. + } + + var unitPath = ServiceUnitFactory.ResolveSystemdUnitPath(name, user); + if (File.Exists(unitPath)) + { + File.Delete(unitPath); + AsyncConsole.WriteLine($"Removed {unitPath}"); + } + + await SystemctlAsync(user, "daemon-reload").ConfigureAwait(false); + } + + public async Task StartAsync(string name, bool user) + { + if (!user) + { + EnsureRoot(); + } + + await SystemctlAsync(user, "start", UnitName(name)).ConfigureAwait(false); + AsyncConsole.WriteLine($"Service '{name}' started."); + } + + public async Task StopAsync(string name, bool user) + { + if (!user) + { + EnsureRoot(); + } + + await SystemctlAsync(user, "stop", UnitName(name)).ConfigureAwait(false); + AsyncConsole.WriteLine($"Service '{name}' stopped."); + } + + public async Task RestartAsync(string name, bool user) + { + if (!user) + { + EnsureRoot(); + } + + await SystemctlAsync(user, "restart", UnitName(name)).ConfigureAwait(false); + AsyncConsole.WriteLine($"Service '{name}' restarted."); + } + + public async Task StatusAsync(string name, bool user) + { + var unitPath = ServiceUnitFactory.ResolveSystemdUnitPath(name, user); + if (!File.Exists(unitPath)) + { + return new ServiceStatusResult(ServiceStatusKind.NotInstalled, name); + } + + var (code, stdout) = await SystemctlCaptureAsync(user, "is-active", UnitName(name)) + .ConfigureAwait(false); + var state = stdout.Trim(); + var kind = state switch + { + "active" => ServiceStatusKind.Running, + "inactive" or "failed" => ServiceStatusKind.Stopped, + _ => ServiceStatusKind.Other, + }; + return new ServiceStatusResult(kind, name, string.IsNullOrEmpty(state) ? $"exit {code}" : state); + } + + private static void EnsureRoot() + { + if (!IsRoot()) + { + throw new InvalidOperationException( + "Root privileges required for a system service. Re-run with sudo (or use --user)."); + } + } + + internal static bool IsRoot() => + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && GetEuid() == 0; + + [LibraryImport("libc", EntryPoint = "geteuid", SetLastError = true)] + private static partial uint GetEuid(); + + private static async Task SystemctlAsync(bool user, params string[] args) + { + var (code, stdout, stderr) = await RunSystemctlAsync(user, args).ConfigureAwait(false); + if (code != 0) + { + var msg = (stdout + stderr).Trim(); + throw new InvalidOperationException( + string.IsNullOrEmpty(msg) + ? $"systemctl exited with code {code}." + : msg); + } + } + + private static async Task<(int Code, string Stdout)> SystemctlCaptureAsync(bool user, params string[] args) + { + var (code, stdout, _) = await RunSystemctlAsync(user, args).ConfigureAwait(false); + return (code, stdout); + } + + private static async Task<(int Code, string Stdout, string Stderr)> RunSystemctlAsync( + bool user, + params string[] args) + { + const string systemctl = "/usr/bin/systemctl"; + if (!File.Exists(systemctl)) + { + throw new InvalidOperationException($"{systemctl} not found. systemd is required on Linux."); + } + + var psi = new ProcessStartInfo + { + FileName = systemctl, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + if (user) + { + psi.ArgumentList.Add("--user"); + } + + foreach (var a in args) + { + psi.ArgumentList.Add(a); + } + + using var proc = Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start systemctl."); + var stdout = await proc.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + var stderr = await proc.StandardError.ReadToEndAsync().ConfigureAwait(false); + await proc.WaitForExitAsync().ConfigureAwait(false); + return (proc.ExitCode, stdout, stderr); + } +} diff --git a/src/Titanium.Cli/Service/WindowsServiceManager.cs b/src/Titanium.Cli/Service/WindowsServiceManager.cs new file mode 100644 index 000000000..0993fd3fa --- /dev/null +++ b/src/Titanium.Cli/Service/WindowsServiceManager.cs @@ -0,0 +1,208 @@ +using System.Diagnostics; +using System.Runtime.Versioning; +using System.ServiceProcess; + +namespace Titanium.Cli.Service; + +[SupportedOSPlatform("windows")] +internal sealed class WindowsServiceManager : IOsServiceManager +{ + public async Task InstallAsync(ServiceInstallRequest request) + { + EnsureElevated(); + var binPath = ServiceUnitFactory.BuildWindowsBinPath( + request.ProgramPrefix, request.ConfigPath, request.Name); + + // Delete existing if present so reinstall is idempotent. + if (ServiceExists(request.Name)) + { + await StopQuietAsync(request.Name).ConfigureAwait(false); + await RunScAsync("delete", request.Name).ConfigureAwait(false); + await Task.Delay(500).ConfigureAwait(false); + } + + await RunScAsync( + "create", + request.Name, + "binPath=", binPath, + "start=", "auto", + "DisplayName=", ServiceDefaults.DisplayName).ConfigureAwait(false); + + await RunScAsync( + "description", + request.Name, + ServiceDefaults.Description).ConfigureAwait(false); + + // Restart on failure after 5 seconds (reset period 60s). + await RunScAsync( + "failure", + request.Name, + "reset=", "60", + "actions=", "restart/5000/restart/5000/restart/5000").ConfigureAwait(false); + + if (request.StartAfterInstall) + { + await StartAsync(request.Name, user: false).ConfigureAwait(false); + } + } + + public async Task UninstallAsync(string name, bool user) + { + _ = user; + EnsureElevated(); + if (!ServiceExists(name)) + { + AsyncConsole.WriteLine($"Service '{name}' is not installed."); + return; + } + + await StopQuietAsync(name).ConfigureAwait(false); + await RunScAsync("delete", name).ConfigureAwait(false); + AsyncConsole.WriteLine($"Service '{name}' removed."); + } + + public Task StartAsync(string name, bool user) + { + _ = user; + EnsureElevated(); + using var sc = new ServiceController(name); + if (sc.Status is ServiceControllerStatus.Running or ServiceControllerStatus.StartPending) + { + AsyncConsole.WriteLine($"Service '{name}' is already running."); + return Task.CompletedTask; + } + + sc.Start(); + sc.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(60)); + AsyncConsole.WriteLine($"Service '{name}' started."); + return Task.CompletedTask; + } + + public Task StopAsync(string name, bool user) + { + _ = user; + EnsureElevated(); + using var sc = new ServiceController(name); + if (sc.Status is ServiceControllerStatus.Stopped or ServiceControllerStatus.StopPending) + { + AsyncConsole.WriteLine($"Service '{name}' is already stopped."); + return Task.CompletedTask; + } + + sc.Stop(); + sc.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(60)); + AsyncConsole.WriteLine($"Service '{name}' stopped."); + return Task.CompletedTask; + } + + public async Task RestartAsync(string name, bool user) + { + await StopAsync(name, user).ConfigureAwait(false); + await StartAsync(name, user).ConfigureAwait(false); + } + + public Task StatusAsync(string name, bool user) + { + _ = user; + if (!ServiceExists(name)) + { + return Task.FromResult(new ServiceStatusResult(ServiceStatusKind.NotInstalled, name)); + } + + using var sc = new ServiceController(name); + var kind = sc.Status switch + { + ServiceControllerStatus.Running => ServiceStatusKind.Running, + ServiceControllerStatus.Stopped => ServiceStatusKind.Stopped, + _ => ServiceStatusKind.Other, + }; + return Task.FromResult(new ServiceStatusResult(kind, name, sc.Status.ToString())); + } + + private static bool ServiceExists(string name) + { + try + { + using var sc = new ServiceController(name); + _ = sc.Status; + return true; + } + catch (InvalidOperationException) + { + return false; + } + } + + private static async Task StopQuietAsync(string name) + { + try + { + using var sc = new ServiceController(name); + if (sc.Status is not (ServiceControllerStatus.Stopped or ServiceControllerStatus.StopPending)) + { + sc.Stop(); + sc.WaitForStatus(ServiceControllerStatus.Stopped, TimeSpan.FromSeconds(60)); + } + } + catch + { + // Best-effort before delete. + } + + await Task.CompletedTask.ConfigureAwait(false); + } + + private static void EnsureElevated() + { + if (!IsElevated()) + { + throw new InvalidOperationException( + "Administrator privileges required. Re-run from an elevated prompt (Run as Administrator)."); + } + } + + internal static bool IsElevated() + { + using var identity = System.Security.Principal.WindowsIdentity.GetCurrent(); + var principal = new System.Security.Principal.WindowsPrincipal(identity); + return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator); + } + + private static async Task RunScAsync(params string[] args) + { + var scPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.System), + "sc.exe"); + if (!File.Exists(scPath)) + { + throw new InvalidOperationException($"sc.exe not found at {scPath}."); + } + + var psi = new ProcessStartInfo + { + FileName = scPath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (var a in args) + { + psi.ArgumentList.Add(a); + } + + using var proc = Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start sc.exe."); + var stdout = await proc.StandardOutput.ReadToEndAsync().ConfigureAwait(false); + var stderr = await proc.StandardError.ReadToEndAsync().ConfigureAwait(false); + await proc.WaitForExitAsync().ConfigureAwait(false); + if (proc.ExitCode != 0) + { + var msg = (stdout + stderr).Trim(); + throw new InvalidOperationException( + string.IsNullOrEmpty(msg) + ? $"sc.exe exited with code {proc.ExitCode}." + : msg); + } + } +} diff --git a/src/Titanium.Cli/Titanium.Cli.csproj b/src/Titanium.Cli/Titanium.Cli.csproj index db66f9bcd..197a04950 100644 --- a/src/Titanium.Cli/Titanium.Cli.csproj +++ b/src/Titanium.Cli/Titanium.Cli.csproj @@ -7,13 +7,14 @@ latest enable false - 7.0.4 + 7.0.5 Jehonathan Thomas Titanium Web Proxy CLI (titanium / twp). MIT titanium $(NoWarn);TWP001 + true @@ -29,5 +30,22 @@ + + + + + + + + <_Http3LaunchSettings>$(MSBuildProjectDirectory)\Properties\launchSettings.json + <_Http3TargetDir>$([System.IO.Path]::GetFullPath('$(TargetDir)').TrimEnd('\').TrimEnd('/')) + + + + diff --git a/src/Titanium.Cli/Updates/VersionAndUpdateCommands.cs b/src/Titanium.Cli/Updates/VersionAndUpdateCommands.cs index c72a8de15..d6e3fc0a9 100644 --- a/src/Titanium.Cli/Updates/VersionAndUpdateCommands.cs +++ b/src/Titanium.Cli/Updates/VersionAndUpdateCommands.cs @@ -6,18 +6,31 @@ using Titanium.Cli; using Titanium.Cli.Config; using Titanium.Cli.Http3; +using Titanium.Cli.Service; +using Titanium.Web.Proxy.Abstractions.Updates; namespace Titanium.Cli.Updates; internal static class VersionCommand { + internal const string StableChannel = "stable"; public static async Task ExecuteAsync(string[] args) { + if (CliHelp.RequestsHelp(args.AsSpan(1))) + { + return PrintHelp(); + } + var check = args.Contains("--check", StringComparer.OrdinalIgnoreCase); var plus = args.Contains("--plus", StringComparer.OrdinalIgnoreCase); - var channel = ParseChannel(args); - var channelDisplay = FormatChannel(channel); + if (!TryResolveChannel(args, out var channel, out var channelError)) + { + AsyncConsole.WriteError(channelError!); + return 1; + } + + var channelDisplay = FormatChannel(channel); PrintLocalVersions(plus); if (!check) @@ -26,50 +39,100 @@ public static async Task ExecuteAsync(string[] args) } var client = new UpdateFeedClient(channel); - var manifest = await client.TryGetManifestAsync(); + var (manifest, feedError) = await client.TryGetManifestWithErrorAsync(); if (manifest is null) { - AsyncConsole.WriteError("Unable to query update feed."); + AsyncConsole.WriteError(feedError ?? "Unable to query update feed."); return 1; } var local = typeof(Program).Assembly.GetName().Version ?? new Version(0, 0); - var remote = Version.TryParse(StripPrerelease(manifest.Version), out var v) ? v : new Version(0, 0); - AsyncConsole.WriteLine($"Remote Cli ({channelDisplay}): {manifest.Version}"); + var remoteText = ReleaseVersion.NormalizeTag(manifest.Version); + var remote = ReleaseVersion.ParseComparable(remoteText); + var localComparable = ReleaseVersion.ToComparable(local); + var localDisplay = ReleaseVersion.FormatDisplay(local); + + AsyncConsole.WriteLine($"Remote Cli ({channelDisplay}): {remoteText}"); + AsyncConsole.WriteLine($"Local Cli: {localDisplay} → remote {remoteText} ({channelDisplay})"); var exit = 0; - if (remote > local) + var cmp = remote.CompareTo(localComparable); + if (cmp > 0) { - AsyncConsole.WriteLine($"A newer Cli build is available ({channelDisplay}). Run: titanium update --channel {channel}"); + AsyncConsole.WriteLine( + $"A newer Cli build is available ({localDisplay} → {remoteText}, {channelDisplay}). Run: titanium update --channel {channel}"); exit = 2; } + else if (cmp < 0) + { + AsyncConsole.WriteLine( + $"Local Cli {localDisplay} is newer than {channelDisplay} {remoteText}."); + } else { - AsyncConsole.WriteLine($"Cli is up to date ({channelDisplay})."); + AsyncConsole.WriteLine($"Cli is up to date ({remoteText}, {channelDisplay})."); } if (plus) { - var plusLocal = TryGetLocalPlusVersion(); - var plusRemote = manifest.Products?.Plus?.Version; - if (!string.IsNullOrEmpty(plusRemote) && - Version.TryParse(StripPrerelease(plusRemote), out var pr) && - (plusLocal is null || pr > plusLocal)) - { - AsyncConsole.WriteLine( - $"A newer Plus build is available ({plusRemote}, {channelDisplay}). Run: titanium update --plus --channel {channel}"); - exit = 2; - } - else if (plusLocal is not null) - { - AsyncConsole.WriteLine($"Plus is up to date ({plusLocal}, {channelDisplay})."); - } + exit = Math.Max(exit, PrintPlusCheck(manifest, channel, channelDisplay)); } return exit; } - private static Version? TryGetLocalPlusVersion() + internal static int PrintHelp() + { + AsyncConsole.WriteLine(""" + titanium version [--check] [--plus] [--channel stable|beta] + + (default) Print local Cli / Core / Abstractions / Configuration versions. + --check Compare local Cli (and optionally Plus) to the update feed. + --plus Include Plus DLL version (with or without --check). + --channel stable (default) or beta. Also: TITANIUM_UPDATE_CHANNEL. + + Exit codes with --check: 0 up to date, 2 update available, 1 feed error. + """); + CliHelp.WriteDocsFooter(); + return 0; + } + + private static int PrintPlusCheck(ReleaseManifest manifest, string channel, string channelDisplay) + { + var plusLocal = TryGetLocalPlusVersion(); + var plusRemoteText = ReleaseVersion.NormalizeTag( + manifest.Products?.Plus?.Version ?? manifest.Version); + var plusRemote = ReleaseVersion.ParseComparable(plusRemoteText); + + if (plusLocal is null) + { + AsyncConsole.WriteLine( + $"Plus is not installed. Run: titanium update --plus --channel {channel}"); + return 2; + } + + var plusLocalDisplay = ReleaseVersion.FormatDisplay(plusLocal); + AsyncConsole.WriteLine($"Local Plus: {plusLocalDisplay} → remote {plusRemoteText} ({channelDisplay})"); + var cmp = plusRemote.CompareTo(ReleaseVersion.ToComparable(plusLocal)); + if (cmp > 0) + { + AsyncConsole.WriteLine( + $"A newer Plus build is available ({plusLocalDisplay} → {plusRemoteText}, {channelDisplay}). Run: titanium update --plus --channel {channel}"); + return 2; + } + + if (cmp < 0) + { + AsyncConsole.WriteLine( + $"Local Plus {plusLocalDisplay} is newer than {channelDisplay} {plusRemoteText}."); + return 0; + } + + AsyncConsole.WriteLine($"Plus is up to date ({plusRemoteText}, {channelDisplay})."); + return 0; + } + + internal static Version? TryGetLocalPlusVersion() { var path = Path.Combine(AppContext.BaseDirectory, "Titanium.Plus.dll"); if (!File.Exists(path)) @@ -91,8 +154,8 @@ private static void PrintLocalVersions(bool includePlus) { void Print(string name, Assembly? asm) { - var ver = asm?.GetName().Version?.ToString() ?? "(not loaded)"; - AsyncConsole.WriteLine($"{name}: {ver}"); + var ver = asm?.GetName().Version; + AsyncConsole.WriteLine($"{name}: {(ver is null ? "(not loaded)" : ReleaseVersion.FormatDisplay(ver))}"); } Print("Cli", typeof(Program).Assembly); @@ -109,7 +172,8 @@ void Print(string name, Assembly? asm) } else if (module is not null) { - AsyncConsole.WriteLine($"Plus: {module.GetType().Assembly.GetName().Version} (RequiredAbstractions={module.RequiredAbstractionsVersion})"); + AsyncConsole.WriteLine( + $"Plus: {ReleaseVersion.FormatDisplay(module.GetType().Assembly.GetName().Version)} (RequiredAbstractions={module.RequiredAbstractionsVersion})"); } else { @@ -118,8 +182,40 @@ void Print(string name, Assembly? asm) } } + /// Parse --channel; returns false when the value is not stable/beta. + internal static bool TryResolveChannel(string[] args, out string channel, out string? error) + { + channel = StableChannel; + error = null; + string? raw = null; + for (var i = 0; i < args.Length; i++) + { + if (args[i] is "--channel" && i + 1 < args.Length) + { + raw = args[i + 1].Trim().ToLowerInvariant(); + break; + } + } + + raw ??= (Environment.GetEnvironmentVariable("TITANIUM_UPDATE_CHANNEL") ?? StableChannel).Trim().ToLowerInvariant(); + if (raw is not (StableChannel or "beta")) + { + error = $"Unknown channel '{raw}'. Use --channel stable or --channel beta."; + return false; + } + + channel = raw; + return true; + } + internal static string ParseChannel(string[] args) { + if (TryResolveChannel(args, out var channel, out _)) + { + return channel; + } + + // Legacy tests / callers: invalid values previously fell through as stable via FormatChannel. for (var i = 0; i < args.Length; i++) { if (args[i] is "--channel" && i + 1 < args.Length) @@ -128,42 +224,111 @@ internal static string ParseChannel(string[] args) } } - return (Environment.GetEnvironmentVariable("TITANIUM_UPDATE_CHANNEL") ?? "stable").Trim().ToLowerInvariant(); + return (Environment.GetEnvironmentVariable("TITANIUM_UPDATE_CHANNEL") ?? StableChannel).Trim().ToLowerInvariant(); } internal static string FormatChannel(string channel) => - channel.Equals("beta", StringComparison.OrdinalIgnoreCase) ? "beta" : "stable"; + channel.Equals("beta", StringComparison.OrdinalIgnoreCase) ? "beta" : StableChannel; - internal static string StripPrerelease(string? version) + internal static string StripPrerelease(string? version) => ReleaseVersion.StripPrerelease(version); + + /// Whether CLI should install the remote release (upgrade or same-semver channel/tag switch). + internal static bool ShouldInstallCliRelease( + Version local, + string remoteText, + string channelDisplay, + string? installedReleaseTag, + string? installedReleaseChannel) { - if (string.IsNullOrEmpty(version)) + remoteText = ReleaseVersion.NormalizeTag(remoteText); + var remoteSemver = ReleaseVersion.ParseComparable(remoteText); + var localSemver = ReleaseVersion.ToComparable(local); + + if (remoteSemver > localSemver) + { + return true; + } + + if (remoteSemver < localSemver) + { + return false; + } + + // Same semver: install when switching to beta tag or channel identity differs. + var isBeta = channelDisplay.Equals("beta", StringComparison.OrdinalIgnoreCase); + if (isBeta + && remoteText.Contains('-', StringComparison.Ordinal) + && (string.IsNullOrEmpty(installedReleaseTag) + || !installedReleaseTag.Equals(remoteText, StringComparison.OrdinalIgnoreCase) + || string.IsNullOrEmpty(installedReleaseChannel) + || !installedReleaseChannel.Equals(channelDisplay, StringComparison.OrdinalIgnoreCase))) { - return "0.0.0"; + return true; } - var trimmed = version.TrimStart('v'); - var dash = trimmed.IndexOf('-'); - return dash > 0 ? trimmed[..dash] : trimmed; + return false; } } internal static class UpdateCommand { + private static readonly string CliIdentityPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "TitaniumCli", + "installed-release.json"); + public static async Task ExecuteAsync(string[] args) { + if (CliHelp.RequestsHelp(args.AsSpan(1))) + { + return PrintHelp(); + } + var plus = args.Contains("--plus", StringComparer.OrdinalIgnoreCase); - var channel = VersionCommand.ParseChannel(args); + var removePlus = args.Contains("--remove-plus", StringComparer.OrdinalIgnoreCase); + if (plus && removePlus) + { + AsyncConsole.WriteError("Use either --plus or --remove-plus, not both."); + return 1; + } + + if (removePlus) + { + if (await ServiceCommand.IsDefaultServiceRunningAsync().ConfigureAwait(false)) + { + AsyncConsole.WriteError( + "Warning: the Titanium OS service appears to be running. Stop it before removing Plus " + + "(`titanium service stop`) — the DLL may be locked, and the in-process control plane " + + "keeps running until the proxy restarts."); + } + + return RemovePlus(); + } + + if (!VersionCommand.TryResolveChannel(args, out var channel, out var channelError)) + { + AsyncConsole.WriteError(channelError!); + return 1; + } + var channelDisplay = VersionCommand.FormatChannel(channel); + if (await ServiceCommand.IsDefaultServiceRunningAsync().ConfigureAwait(false)) + { + AsyncConsole.WriteError( + "Warning: the Titanium OS service appears to be running. Stop it before updating " + + "(`titanium service stop`), then run update again, then `titanium service start`."); + } + AsyncConsole.WriteLine(plus ? $"Checking Plus updates ({channelDisplay})…" : $"Checking for updates ({channelDisplay})…"); var client = new UpdateFeedClient(channel); - var manifest = await client.TryGetManifestAsync(); + var (manifest, feedError) = await client.TryGetManifestWithErrorAsync(); if (manifest is null) { - AsyncConsole.WriteError("Unable to query update feed."); + AsyncConsole.WriteError(feedError ?? "Unable to query update feed."); return 1; } @@ -175,14 +340,91 @@ public static async Task ExecuteAsync(string[] args) return await UpdateCliAsync(manifest, channelDisplay); } + internal static int PrintHelp() + { + AsyncConsole.WriteLine(""" + titanium update [--plus] [--remove-plus] [--channel stable|beta] + + (default) Download and install a newer CLI zip when the feed is ahead. + --plus Install or update Titanium.Plus.dll beside the CLI. + --remove-plus Delete Titanium.Plus.dll beside the CLI (no network). + --channel stable (default) or beta. Also: TITANIUM_UPDATE_CHANNEL. + + Does not use winget. If an OS service is running, stop it first so the exe can be replaced. + Plus is PolyForm Noncommercial — not for commercial use. Disable in config with + plus.enabled: false; use --remove-plus to delete the DLL from disk. + """); + CliHelp.WriteDocsFooter(); + return 0; + } + + /// + /// Deletes Titanium.Plus.dll (and .bak / .new) beside the CLI install. + /// Idempotent when Plus is already absent. is for tests. + /// + internal static int RemovePlus(string? installDir = null) + { + var dir = string.IsNullOrWhiteSpace(installDir) + ? AppContext.BaseDirectory + : installDir; + dir = dir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var dest = Path.Combine(dir, "Titanium.Plus.dll"); + var backup = dest + ".bak"; + var staging = dest + ".new"; + + var removed = false; + try + { + foreach (var path in new[] { dest, backup, staging }) + { + if (!File.Exists(path)) + continue; + File.Delete(path); + removed = true; + } + } + catch (Exception ex) + { + AsyncConsole.WriteError($"Plus remove failed: {ex.Message}"); + return 1; + } + + if (removed) + AsyncConsole.WriteLine("Removed Titanium.Plus.dll from the CLI install directory."); + else + AsyncConsole.WriteLine("Plus is not installed beside the CLI (nothing to remove)."); + + AsyncConsole.WriteLine( + "If a titanium run / OS service is still up with Plus loaded, stop and restart it so the " + + "control plane and dashboard unload (remove-plus does not stop them). " + + "If your config still has plus.enabled: true, set it to false (or remove the plus: block). " + + "Plus is PolyForm Noncommercial — not for commercial use."); + return 0; + } + private static async Task UpdateCliAsync(ReleaseManifest manifest, string channelDisplay) { var local = typeof(Program).Assembly.GetName().Version ?? new Version(0, 0); - var remoteText = manifest.Version?.TrimStart('v') ?? "0.0.0"; - var remote = Version.TryParse(VersionCommand.StripPrerelease(remoteText), out var v) ? v : new Version(0, 0); - if (remote <= local) + var remoteText = ReleaseVersion.NormalizeTag(manifest.Version); + var (installedTag, installedChannel) = ReadCliIdentity(); + var localDisplay = ReleaseVersion.FormatDisplay(local); + + if (!VersionCommand.ShouldInstallCliRelease( + local, remoteText, channelDisplay, installedTag, installedChannel)) { - AsyncConsole.WriteLine($"Titanium CLI is up to date ({channelDisplay})."); + var remote = ReleaseVersion.ParseComparable(remoteText); + var localComparable = ReleaseVersion.ToComparable(local); + if (remote < localComparable) + { + AsyncConsole.WriteLine( + $"Local Cli {localDisplay} is newer than {channelDisplay} {remoteText}. No changes."); + } + else + { + AsyncConsole.WriteLine($"Titanium CLI is up to date ({remoteText}, {channelDisplay})."); + WriteCliIdentity(remoteText, channelDisplay); + } + return 0; } @@ -195,7 +437,12 @@ private static async Task UpdateCliAsync(ReleaseManifest manifest, string c return 1; } - AsyncConsole.WriteLine($"Update {remoteText} ({channelDisplay}) is available. Installing…"); + var remoteCmp = ReleaseVersion.ParseComparable(remoteText); + var localCmp = ReleaseVersion.ToComparable(local); + var action = remoteCmp > localCmp + ? $"Update {localDisplay} → {remoteText} ({channelDisplay})" + : $"Switching to {remoteText} ({channelDisplay})"; + AsyncConsole.WriteLine($"{action}. Installing…"); AsyncConsole.WriteLine("Downloading…"); var workDir = Path.Combine(Path.GetTempPath(), "TitaniumCli-update"); @@ -232,8 +479,6 @@ private static async Task UpdateCliAsync(ReleaseManifest manifest, string c var relaunch = Path.Combine(installDir, exeName); if (!File.Exists(relaunch)) { - // Published layout may use AssemblyName titanium without extension on Unix already handled; - // twp sibling is optional. relaunch = Path.Combine(installDir, OperatingSystem.IsWindows() ? "twp.exe" : "twp"); } @@ -246,9 +491,9 @@ private static async Task UpdateCliAsync(ReleaseManifest manifest, string c remoteText, channelDisplay); + WriteCliIdentity(remoteText, channelDisplay); AsyncConsole.WriteLine( $"Installing {remoteText} ({channelDisplay}) in the background. When finished, run: titanium version"); - // Exit so the helper can replace locked binaries. return 0; } @@ -261,9 +506,29 @@ private static async Task UpdatePlusAsync(ReleaseManifest manifest, string return 1; } - var remoteLabel = manifest.Products?.Plus?.Version ?? manifest.Version ?? "unknown"; + var remoteLabel = ReleaseVersion.NormalizeTag( + manifest.Products?.Plus?.Version ?? manifest.Version ?? "unknown"); + var remoteSemver = ReleaseVersion.ParseComparable(remoteLabel); var dest = Path.Combine(AppContext.BaseDirectory, "Titanium.Plus.dll"); var backup = dest + ".bak"; + var plusLocal = VersionCommand.TryGetLocalPlusVersion(); + var installing = plusLocal is null; + + if (plusLocal is not null) + { + var localComparable = ReleaseVersion.ToComparable(plusLocal); + if (remoteSemver == localComparable + || (!string.IsNullOrEmpty(asset.Sha256) && File.Exists(dest) && FileSha256Matches(dest, asset.Sha256))) + { + AsyncConsole.WriteLine( + $"Plus is already at {remoteLabel} ({channelDisplay})."); + return 0; + } + } + + AsyncConsole.WriteLine(installing + ? $"Installing Titanium.Plus {remoteLabel} ({channelDisplay})…" + : $"Updating Plus {ReleaseVersion.FormatDisplay(plusLocal)} → {remoteLabel} ({channelDisplay})…"); AsyncConsole.WriteLine("Downloading…"); try { @@ -288,17 +553,99 @@ private static async Task UpdatePlusAsync(ReleaseManifest manifest, string var staging = dest + ".new"; await File.WriteAllBytesAsync(staging, bytes); - File.Move(staging, dest, overwrite: true); - AsyncConsole.WriteLine($"Updated Titanium.Plus.dll to {remoteLabel} ({channelDisplay})."); + try + { + File.Move(staging, dest, overwrite: true); + } + catch + { + TryRestorePlusBackup(dest, backup); + throw; + } + + AsyncConsole.WriteLine(installing + ? $"Installed Titanium.Plus.dll {remoteLabel} ({channelDisplay})." + : $"Updated Titanium.Plus.dll to {remoteLabel} ({channelDisplay})."); return 0; } catch (Exception ex) { + TryRestorePlusBackup(dest, backup); AsyncConsole.WriteError($"Plus update failed: {ex.Message}"); return 1; } } + private static bool FileSha256Matches(string path, string expectedHex) + { + try + { + var hash = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))).ToLowerInvariant(); + return hash.Equals(expectedHex, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + private static void TryRestorePlusBackup(string dest, string backup) + { + try + { + if (File.Exists(backup)) + { + File.Copy(backup, dest, overwrite: true); + } + } + catch + { + // Best-effort restore. + } + } + + private static (string? Tag, string? Channel) ReadCliIdentity() + { + try + { + if (!File.Exists(CliIdentityPath)) + { + return (null, null); + } + + var json = File.ReadAllText(CliIdentityPath); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var tag = root.TryGetProperty("tag", out var t) ? t.GetString() : null; + var channel = root.TryGetProperty("channel", out var c) ? c.GetString() : null; + return (tag, channel); + } + catch + { + return (null, null); + } + } + + private static void WriteCliIdentity(string tag, string channel) + { + try + { + var dir = Path.GetDirectoryName(CliIdentityPath); + if (!string.IsNullOrEmpty(dir)) + { + Directory.CreateDirectory(dir); + } + + File.WriteAllText( + CliIdentityPath, + JsonSerializer.Serialize(new { tag, channel })); + } + catch + { + // Non-fatal. + } + } + private static string ResolveRid() => Http3DepsCommand.SuggestRid(); } @@ -423,11 +770,17 @@ internal sealed class UpdateFeedClient public UpdateFeedClient(string channel) => _channel = channel; public async Task TryGetManifestAsync() + { + var (manifest, _) = await TryGetManifestWithErrorAsync(); + return manifest; + } + + public async Task<(ReleaseManifest? Manifest, string? Error)> TryGetManifestWithErrorAsync() { var feed = Environment.GetEnvironmentVariable("TITANIUM_UPDATE_FEED"); if (feed == string.Empty) { - return null; + return (null, "Update feed disabled (TITANIUM_UPDATE_FEED is empty)."); } try @@ -438,27 +791,50 @@ internal sealed class UpdateFeedClient if (!string.IsNullOrEmpty(feed)) { var json = await http.GetStringAsync(feed); - return JsonSerializer.Deserialize(json, ManifestJson); + var fromFeed = JsonSerializer.Deserialize(json, ManifestJson); + return fromFeed is null + ? (null, "Update feed returned invalid JSON.") + : (fromFeed, null); } var api = _channel.Equals("beta", StringComparison.OrdinalIgnoreCase) ? "https://api.github.com/repos/justcoding121/titanium-web-proxy/releases" : "https://api.github.com/repos/justcoding121/titanium-web-proxy/releases/latest"; - var payload = await http.GetStringAsync(api); + using var response = await http.GetAsync(api); + if (!response.IsSuccessStatusCode) + { + return (null, $"Update feed HTTP {(int)response.StatusCode} from GitHub Releases."); + } + + var payload = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(payload); if (!TrySelectRelease(doc.RootElement, out var release)) { - return null; + return (null, _channel.Equals("beta", StringComparison.OrdinalIgnoreCase) + ? "No beta release found." + : "No stable release found."); } var version = release.GetProperty("tag_name").GetString()?.TrimStart('v') ?? "0.0.0"; var fromAsset = await TryLoadManifestAssetAsync(http, release, version); - return fromAsset ?? new ReleaseManifest { Version = version, Channel = _channel }; + return (fromAsset ?? new ReleaseManifest { Version = version, Channel = _channel }, null); } - catch + catch (HttpRequestException ex) { - return null; + return (null, $"Update feed network error: {ex.Message}"); + } + catch (TaskCanceledException) + { + return (null, "Update feed timed out."); + } + catch (JsonException ex) + { + return (null, $"Update feed JSON error: {ex.Message}"); + } + catch (Exception ex) + { + return (null, $"Unable to query update feed: {ex.Message}"); } } diff --git a/src/Titanium.Inspector/App.axaml b/src/Titanium.Inspector/App.axaml index 939f04165..a42fc4526 100644 --- a/src/Titanium.Inspector/App.axaml +++ b/src/Titanium.Inspector/App.axaml @@ -2,35 +2,95 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:conv="using:Titanium.Inspector.Converters" x:Class="Titanium.Inspector.App" + Name="Titanium Inspector" RequestedThemeVariant="Default"> + + + + + + - + + + + + + + + + + + - + + + + + + + + + + + + + + + + - + + + + + + + + + + + + diff --git a/src/Titanium.Inspector/App.axaml.cs b/src/Titanium.Inspector/App.axaml.cs index 3b1c350a4..a06005d44 100644 --- a/src/Titanium.Inspector/App.axaml.cs +++ b/src/Titanium.Inspector/App.axaml.cs @@ -1,4 +1,5 @@ using Avalonia; +using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Titanium.Inspector.Services; @@ -14,6 +15,7 @@ public partial class App : Application public override void OnFrameworkInitializationCompleted() { var settings = SettingsService.Load(); + ThemeService.ApplyThemeMode(settings.Current.ThemeMode); var sessions = new SessionRegistry(SessionStoreOptions.FromSettings(settings.Current)); var buffer = new SessionStreamBuffer(); var updates = new UpdateService(settings); @@ -43,4 +45,21 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } + + /// + /// macOS application menu About — same as Help. + /// + private void OnMacAboutClick(object? sender, EventArgs e) + { + if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime { MainWindow: Window window }) + { + return; + } + + if (window.DataContext is MainWindowViewModel vm + && vm.OpenAboutCommand.CanExecute(null)) + { + vm.OpenAboutCommand.Execute(null); + } + } } diff --git a/src/Titanium.Inspector/Converters/StatusCodeBrushConverter.cs b/src/Titanium.Inspector/Converters/StatusCodeBrushConverter.cs index 379efa01d..943ba929a 100644 --- a/src/Titanium.Inspector/Converters/StatusCodeBrushConverter.cs +++ b/src/Titanium.Inspector/Converters/StatusCodeBrushConverter.cs @@ -1,3 +1,4 @@ +using System.Collections; using System.Globalization; using Avalonia; using Avalonia.Data.Converters; @@ -7,19 +8,38 @@ namespace Titanium.Inspector.Converters; /// Maps HTTP status codes to theme-aware session status brushes. -public sealed class StatusCodeBrushConverter : IValueConverter +public sealed class StatusCodeBrushConverter : IValueConverter, IMultiValueConverter { public static readonly StatusCodeBrushConverter Instance = new(); private static readonly IBrush FallbackMuted = new SolidColorBrush(Color.Parse("#888888")); - private static readonly IBrush FallbackSuccess = new SolidColorBrush(Color.Parse("#0F7B0F")); + private static readonly IBrush FallbackSuccess = new SolidColorBrush(Color.Parse("#0A5F0A")); private static readonly IBrush FallbackRedirect = new SolidColorBrush(Color.Parse("#0078D4")); private static readonly IBrush FallbackClientError = new SolidColorBrush(Color.Parse("#C19C00")); private static readonly IBrush FallbackServerError = new SolidColorBrush(Color.Parse("#C42B1C")); + private static int? AsNullableInt(object? value) + { + if (value is int i) + return i; + return value as int?; + } + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => ConvertStatusCode(AsNullableInt(value)); + + object? IMultiValueConverter.Convert(IList values, Type targetType, object? parameter, CultureInfo culture) + { + var status = values is { Count: > 0 } ? AsNullableInt(values[0]) : null; + _ = values is { Count: > 1 } ? values[1] : null; + return ConvertStatusCode(status); + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); + + private static object? ConvertStatusCode(int? status) { - var status = value as int? ?? (value is int i ? i : null); var statusClass = SessionDisplayFormat.GetStatusClass(status); return statusClass switch @@ -34,9 +54,6 @@ HttpStatusClass.Pending or HttpStatusClass.Informational or HttpStatusClass.Othe }; } - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - => throw new NotSupportedException(); - private static IBrush ResolveBrush(string resourceKey, IBrush fallback) { if (Application.Current?.TryGetResource(resourceKey, Application.Current.ActualThemeVariant, out var resource) == true diff --git a/src/Titanium.Inspector/Converters/StatusSeverityBrushConverter.cs b/src/Titanium.Inspector/Converters/StatusSeverityBrushConverter.cs new file mode 100644 index 000000000..0ec835cf3 --- /dev/null +++ b/src/Titanium.Inspector/Converters/StatusSeverityBrushConverter.cs @@ -0,0 +1,46 @@ +using System.Globalization; +using Avalonia; +using Avalonia.Data.Converters; +using Avalonia.Media; +using Titanium.Inspector.Services; + +namespace Titanium.Inspector.Converters; + +/// Maps to theme-aware status bar foreground brushes. +public sealed class StatusSeverityBrushConverter : IValueConverter +{ + public static readonly StatusSeverityBrushConverter Instance = new(); + + private static readonly IBrush FallbackNeutral = new SolidColorBrush(Color.Parse("#6B6B6B")); + private static readonly IBrush FallbackBusy = new SolidColorBrush(Color.Parse("#0078D4")); + private static readonly IBrush FallbackSuccess = new SolidColorBrush(Color.Parse("#0A5F0A")); + private static readonly IBrush FallbackWarning = new SolidColorBrush(Color.Parse("#9A6700")); + private static readonly IBrush FallbackError = new SolidColorBrush(Color.Parse("#C42B1C")); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var severity = value is StatusSeverity s ? s : StatusSeverity.Neutral; + return severity switch + { + StatusSeverity.Busy => ResolveBrush("StatusFeedbackBusyBrush", FallbackBusy), + StatusSeverity.Success => ResolveBrush("StatusFeedbackSuccessBrush", FallbackSuccess), + StatusSeverity.Warning => ResolveBrush("StatusFeedbackWarningBrush", FallbackWarning), + StatusSeverity.Error => ResolveBrush("StatusFeedbackErrorBrush", FallbackError), + _ => ResolveBrush("StatusFeedbackNeutralBrush", FallbackNeutral), + }; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); + + private static IBrush ResolveBrush(string resourceKey, IBrush fallback) + { + if (Application.Current?.TryGetResource(resourceKey, Application.Current.ActualThemeVariant, out var resource) == true + && resource is IBrush brush) + { + return brush; + } + + return fallback; + } +} diff --git a/src/Titanium.Inspector/InspectorAppFactory.cs b/src/Titanium.Inspector/InspectorAppFactory.cs index 312898a34..bc02fba56 100644 --- a/src/Titanium.Inspector/InspectorAppFactory.cs +++ b/src/Titanium.Inspector/InspectorAppFactory.cs @@ -7,27 +7,16 @@ namespace Titanium.Inspector; /// Shared wiring for desktop App and headless / E2E fixtures. public static class InspectorAppFactory { - public static MainWindowViewModel CreateViewModel( - SettingsService settings, - SessionStreamBuffer buffer, - SessionRegistry registry, - UpdateService updates, - InterceptionService? interception = null, - IInspectorDialogs? dialogs = null, - IInspectorPathPicker? pathPicker = null) => - new(buffer, registry, updates, settings, interception, dialogs, pathPicker); + public static MainWindowViewModel CreateViewModel(InspectorViewModelServices services) => + new(services); public static (MainWindowViewModel ViewModel, MainWindow Window) CreateMainWindow( - SettingsService settings, - SessionStreamBuffer buffer, - SessionRegistry registry, - UpdateService updates, - InterceptionService? interception = null, - IInspectorDialogs? dialogs = null, - IInspectorPathPicker? pathPicker = null) + InspectorViewModelServices services) { - var vm = CreateViewModel(settings, buffer, registry, updates, interception, dialogs, pathPicker); + ThemeService.ApplyThemeMode(services.Settings.Current.ThemeMode); + var vm = CreateViewModel(services); var window = new MainWindow { DataContext = vm }; return (vm, window); } + } diff --git a/src/Titanium.Inspector/InspectorViewModelServices.cs b/src/Titanium.Inspector/InspectorViewModelServices.cs new file mode 100644 index 000000000..32dc95048 --- /dev/null +++ b/src/Titanium.Inspector/InspectorViewModelServices.cs @@ -0,0 +1,14 @@ +using Titanium.Inspector.Services; + +namespace Titanium.Inspector.ViewModels; + +/// Constructor bag for (keeps the VM under 7 parameters). +public readonly record struct InspectorViewModelServices( + SessionStreamBuffer Buffer, + SessionRegistry Registry, + UpdateService Updates, + SettingsService Settings, + InterceptionService? Interception = null, + IInspectorDialogs? Dialogs = null, + IInspectorPathPicker? PathPicker = null, + IStatusNotifier? StatusNotifier = null); diff --git a/src/Titanium.Inspector/Program.cs b/src/Titanium.Inspector/Program.cs index 064ca1e84..ff63d9ea5 100644 --- a/src/Titanium.Inspector/Program.cs +++ b/src/Titanium.Inspector/Program.cs @@ -1,5 +1,7 @@ -using Avalonia; using System; +using System.Runtime.InteropServices; +using Avalonia; +using Titanium.Web.Proxy.Http3; namespace Titanium.Inspector; @@ -7,14 +9,31 @@ namespace Titanium.Inspector; internal static class Program { [STAThread] - public static void Main(string[] args) => + public static void Main(string[] args) + { + // Framework-dependent macOS Debug: System.Net.Quic does not search BaseDirectory for + // libmsquic; relaunch with DYLD_FALLBACK when natives were copied beside the apphost. + Http3NativeBootstrap.EnsureAppLocalMsQuicVisible(args); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + } public static AppBuilder BuildAvaloniaApp() { var builder = AppBuilder.Configure() .UsePlatformDetect() .WithInterFont(); + + // macOS: host menus/context menus as overlay popups instead of child NSWindows. + // Without this, Menu and ContextMenu often fail to open when Inspector is launched from + // a fullscreen host (Cursor/Rider/iTerm) — AvaloniaUI/Avalonia#15178 / #17264. + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + builder = builder.With(new AvaloniaNativePlatformOptions + { + OverlayPopups = true, + }); + } + #if DEBUG // Avalonia Trace is typically sync; keep it Debug-only so published builds never // push framework noise through a blocking Trace listener. diff --git a/src/Titanium.Inspector/Services/AvaloniaStatusNotifier.cs b/src/Titanium.Inspector/Services/AvaloniaStatusNotifier.cs new file mode 100644 index 000000000..c7ff0ca67 --- /dev/null +++ b/src/Titanium.Inspector/Services/AvaloniaStatusNotifier.cs @@ -0,0 +1,45 @@ +using Avalonia.Controls.Notifications; + +namespace Titanium.Inspector.Services; + +/// Shows short in-window toasts via Avalonia . +public sealed class AvaloniaStatusNotifier : IStatusNotifier +{ + private readonly Func _manager; + + public AvaloniaStatusNotifier(Func manager) => + _manager = manager; + + public void Show(string message, StatusSeverity severity) + { + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + var manager = _manager(); + if (manager is null) + { + return; + } + + var type = severity switch + { + StatusSeverity.Success => NotificationType.Success, + StatusSeverity.Warning => NotificationType.Warning, + StatusSeverity.Error => NotificationType.Error, + StatusSeverity.Busy => NotificationType.Information, + _ => NotificationType.Information, + }; + + var title = severity switch + { + StatusSeverity.Success => "Done", + StatusSeverity.Warning => "Notice", + StatusSeverity.Error => "Error", + _ => "Inspector", + }; + + manager.Show(new Notification(title, message, type, TimeSpan.FromSeconds(3.5))); + } +} diff --git a/src/Titanium.Inspector/Services/ExclusionPreview.cs b/src/Titanium.Inspector/Services/ExclusionPreview.cs new file mode 100644 index 000000000..1d5e6c0f0 --- /dev/null +++ b/src/Titanium.Inspector/Services/ExclusionPreview.cs @@ -0,0 +1,62 @@ +using System.Runtime.InteropServices; +using Titanium.Web.Proxy; +using Titanium.Web.Proxy.Helpers; + +namespace Titanium.Inspector.Services; + +/// Formats effective OS proxy bypass lists for the exclusions UI. +public static class ExclusionPreview +{ + public static string BuildWinInetOverride(InspectorSettings settings, string? currentOverride = null) + { + var proxySettings = MitmBypass.CreateSystemProxySettings(settings); + return proxySettings.BuildProxyOverride(currentOverride); + } + + public static (string Label, string Value) FormatForCurrentOs( + InspectorSettings settings, + string? currentOverride = null) + { + var winInet = BuildWinInetOverride(settings, currentOverride); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return ("WinINET bypass list", winInet); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return ("Proxy bypass domains (networksetup)", UnixProxyBypassMapper.ToCommaSeparated(winInet)); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + var gsettings = UnixProxyBypassMapper.ToGsettingsArray(winInet); + var noProxy = UnixProxyBypassMapper.ToNoProxyEnv(winInet); + return ("Ignored hosts / NO_PROXY", $"gsettings: {gsettings}\nNO_PROXY={noProxy}"); + } + + return ("Bypass list", winInet); + } + + public static string ExclusionSummary(InspectorSettings settings) + { + var bypass = settings.SystemProxyBypassHosts?.Count(h => !string.IsNullOrWhiteSpace(h)) ?? 0; + var tunnel = settings.DecryptSkipHosts?.Count(h => !string.IsNullOrWhiteSpace(h)) ?? 0; + if (bypass == 0 && tunnel == 0) + { + return ""; + } + + return $"Exclusions: {bypass} OS bypass, {tunnel} tunnel-only"; + } + + public static string DescribeOpaqueReason(OpaqueTunnelReason reason) => reason switch + { + OpaqueTunnelReason.DecryptOff => "Encrypted: Decrypt HTTPS is off", + OpaqueTunnelReason.BuiltInIdentity => "Encrypted: Microsoft identity bypass (tunnel)", + OpaqueTunnelReason.BuiltInPinning => "Encrypted: pinning host (tunnel only)", + OpaqueTunnelReason.UserSkipList => "Encrypted: tunnel-only exclusion list", + OpaqueTunnelReason.UserOnlyList => "Encrypted: not on decrypt-only allowlist", + _ => "", + }; +} diff --git a/src/Titanium.Inspector/Services/GraphQlOperationMatcher.cs b/src/Titanium.Inspector/Services/GraphQlOperationMatcher.cs new file mode 100644 index 000000000..922c13c5f --- /dev/null +++ b/src/Titanium.Inspector/Services/GraphQlOperationMatcher.cs @@ -0,0 +1,96 @@ +using System.Text.Json; + +namespace Titanium.Inspector.Services; + +/// Extracts GraphQL operationName from a JSON request body (tools matching only). +public static class GraphQlOperationMatcher +{ + /// + /// Tries to read operationName from a GraphQL JSON body. + /// Returns false when body is not GraphQL JSON or operationName is absent/null. + /// + public static bool TryGetOperationName(string? body, out string? operationName) + { + operationName = null; + if (string.IsNullOrWhiteSpace(body)) + { + return false; + } + + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + return false; + } + + if (!doc.RootElement.TryGetProperty("operationName", out var op) || + op.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + // Fallback: first word after query/mutation/subscription in "query" field. + if (doc.RootElement.TryGetProperty("query", out var queryEl) && + queryEl.ValueKind == JsonValueKind.String && + TryParseOperationFromQuery(queryEl.GetString(), out operationName)) + { + return true; + } + + return false; + } + + if (op.ValueKind != JsonValueKind.String) + { + return false; + } + + operationName = op.GetString(); + return !string.IsNullOrWhiteSpace(operationName); + } + catch (JsonException) + { + return false; + } + } + + /// True when is empty or equals the body's operationName. + public static bool MatchesOperation(string? body, string? requiredOperation) + { + if (string.IsNullOrWhiteSpace(requiredOperation)) + { + return true; + } + + if (!TryGetOperationName(body, out var name) || name is null) + { + return false; + } + + return string.Equals(name, requiredOperation.Trim(), StringComparison.Ordinal); + } + + private static bool TryParseOperationFromQuery(string? query, out string? name) + { + name = null; + if (string.IsNullOrWhiteSpace(query)) + { + return false; + } + + var tokens = query.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + for (var i = 0; i < tokens.Length - 1; i++) + { + if (tokens[i] is "query" or "mutation" or "subscription") + { + var candidate = tokens[i + 1].Trim().TrimEnd('(', '{'); + if (candidate.Length > 0 && candidate is not "{" and not "(") + { + name = candidate; + return true; + } + } + } + + return false; + } +} diff --git a/src/Titanium.Inspector/Services/HostListFormat.cs b/src/Titanium.Inspector/Services/HostListFormat.cs new file mode 100644 index 000000000..fd8ec2e96 --- /dev/null +++ b/src/Titanium.Inspector/Services/HostListFormat.cs @@ -0,0 +1,22 @@ +namespace Titanium.Inspector.Services; + +/// Newline-separated host pattern helpers for settings UI. +public static class HostListFormat +{ + public static string Join(IEnumerable? hosts) => + hosts is null ? "" : string.Join(Environment.NewLine, hosts.Where(h => !string.IsNullOrWhiteSpace(h))); + + public static List Parse(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return []; + } + + return text + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(h => h.Length > 0 && !h.StartsWith('#')) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} diff --git a/src/Titanium.Inspector/Services/IInspectorDialogs.cs b/src/Titanium.Inspector/Services/IInspectorDialogs.cs index f7319af01..0051b931d 100644 --- a/src/Titanium.Inspector/Services/IInspectorDialogs.cs +++ b/src/Titanium.Inspector/Services/IInspectorDialogs.cs @@ -1,5 +1,6 @@ using Avalonia.Controls; using Titanium.Inspector.Views; +using Titanium.Web.Proxy.Network; namespace Titanium.Inspector.Services; @@ -15,6 +16,36 @@ public interface IInspectorDialogs /// Ask to retry CA install with an OS admin prompt. Returns true if confirmed. Task ConfirmElevateRootCaAsync(Window? owner); + /// + /// Adaptive recovery when user OS trust failed (certutil missing, Keychain confirm, elevate). + /// + Task ShowTrustRecoveryAsync(Window? owner, CertificateOsTrustResult? result); + + /// + /// macOS: wait while the user sets Always Trust; polls SSL verify until trusted or cancelled. + /// + Task ShowMacSslTrustWaitAsync( + Window? owner, + Func verifySslTrust, + Action openKeychain, + Func? isInLoginKeychain = null); + + /// + /// Terminal failure after trust recovery: Try again / Export CA / Keychain confirm. + /// + Task ShowDecryptTrustFailedAsync(Window? owner, CertificateOsTrustResult? result); + + /// + /// Offer to start the proxy so Decrypt HTTPS can continue. Returns true if Start. + /// + Task ConfirmStartProxyForDecryptAsync(Window? owner); + + /// Ask to install root CA before Firefox trust. Returns true if Install. + Task ConfirmInstallRootCaBeforeFirefoxAsync(Window? owner); + + /// Ask the user to quit Firefox so the profile DB can be updated. + Task ConfirmQuitFirefoxForTrustAsync(Window? owner); + /// /// Show device CA setup steps. Returns true if the user chose Export CA; false on Close / no owner. /// @@ -28,29 +59,40 @@ public interface IInspectorDialogs /// Task ConfirmResetSettingsAsync(Window? owner); + /// + /// Warn when enabling System proxy will replace an existing PAC script. + /// + Task ConfirmPacReplaceAsync(Window? owner); + /// /// Confirm installing an Inspector update for the selected channel. Returns true if Install and restart. /// - Task ConfirmInstallUpdateAsync(Window? owner, string version, string channelDisplay); + Task ConfirmInstallUpdateAsync( + Window? owner, + string version, + string channelDisplay, + UpdateOfferKind offerKind = UpdateOfferKind.Upgrade); } /// Avalonia modal dialogs. public sealed class AvaloniaInspectorDialogs : IInspectorDialogs { private const string CancelLabel = "Cancel"; + private const string ExportCaLabel = "Export CA"; public Task ConfirmInstallRootCaAsync(Window? owner) => SimpleConfirmDialog.ShowAsync( owner, "Install root CA", - "Decrypt HTTPS requires trusting the Titanium Inspector root CA in your current-user certificate store (and Keychain/NSS on macOS/Linux). Install now?", + OsTrustUxCopy.ConfirmInstallRootCaBody(), accept: "Install", - cancel: CancelLabel); + cancel: CancelLabel, + height: OperatingSystem.IsWindows() ? 260 : 220); public Task ConfirmRemoveRootCaAsync(Window? owner) => SimpleConfirmDialog.ShowAsync( owner, "Remove root CA", - "Remove the Titanium Inspector root CA from the current-user Trusted Root store? HTTPS decrypt will be turned off.", + OsTrustUxCopy.ConfirmRemoveRootCaBody(), accept: "Remove", cancel: CancelLabel); @@ -58,16 +100,116 @@ public Task ConfirmElevateRootCaAsync(Window? owner) => SimpleConfirmDialog.ShowAsync( owner, "Install with administrator privileges", - "User-level trust failed or was insufficient. Continue to show the OS admin prompt (UAC / macOS authentication / polkit)? Cancel leaves certificate settings unchanged.", + OsTrustUxCopy.ConfirmElevateRootCaBody(), accept: "Continue", cancel: CancelLabel); + public Task ShowTrustRecoveryAsync(Window? owner, CertificateOsTrustResult? result) + { + var kind = result?.Kind ?? CertificateOsTrustKind.Failed; + var message = result?.Message ?? "Root CA trust failed."; + var packageHint = result?.PackageHint; + var brewAvailable = result?.BrewAvailable == true; + + return kind switch + { + CertificateOsTrustKind.CertutilMissing when brewAvailable => + TrustRecoveryDialog.ShowAsync( + owner, + "Install browser certificate tools", + message + "\n\nThis runs: brew install nss", + primary: "Install via Homebrew", + secondary: ExportCaLabel, + height: 280), + + CertificateOsTrustKind.CertutilMissing => + TrustRecoveryDialog.ShowAsync( + owner, + "Install browser certificate tools", + message + (string.IsNullOrEmpty(packageHint) + ? "" + : $"\n\nPackage: {packageHint}"), + primary: "Install browser certificate tools", + secondary: ExportCaLabel, + height: 280), + + CertificateOsTrustKind.HomebrewMissing => + TrustRecoveryDialog.ShowAsync( + owner, + "certutil not available", + message, + primary: ExportCaLabel, + secondary: null, + height: 260), + + CertificateOsTrustKind.MacNeedsManualTrustConfirm => + TrustRecoveryDialog.ShowAsync( + owner, + "Confirm trust in Keychain Access", + OsTrustUxCopy.MacSslTrustWaitBody, + primary: "Open Keychain Access", + secondary: null, + height: 340), + + _ => TrustRecoveryDialog.ShowAsync( + owner, + "Install with administrator privileges", + OsTrustUxCopy.TrustRecoveryAdminBody(message), + primary: "Install with administrator", + secondary: ExportCaLabel, + height: 280), + }; + } + + public Task ShowMacSslTrustWaitAsync( + Window? owner, + Func verifySslTrust, + Action openKeychain, + Func? isInLoginKeychain = null) => + MacSslTrustWaitDialog.ShowAsync(owner, verifySslTrust, openKeychain, isInLoginKeychain); + + public Task ShowDecryptTrustFailedAsync( + Window? owner, + CertificateOsTrustResult? result) + { + var (title, body, primary, secondary, height) = OsTrustUxCopy.FormatDecryptTrustFailed(result); + return TrustRecoveryDialog.ShowAsync(owner, title, body, primary, secondary, height); + } + + public Task ConfirmStartProxyForDecryptAsync(Window? owner) => + SimpleConfirmDialog.ShowAsync( + owner, + "Start the proxy?", + "Decrypt HTTPS needs the proxy running so Inspector can install and verify the root CA. Start now?", + accept: "Start proxy", + cancel: CancelLabel, + height: 220); + + public Task ConfirmInstallRootCaBeforeFirefoxAsync(Window? owner) => + SimpleConfirmDialog.ShowAsync( + owner, + "Install root CA first", + "Firefox trust needs the Titanium Inspector root CA installed on this PC first. Install the root CA now?", + accept: "Install", + cancel: CancelLabel); + + public Task ConfirmQuitFirefoxForTrustAsync(Window? owner) => + SimpleConfirmDialog.ShowAsync( + owner, + "Quit Firefox", + "Firefox appears to be running and may lock its certificate database.\n\n" + + "Inspector can ask Firefox to quit gracefully (unsaved tabs may prompt inside Firefox). " + + "It will not force-kill the process.", + accept: "Quit Firefox and retry", + cancel: CancelLabel, + height: 260); + public Task ShowDeviceCaSetupAsync(Window? owner, string message) => SimpleConfirmDialog.ShowAsync( owner, "Device CA setup", message, - accept: "Export CA", + accept: ExportCaLabel, cancel: "Close", height: 320); @@ -88,21 +230,54 @@ public Task ConfirmResetSettingsAsync(Window? owner) => SimpleConfirmDialog.ShowAsync( owner, "Reset Inspector settings", - "Restore bind address, menus, Tools (Composer/Breakpoints/AutoResponder/Scripts), retention, logging, HTTPS host lists, and layout to factory defaults?\n\n" + - "This does not remove the root CA, change OS trust, clear captured sessions, or delete the on-disk body cache. Restart Inspector afterward so retention limits fully apply.", + "Restore bind address, menus, Tools, retention, logging, exclusion host lists (bypass and tunnel-only), and layout to factory defaults?\n\n" + + "This does not remove the root CA, change OS proxy or Store loopback exemptions, clear captured sessions, or delete the on-disk body cache. Restart Inspector afterward so retention limits fully apply.", accept: "Reset settings", cancel: CancelLabel, - height: 300); + height: 320); - public Task ConfirmInstallUpdateAsync(Window? owner, string version, string channelDisplay) => + public Task ConfirmPacReplaceAsync(Window? owner) => SimpleConfirmDialog.ShowAsync( owner, - "Update available", - $"Update {version} ({channelDisplay}) is available. Install and restart now?\n\n" + - "Inspector will close, apply the update, and relaunch.", - accept: "Install and restart", + "Replace PAC script?", + "Inspector will set itself as the system proxy and replace any PAC script. Your existing bypass list will be preserved and merged. Disabling System proxy restores previous settings.", + accept: "Enable system proxy", + cancel: CancelLabel, + height: 280); + + public Task ConfirmInstallUpdateAsync( + Window? owner, + string version, + string channelDisplay, + UpdateOfferKind offerKind = UpdateOfferKind.Upgrade) + { + var (title, body, accept) = offerKind switch + { + UpdateOfferKind.Downgrade => ( + "Install older release", + $"Install older {channelDisplay} {version}? Your current build is newer and will be replaced.\n\n" + + "Inspector will close, replace the current installation, and relaunch.", + "Install and restart"), + UpdateOfferKind.ChannelSwitch => ( + "Switch update channel", + $"Switch to {channelDisplay} {version}? This replaces your current build.\n\n" + + "Inspector will close, replace the current installation, and relaunch.", + "Switch and restart"), + _ => ( + "Update available", + $"Version {version} ({channelDisplay}) is available.\n\n" + + "Inspector will close, replace the current installation, and relaunch.", + "Update and restart"), + }; + + return SimpleConfirmDialog.ShowAsync( + owner, + title, + body, + accept: accept, cancel: "Later", height: 240); + } } /// Scripted answers for unit / E2E-UI tests (no real windows). @@ -111,13 +286,26 @@ public sealed class ScriptedInspectorDialogs : IInspectorDialogs public bool InstallRootCaResult { get; set; } = true; public bool RemoveRootCaResult { get; set; } = true; public bool ElevateRootCaResult { get; set; } = true; + public TrustRecoveryChoice TrustRecoveryResult { get; set; } = TrustRecoveryChoice.Primary; + public MacSslTrustWaitResult MacSslTrustWaitResult { get; set; } = MacSslTrustWaitResult.Trusted; + public bool InstallRootCaBeforeFirefoxResult { get; set; } = true; + public bool QuitFirefoxForTrustResult { get; set; } = true; public bool DeviceCaSetupResult { get; set; } public bool ResetSettingsResult { get; set; } = true; + public bool PacReplaceResult { get; set; } = true; public bool RotateRootCaResult { get; set; } = true; public bool InstallUpdateResult { get; set; } = true; + public TrustRecoveryChoice DecryptTrustFailedResult { get; set; } = TrustRecoveryChoice.Cancel; + public bool StartProxyForDecryptResult { get; set; } public int InstallRootCaCalls { get; private set; } public int RemoveRootCaCalls { get; private set; } public int ElevateRootCaCalls { get; private set; } + public int TrustRecoveryCalls { get; private set; } + public int MacSslTrustWaitCalls { get; private set; } + public int DecryptTrustFailedCalls { get; private set; } + public int StartProxyForDecryptCalls { get; private set; } + public int InstallRootCaBeforeFirefoxCalls { get; private set; } + public int QuitFirefoxForTrustCalls { get; private set; } public int DeviceCaSetupCalls { get; private set; } public int ResetSettingsCalls { get; private set; } public int RotateRootCaCalls { get; private set; } @@ -125,6 +313,8 @@ public sealed class ScriptedInspectorDialogs : IInspectorDialogs public string? LastDeviceCaSetupMessage { get; private set; } public string? LastInstallUpdateVersion { get; private set; } public string? LastInstallUpdateChannel { get; private set; } + public CertificateOsTrustResult? LastTrustRecoveryResult { get; private set; } + public CertificateOsTrustResult? LastDecryptTrustFailedResult { get; private set; } public Task ConfirmInstallRootCaAsync(Window? owner) { @@ -144,6 +334,72 @@ public Task ConfirmElevateRootCaAsync(Window? owner) return Task.FromResult(ElevateRootCaResult); } + public Task ShowTrustRecoveryAsync(Window? owner, CertificateOsTrustResult? result) + { + TrustRecoveryCalls++; + LastTrustRecoveryResult = result; + return Task.FromResult(TrustRecoveryResult); + } + + public Task ShowMacSslTrustWaitAsync( + Window? owner, + Func verifySslTrust, + Action openKeychain, + Func? isInLoginKeychain = null) + { + MacSslTrustWaitCalls++; + try + { + openKeychain(); + } + catch + { + // ignore in tests + } + + if (MacSslTrustWaitResult == MacSslTrustWaitResult.Trusted) + { + try + { + // Allow scripted verify to update interception state when tests wire a real callback. + _ = verifySslTrust(); + } + catch + { + // ignore + } + } + + return Task.FromResult(MacSslTrustWaitResult); + } + + public Task ShowDecryptTrustFailedAsync( + Window? owner, + CertificateOsTrustResult? result) + { + DecryptTrustFailedCalls++; + LastDecryptTrustFailedResult = result; + return Task.FromResult(DecryptTrustFailedResult); + } + + public Task ConfirmStartProxyForDecryptAsync(Window? owner) + { + StartProxyForDecryptCalls++; + return Task.FromResult(StartProxyForDecryptResult); + } + + public Task ConfirmInstallRootCaBeforeFirefoxAsync(Window? owner) + { + InstallRootCaBeforeFirefoxCalls++; + return Task.FromResult(InstallRootCaBeforeFirefoxResult); + } + + public Task ConfirmQuitFirefoxForTrustAsync(Window? owner) + { + QuitFirefoxForTrustCalls++; + return Task.FromResult(QuitFirefoxForTrustResult); + } + public Task ConfirmRotateRootCaAsync(Window? owner) { RotateRootCaCalls++; @@ -163,11 +419,21 @@ public Task ConfirmResetSettingsAsync(Window? owner) return Task.FromResult(ResetSettingsResult); } - public Task ConfirmInstallUpdateAsync(Window? owner, string version, string channelDisplay) + public Task ConfirmPacReplaceAsync(Window? owner) => + Task.FromResult(PacReplaceResult); + + public Task ConfirmInstallUpdateAsync( + Window? owner, + string version, + string channelDisplay, + UpdateOfferKind offerKind = UpdateOfferKind.Upgrade) { InstallUpdateCalls++; LastInstallUpdateVersion = version; LastInstallUpdateChannel = channelDisplay; + LastInstallUpdateOfferKind = offerKind; return Task.FromResult(InstallUpdateResult); } + + public UpdateOfferKind LastInstallUpdateOfferKind { get; private set; } } diff --git a/src/Titanium.Inspector/Services/IInspectorPathPicker.cs b/src/Titanium.Inspector/Services/IInspectorPathPicker.cs index 1f74d3803..7969c4abb 100644 --- a/src/Titanium.Inspector/Services/IInspectorPathPicker.cs +++ b/src/Titanium.Inspector/Services/IInspectorPathPicker.cs @@ -2,20 +2,31 @@ namespace Titanium.Inspector.Services; +/// One save-dialog type filter (display name + wildcard pattern). +public readonly record struct PathPickerFileType(string Name, string Pattern); + /// File open/save prompts; injectable so headless / E2E tests avoid StorageProvider. public interface IInspectorPathPicker { Task PickSavePathAsync(string title, string suggestedFileName, string filterName, string pattern); + Task PickSavePathAsync(string title, string suggestedFileName, IReadOnlyList fileTypes); + Task PickOpenPathAsync(string title, string filterName, params string[] patterns); } /// Production picker: Avalonia StorageProvider when available, else Desktop fallback path. public sealed class AvaloniaInspectorPathPicker : IInspectorPathPicker { - public async Task PickSavePathAsync(string title, string suggestedFileName, string filterName, string pattern) + public Task PickSavePathAsync(string title, string suggestedFileName, string filterName, string pattern) => + PickSavePathAsync(title, suggestedFileName, [new PathPickerFileType(filterName, pattern)]); + + public async Task PickSavePathAsync( + string title, + string suggestedFileName, + IReadOnlyList fileTypes) { - var fromUi = await InspectorPathPickerHelpers.TrySaveViaStorageAsync(title, suggestedFileName, filterName, pattern); + var fromUi = await InspectorPathPickerHelpers.TrySaveViaStorageAsync(title, suggestedFileName, fileTypes); if (fromUi is not null) { return fromUi; @@ -60,10 +71,18 @@ public sealed class ScriptedInspectorPathPicker : IInspectorPathPicker public string? OpenPath { get; set; } public int SaveCalls { get; private set; } public int OpenCalls { get; private set; } + public IReadOnlyList? LastSaveFileTypes { get; private set; } + + public Task PickSavePathAsync(string title, string suggestedFileName, string filterName, string pattern) => + PickSavePathAsync(title, suggestedFileName, [new PathPickerFileType(filterName, pattern)]); - public Task PickSavePathAsync(string title, string suggestedFileName, string filterName, string pattern) + public Task PickSavePathAsync( + string title, + string suggestedFileName, + IReadOnlyList fileTypes) { SaveCalls++; + LastSaveFileTypes = fileTypes; return Task.FromResult(SavePath); } @@ -79,8 +98,7 @@ internal static class InspectorPathPickerHelpers public static async Task TrySaveViaStorageAsync( string title, string suggestedFileName, - string filterName, - string pattern) + IReadOnlyList fileTypes) { var top = TryGetMainWindow(); if (top?.StorageProvider is not { CanSave: true } sp) @@ -88,14 +106,17 @@ internal static class InspectorPathPickerHelpers return null; } - var file = await sp.SaveFilePickerAsync(new Avalonia.Platform.Storage.FilePickerSaveOptions + var choices = fileTypes.Count == 0 + ? [new FilePickerFileType("All") { Patterns = ["*.*"] }] + : fileTypes + .Select(t => new FilePickerFileType(t.Name) { Patterns = [t.Pattern] }) + .ToList(); + + var file = await sp.SaveFilePickerAsync(new FilePickerSaveOptions { Title = title, SuggestedFileName = suggestedFileName, - FileTypeChoices = - [ - new Avalonia.Platform.Storage.FilePickerFileType(filterName) { Patterns = [pattern] }, - ], + FileTypeChoices = choices, }); return file?.TryGetLocalPath(); } @@ -108,13 +129,13 @@ internal static class InspectorPathPickerHelpers return null; } - var files = await sp.OpenFilePickerAsync(new Avalonia.Platform.Storage.FilePickerOpenOptions + var files = await sp.OpenFilePickerAsync(new FilePickerOpenOptions { Title = title, AllowMultiple = false, FileTypeFilter = [ - new Avalonia.Platform.Storage.FilePickerFileType(filterName) { Patterns = patterns.ToList() }, + new FilePickerFileType(filterName) { Patterns = patterns.ToList() }, ], }); return files.Count > 0 ? files[0].TryGetLocalPath() : null; diff --git a/src/Titanium.Inspector/Services/IStatusNotifier.cs b/src/Titanium.Inspector/Services/IStatusNotifier.cs new file mode 100644 index 000000000..121794fcb --- /dev/null +++ b/src/Titanium.Inspector/Services/IStatusNotifier.cs @@ -0,0 +1,26 @@ +namespace Titanium.Inspector.Services; + +/// Optional toast/notification surface for important status outcomes. +public interface IStatusNotifier +{ + void Show(string message, StatusSeverity severity); +} + +/// No-op notifier for unit / headless tests. +public sealed class NullStatusNotifier : IStatusNotifier +{ + public static NullStatusNotifier Instance { get; } = new(); + + public void Show(string message, StatusSeverity severity) + { + } +} + +/// Records toast calls for tests. +public sealed class RecordingStatusNotifier : IStatusNotifier +{ + public List<(string Message, StatusSeverity Severity)> Calls { get; } = new(); + + public void Show(string message, StatusSeverity severity) => + Calls.Add((message, severity)); +} diff --git a/src/Titanium.Inspector/Services/ISystemProxyController.cs b/src/Titanium.Inspector/Services/ISystemProxyController.cs index 470252faa..f0bc161ac 100644 --- a/src/Titanium.Inspector/Services/ISystemProxyController.cs +++ b/src/Titanium.Inspector/Services/ISystemProxyController.cs @@ -6,21 +6,21 @@ namespace Titanium.Inspector.Services; /// Seam for system-proxy writes so tests can assert without mutating the machine. public interface ISystemProxyController { - void SetAsSystemProxy(ProxyServer proxy, ExplicitProxyEndPoint endPoint); - void RestoreOriginalProxySettings(ProxyServer proxy); + SystemProxyChangeResult SetAsSystemProxy(ProxyServer proxy, ExplicitProxyEndPoint endPoint, InspectorSettings settings); + SystemProxyChangeResult RestoreOriginalProxySettings(ProxyServer proxy); } /// Production controller that configures the OS system proxy via . public sealed class ProxyServerSystemProxyController : ISystemProxyController { - public void SetAsSystemProxy(ProxyServer proxy, ExplicitProxyEndPoint endPoint) + public SystemProxyChangeResult SetAsSystemProxy(ProxyServer proxy, ExplicitProxyEndPoint endPoint, InspectorSettings settings) { - var settings = MitmBypass.CreateSystemProxySettings(); - proxy.SetAsSystemProxy(endPoint, ProxyProtocolType.AllHttp, settings); + var proxySettings = MitmBypass.CreateSystemProxySettings(settings); + return proxy.TrySetAsSystemProxy(endPoint, ProxyProtocolType.AllHttp, proxySettings); } - public void RestoreOriginalProxySettings(ProxyServer proxy) => - proxy.RestoreOriginalProxySettings(); + public SystemProxyChangeResult RestoreOriginalProxySettings(ProxyServer proxy) => + proxy.TryRestoreOriginalProxySettings(); } /// Backward-compatible alias for . @@ -28,10 +28,10 @@ public sealed class WinInetSystemProxyController : ISystemProxyController { private readonly ProxyServerSystemProxyController _inner = new(); - public void SetAsSystemProxy(ProxyServer proxy, ExplicitProxyEndPoint endPoint) => - _inner.SetAsSystemProxy(proxy, endPoint); + public SystemProxyChangeResult SetAsSystemProxy(ProxyServer proxy, ExplicitProxyEndPoint endPoint, InspectorSettings settings) => + _inner.SetAsSystemProxy(proxy, endPoint, settings); - public void RestoreOriginalProxySettings(ProxyServer proxy) => + public SystemProxyChangeResult RestoreOriginalProxySettings(ProxyServer proxy) => _inner.RestoreOriginalProxySettings(proxy); } @@ -41,16 +41,33 @@ public sealed class RecordingSystemProxyController : ISystemProxyController public int SetCount { get; private set; } public int RestoreCount { get; private set; } public bool LastEnabled { get; private set; } + public InspectorSettings? LastSettings { get; private set; } - public void SetAsSystemProxy(ProxyServer proxy, ExplicitProxyEndPoint endPoint) + public bool FailSet { get; set; } + public bool FailRestore { get; set; } + public bool ThrowOnSet { get; set; } + public bool ThrowOnRestore { get; set; } + + public SystemProxyChangeResult SetAsSystemProxy(ProxyServer proxy, ExplicitProxyEndPoint endPoint, InspectorSettings settings) { SetCount++; LastEnabled = true; + LastSettings = settings; + if (ThrowOnSet) + throw new InvalidOperationException("recorded set throw"); + if (FailSet) + return SystemProxyChangeResult.Fail("recorded set failure"); + return SystemProxyChangeResult.Ok("System proxy recorded"); } - public void RestoreOriginalProxySettings(ProxyServer proxy) + public SystemProxyChangeResult RestoreOriginalProxySettings(ProxyServer proxy) { RestoreCount++; LastEnabled = false; + if (ThrowOnRestore) + throw new InvalidOperationException("recorded restore throw"); + if (FailRestore) + return SystemProxyChangeResult.Fail("recorded restore failure"); + return SystemProxyChangeResult.Ok("System proxy restore recorded"); } -} \ No newline at end of file +} diff --git a/src/Titanium.Inspector/Services/InterceptionService.cs b/src/Titanium.Inspector/Services/InterceptionService.cs index 65396c7e4..87b444de8 100644 --- a/src/Titanium.Inspector/Services/InterceptionService.cs +++ b/src/Titanium.Inspector/Services/InterceptionService.cs @@ -3,13 +3,16 @@ using System.Net.Security; using System.Security.Cryptography.X509Certificates; using System.Text; +using System.Threading.Channels; using Microsoft.Extensions.Logging; using Titanium.Inspector.ViewModels; using Titanium.Web.Proxy; +using Titanium.Web.Proxy.Abstractions.Plugins; using Titanium.Web.Proxy.Diagnostics; using Titanium.Web.Proxy.EventArguments; using Titanium.Web.Proxy.Http; using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network; namespace Titanium.Inspector.Services; @@ -32,6 +35,10 @@ public sealed class InterceptionService : IDisposable private readonly ManualResetEventSlim _shutdownCompleted = new(false); private string? _rootPfxPath; private InspectorSettings? _loggingSettings; + private Channel? _processResolveChannel; + private CancellationTokenSource? _processResolveCts; + + private readonly record struct ProcessResolveWork(SessionSnapshot Snap, Lazy ProcessId); public InterceptionService(ISystemProxyController? systemProxy = null) { @@ -58,6 +65,14 @@ public InterceptionService(ISystemProxyController? systemProxy = null) /// When non-empty, only these hosts are decrypted (built-in bypasses still never decrypt). public List DecryptOnlyHosts { get; set; } = []; + /// User WinINET bypass patterns when System proxy is on. + public List SystemProxyBypassHosts { get; set; } = []; + + /// Proxy localhost through the system proxy when enabled. + public bool ProxyLoopback { get; set; } = true; + + public InspectorSettings? SystemProxySettings { get; set; } + /// True when the OS can host QUIC (MsQuic / QuicListener.IsSupported). public static bool IsHttp3Supported => System.Net.Quic.QuicListener.IsSupported; @@ -76,6 +91,19 @@ public InterceptionService(ISystemProxyController? systemProxy = null) public X509Certificate2? RootCertificate => _proxy?.CertificateManager.RootCertificate; public bool IsRootTrusted { get; private set; } + /// Last OS trust outcome from Install root CA (Keychain / NSS / package hints). + public CertificateOsTrustResult? LastOsTrustResult { get; private set; } + + /// Last system-proxy enable/disable failure message (null after success). + public string? LastSystemProxyError { get; private set; } + + /// Root certificate display name used as NSS nickname. + public string RootCertificateName => + _proxy?.CertificateManager.RootCertificateName ?? "Titanium Inspector Root Certificate"; + + /// True when a Firefox profiles.ini is present on this machine. + public static bool IsFirefoxProfilePresent => FirefoxCertificateTrust.IsFirefoxProfilePresent(); + /// True when the running proxy currently allows HTTP/2. public bool Http2Enabled { get; private set; } = true; @@ -89,6 +117,7 @@ public InterceptionService(ISystemProxyController? systemProxy = null) public bool AutoTrustRootOnStart { get; set; } public AutoResponderViewModel? AutoResponder { get; set; } + public MapRemoteViewModel? MapRemote { get; set; } public BreakpointViewModel? Breakpoints { get; set; } /// When true, breakpoints also fire on BeforeResponse. @@ -100,6 +129,12 @@ public InterceptionService(ISystemProxyController? systemProxy = null) /// Optional light response script (set-header / set-status / abort). public string? ScriptOnResponse { get; set; } + /// Active network throttle profile (null / None = off). + public NetworkThrottleProfile? ThrottleProfile { get; set; } + + /// Optional FileDescriptorSet path for protobuf decode hints. + public string? ProtobufDescriptorSetPath { get; set; } + public event EventHandler? SessionCaptured; public event EventHandler? SessionUpdated; @@ -127,6 +162,8 @@ public async Task StartAsync(IPAddress address, int port, CancellationToken canc _proxy.BeforeRequest += OnBeforeRequest; _proxy.BeforeResponse += OnBeforeResponse; _proxy.AfterResponse += OnAfterResponse; + _proxy.OnRequestBodyWrite += OnRequestBodyWriteThrottle; + _proxy.OnResponseBodyWrite += OnResponseBodyWriteThrottle; _proxy.ServerCertificateValidationCallback += OnServerCertValidation; if (!string.IsNullOrWhiteSpace(UpstreamProxyAddress) && @@ -149,8 +186,12 @@ public async Task StartAsync(IPAddress address, int port, CancellationToken canc _proxy.AddEndPoint(_endPoint); _proxy.Start(); BoundPort = _endPoint.Port; + StartProcessResolveWorker(); - IsRootTrusted = UseInMemoryTrustState ? _inMemoryTrusted : IsRootPresentInStore(machineStore: false); + // Do not treat Unix store/Keychain presence as SSL trust (see RefreshTrustState). + IsRootTrusted = UseInMemoryTrustState + ? _inMemoryTrusted + : RefreshTrustState(machineStore: false); TryPruneLegacySharedCrtsOnce(); @@ -208,7 +249,7 @@ public void EnsureShutdown() { if (Interlocked.Exchange(ref _shutdownStarted, 1) != 0) { - _shutdownCompleted.Wait(TimeSpan.FromSeconds(3)); + _shutdownCompleted.Wait(TimeSpan.FromSeconds(3), CancellationToken.None); return; } @@ -316,6 +357,8 @@ public void Stop() _proxy.BeforeRequest -= OnBeforeRequest; _proxy.BeforeResponse -= OnBeforeResponse; _proxy.AfterResponse -= OnAfterResponse; + _proxy.OnRequestBodyWrite -= OnRequestBodyWriteThrottle; + _proxy.OnResponseBodyWrite -= OnResponseBodyWriteThrottle; _proxy.ServerCertificateValidationCallback -= OnServerCertValidation; if (_endPoint is not null) { @@ -329,6 +372,7 @@ public void Stop() _endPoint = null; BoundPort = 0; _live.Clear(); + StopProcessResolveWorker(); IsRootTrusted = false; _systemProxyEnabled = false; Http3Enabled = false; @@ -337,10 +381,12 @@ public void Stop() /// /// Enable or disable system proxy. Returns false if the proxy is not running or the underlying call failed. /// - public bool SetSystemProxy(bool enable) + public bool SetSystemProxy(bool enable, InspectorSettings? settings = null) { + LastSystemProxyError = null; if (_proxy is null || _endPoint is null || !_proxy.ProxyRunning) { + LastSystemProxyError = "Proxy is not running"; return false; } @@ -348,25 +394,62 @@ public bool SetSystemProxy(bool enable) { if (enable) { - _systemProxy.SetAsSystemProxy(_proxy, _endPoint); + var effective = settings ?? SystemProxySettings ?? new InspectorSettings(); + SystemProxySettings = effective; + var result = _systemProxy.SetAsSystemProxy(_proxy, _endPoint, effective); + if (!result.Succeeded) + { + LastSystemProxyError = result.Message; + _proxy.Logger.LogWarning("System proxy enable failed: {Message}", result.Message); + return false; + } + _systemProxyEnabled = true; } else { - _systemProxy.RestoreOriginalProxySettings(_proxy); + var result = _systemProxy.RestoreOriginalProxySettings(_proxy); + if (!result.Succeeded) + { + LastSystemProxyError = result.Message; + _proxy.Logger.LogWarning("System proxy disable failed: {Message}", result.Message); + return false; + } + _systemProxyEnabled = false; } return true; } - catch + catch (Exception ex) { + LastSystemProxyError = ex.Message; + try + { + _proxy.Logger.LogWarning(ex, "System proxy {Action} failed", enable ? "enable" : "disable"); + } + catch + { + // logging must not hide the original failure + } + return false; } } + /// Re-applies system proxy bypass rules when already enabled (after settings change). + public bool ReapplySystemProxyIfEnabled() + { + if (!_systemProxyEnabled || SystemProxySettings is null) + { + return true; + } + + return SetSystemProxy(true, SystemProxySettings); + } + /// Install root CA and refresh from the store. - /// True when the cert is present in the target Root store after install. + /// True when the cert is present in the target Root store after install (or Unix SSL trust succeeded / needs Keychain confirm). public bool InstallRootCertificate(bool machineStore) { if (_proxy is null) @@ -377,6 +460,8 @@ public bool InstallRootCertificate(bool machineStore) if (FailNextUserTrustInstall) { FailNextUserTrustInstall = false; + LastOsTrustResult = CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, "Forced user-trust failure (test)"); return false; } @@ -384,21 +469,65 @@ public bool InstallRootCertificate(bool machineStore) { _inMemoryTrusted = true; IsRootTrusted = true; + LastOsTrustResult = CertificateOsTrustResult.Ok("Root CA trusted (in-memory)"); return true; } - // Already trusted: skip TrustRootCertificate so Windows does not show another - // Trusted Root security dialog (or orphan-removal prompt) on repeated Install CA. + // Already in the .NET Root store: on Windows that is SSL trust. On macOS/Linux the + // cert can sit in Keychain/NSS without "Always Trust" / SSL trust — do not treat + // presence alone as trusted (Chrome then gets NET::ERR_CERT_AUTHORITY_INVALID). if (IsRootPresentInStore(machineStore)) { - IsRootTrusted = true; - return true; + if (OperatingSystem.IsWindows()) + { + IsRootTrusted = true; + LastOsTrustResult = CertificateOsTrustResult.Ok("Root CA already trusted"); + return CompleteRootTrustInstall(true); + } + + if (_proxy.CertificateManager.VerifyOsUserSslTrust()) + { + IsRootTrusted = true; + LastOsTrustResult = CertificateOsTrustResult.Ok("Root CA already trusted for SSL"); + return CompleteRootTrustInstall(true); + } + + // .NET/Keychain has the cert but SSL trust is incomplete — push OS trust again. + _proxy.CertificateManager.TrustRootCertificate(machineStore); + LastOsTrustResult = _proxy.CertificateManager.LastOsTrustResult; + IsRootTrusted = EvaluateUnixTrustSuccess(LastOsTrustResult) || + _proxy.CertificateManager.VerifyOsUserSslTrust(); + // MacNeedsManualTrustConfirm: cert was added; UI should guide Always Trust then re-verify. + // Return true so the recovery loop runs, but keep IsRootTrusted false until verified. + return CompleteRootTrustInstall( + IsRootTrusted || + LastOsTrustResult?.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm); } _proxy.CertificateManager.TrustRootCertificate(machineStore); - IsRootTrusted = IsRootPresentInStore(machineStore); - return IsRootTrusted; + LastOsTrustResult = _proxy.CertificateManager.LastOsTrustResult; + + if (OperatingSystem.IsWindows()) + { + IsRootTrusted = IsRootPresentInStore(machineStore); + return CompleteRootTrustInstall(IsRootTrusted); + } + + IsRootTrusted = EvaluateUnixTrustSuccess(LastOsTrustResult) || + _proxy.CertificateManager.VerifyOsUserSslTrust(); + // MacNeedsManualTrustConfirm: cert was added; UI should guide Always Trust then re-verify. + return CompleteRootTrustInstall( + IsRootTrusted || + LastOsTrustResult?.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm); + } + + private bool CompleteRootTrustInstall(bool installed) + { + if (IsRootTrusted) + TryEnableFirefoxEnterpriseRootsBestEffort(); + return installed; } + /// /// Installs the root CA with an OS admin prompt when required (UAC / macOS auth / polkit). /// @@ -413,15 +542,135 @@ public bool InstallRootCertificateAsAdmin(bool machineStore) { _inMemoryTrusted = true; IsRootTrusted = true; + LastOsTrustResult = CertificateOsTrustResult.Ok("Root CA trusted (in-memory)"); return true; } var ok = _proxy.CertificateManager.TrustRootCertificateAsAdmin(machineStore); - // On non-Windows, OS trust may succeed even when X509Store presence checks are incomplete. - IsRootTrusted = ok && (IsRootPresentInStore(machineStore) || !OperatingSystem.IsWindows()); - if (ok && !IsRootTrusted) + LastOsTrustResult = _proxy.CertificateManager.LastOsTrustResult; + if (OperatingSystem.IsWindows()) + { + IsRootTrusted = ok && IsRootPresentInStore(machineStore); + return CompleteRootTrustInstall(IsRootTrusted); + } + + IsRootTrusted = ok && (EvaluateUnixTrustSuccess(LastOsTrustResult) || + _proxy.CertificateManager.VerifyOsUserSslTrust()); + return CompleteRootTrustInstall( + IsRootTrusted || + LastOsTrustResult?.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm); + } + + /// Installs certutil (package/brew) then retries user SSL trust. + public CertificateOsTrustResult InstallNssToolsAndRetryTrust() + { + if (_proxy is null) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, "Start the proxy first"); + } + + var result = _proxy.CertificateManager.InstallNssCertutilAndRetryUserTrust(); + LastOsTrustResult = result; + if (EvaluateUnixTrustSuccess(result)) + { IsRootTrusted = true; - return IsRootTrusted; + } + else if (result.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm) + { + // Cert may be present; SSL trust still requires Always Trust confirmation. + IsRootTrusted = _proxy.CertificateManager.VerifyOsUserSslTrust(); + } + + return result; + } + + /// Opens Keychain Access for Always Trust guidance. + public string? OpenMacKeychainGuidance() => _proxy?.CertificateManager.OpenMacKeychainGuidance(); + + /// Best-effort: root is present in the macOS login keychain (not necessarily SSL-trusted). + public bool IsRootInLoginKeychain() => + _proxy?.CertificateManager.IsRootInLoginKeychain() == true; + + /// Re-verifies macOS/Linux user SSL trust and updates . + public bool VerifyOsUserSslTrust() + { + if (_proxy is null) return false; + if (UseInMemoryTrustState) return IsRootTrusted; + // Windows: Root store presence is trust. Unix: require real SSL trust verification — + // Keychain/NSS can hold the CA without trusting it for SSL (Chrome MITM fails). + var ok = OperatingSystem.IsWindows() + ? IsRootPresentInStore(false) + : _proxy.CertificateManager.VerifyOsUserSslTrust(); + IsRootTrusted = ok; + if (ok) + TryEnableFirefoxEnterpriseRootsBestEffort(); + return ok; + } + + /// + /// Trust CA for Firefox: enable OS-root import (Windows policy / macOS Keychain via + /// user.js) first; otherwise import into the default Firefox profile via certutil. + /// + public CertificateOsTrustResult TrustFirefox() + { + if (_proxy is null) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, "Start the proxy first"); + } + + var cert = _proxy.CertificateManager.RootCertificate; + if (cert is null) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, "Root certificate is not loaded"); + } + + if (OperatingSystem.IsWindows()) + { + var policy = FirefoxCertificateTrust.TryEnableWindowsEnterpriseRoots(); + if (policy.Succeeded) + return policy; + // Fall through to profile NSS import. + } + else + { + var pref = FirefoxCertificateTrust.TryEnableEnterpriseRootsUserPref(); + if (pref.Succeeded) + return pref; + } + + return FirefoxCertificateTrust.TrustDefaultProfile(cert, RootCertificateName); + } + + /// + /// Best-effort: if a Firefox profile exists, enable OS-root trust so Install root CA + /// is enough after a Firefox restart (no extra menu, no certutil). + /// + public static void TryEnableFirefoxEnterpriseRootsBestEffort() + { + try + { + if (!FirefoxCertificateTrust.IsFirefoxProfilePresent()) + return; + FirefoxCertificateTrust.TryEnableEnterpriseRootsUserPref(); + } + catch + { + // install path must not fail because Firefox prefs were locked + } + } + + private static bool EvaluateUnixTrustSuccess(CertificateOsTrustResult? result) => + result is { Succeeded: true }; + + /// Marks the last trust attempt as user-cancelled (recovery dialog dismissed). + public void SetLastOsTrustCancelled() + { + LastOsTrustResult = CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Cancelled, + "Root CA install cancelled"); } public void UntrustRootCertificate(bool machineStore) @@ -439,7 +688,15 @@ public void UntrustRootCertificate(bool machineStore) } _proxy.CertificateManager.RemoveTrustedRootCertificate(machineStore); - IsRootTrusted = IsRootPresentInStore(machineStore); + // Windows: Root store presence is trust. macOS: Chrome still trusts System.keychain + // copies after the .NET user store is cleared. Linux: Chrome reads NSS (~/.pki/nssdb), + // not the .NET store — leftover nicknames must keep IsRootTrusted true. + if (OperatingSystem.IsWindows()) + IsRootTrusted = IsRootPresentInStore(machineStore); + else if (OperatingSystem.IsMacOS()) + IsRootTrusted = _proxy.CertificateManager.IsOsRootStillPresent(); + else + IsRootTrusted = _proxy.CertificateManager.VerifyOsUserSslTrust(); } /// @@ -550,7 +807,27 @@ public void PruneLegacySharedCrts(bool force) public bool RefreshTrustState(bool machineStore = false) { - IsRootTrusted = UseInMemoryTrustState ? _inMemoryTrusted : IsRootPresentInStore(machineStore); + if (UseInMemoryTrustState) + { + IsRootTrusted = _inMemoryTrusted; + return IsRootTrusted; + } + + // Windows Root store presence == trust. On macOS/Linux, presence is not enough — + // VerifyOsUserSslTrust checks Keychain/NSS SSL trust (security verify-cert / certutil). + if (OperatingSystem.IsWindows()) + { + IsRootTrusted = IsRootPresentInStore(machineStore); + return IsRootTrusted; + } + + if (_proxy is null) + { + IsRootTrusted = false; + return false; + } + + IsRootTrusted = _proxy.CertificateManager.VerifyOsUserSslTrust(); return IsRootTrusted; } @@ -593,10 +870,38 @@ public bool IsRootPresentInStore(bool machineStore) var path = destinationPath ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), "TitaniumInspector-RootCA.cer"); - File.WriteAllBytes(path, cert.Export(X509ContentType.Cert)); + var der = cert.Export(X509ContentType.Cert); + if (IsPemExportPath(path)) + { + File.WriteAllText(path, EncodeCertificatePem(der), Encoding.ASCII); + } + else + { + File.WriteAllBytes(path, der); + } + return path; } + internal static bool IsPemExportPath(string path) => + Path.GetExtension(path).Equals(".pem", StringComparison.OrdinalIgnoreCase); + + internal static string EncodeCertificatePem(byte[] der) + { + var b64 = Convert.ToBase64String(der); + var sb = new StringBuilder(b64.Length + 64); + sb.Append("-----BEGIN CERTIFICATE-----\n"); + for (var i = 0; i < b64.Length; i += 64) + { + var len = Math.Min(64, b64.Length - i); + sb.Append(b64, i, len); + sb.Append('\n'); + } + + sb.Append("-----END CERTIFICATE-----\n"); + return sb.ToString(); + } + private void EnsureRootPfxPath() { if (_rootPfxPath is not null) @@ -615,10 +920,14 @@ private Task OnBeforeTunnelConnect(object sender, TunnelConnectSessionEventArgs { var host = e.HttpClient.Request.RequestUri?.Host ?? TryHost(e.HttpClient.Request); - e.DecryptSsl = DecryptHttps && !MitmBypass.ShouldDisableSslDecrypt( + var disableDecrypt = MitmBypass.ShouldDisableSslDecrypt( host, DecryptSkipHosts, - DecryptOnlyHosts); + userOnlyHosts: null); + e.DecryptSsl = DecryptHttps && !disableDecrypt; + var opaqueReason = disableDecrypt || !DecryptHttps + ? MitmBypass.ResolveOpaqueReason(host, DecryptHttps, DecryptSkipHosts, userOnlyHosts: null) + : OpaqueTunnelReason.None; if (!Capturing) { @@ -629,10 +938,11 @@ private Task OnBeforeTunnelConnect(object sender, TunnelConnectSessionEventArgs { // Opaque HTTPS (DecryptHttps=false) never hits BeforeRequest — publish CONNECT here // so the session list matches Fiddler when decryption is off. - var snap = CreateTunnelSnapshot(e); + var snap = CreateTunnelSnapshot(e, opaqueReason); AttachTunnelByteCounters(e, snap); _live[e.HttpClient] = snap; SessionCaptured?.Invoke(this, snap); + ScheduleProcessResolve(snap, e.HttpClient.ProcessId); } catch { @@ -670,23 +980,9 @@ private Task OnBeforeTunnelConnectResponse(object sender, TunnelConnectSessionEv return Task.CompletedTask; } - private SessionSnapshot CreateTunnelSnapshot(TunnelConnectSessionEventArgs e) + private SessionSnapshot CreateTunnelSnapshot(TunnelConnectSessionEventArgs e, OpaqueTunnelReason opaqueReason) { var req = e.HttpClient.Request; - var processId = 0; - string? processName = null; - try - { - processId = e.HttpClient.ProcessId.Value; - if (processId > 0) - { - processName = System.Diagnostics.Process.GetProcessById(processId).ProcessName; - } - } - catch - { - // process may have exited - } return new SessionSnapshot { @@ -697,20 +993,25 @@ private SessionSnapshot CreateTunnelSnapshot(TunnelConnectSessionEventArgs e) StartedUtc = DateTimeOffset.UtcNow, RequestHeadersText = FormatHeaders(req.Headers), Protocol = SessionDisplayFormat.FormatHttpProtocol(req.HttpVersion), - ProcessId = processId, - ProcessName = processName, IsTunnel = true, + OpaqueReason = opaqueReason, }; } - private async Task OnBeforeRequest(object sender, SessionEventArgs e) + private async Task OnBeforeRequest(object sender, SessionEventArgs e) // NOSONAR S3776 -- Capture pipeline (scripts, AutoResponder, breakpoints) shares session state; splitting would hide ordering. { try { - if (e.HttpClient.Request.HasBody && ShouldBufferBody(e.HttpClient.Request, e)) + // Buffer body when tools need GraphQL operationName matching. + var needsBodyForTools = + (AutoResponder is { Enabled: true } && AutoResponder.Rules.Any(r => r.Enabled && !string.IsNullOrWhiteSpace(r.GraphQlOperationName))) || + (MapRemote is { Enabled: true } && MapRemote.Rules.Any(r => r.Enabled && !string.IsNullOrWhiteSpace(r.GraphQlOperationName))) || + (Breakpoints is { Enabled: true } && !string.IsNullOrWhiteSpace(Breakpoints.GraphQlOperationName)); + + if (e.HttpClient.Request.HasBody && (ShouldBufferBody(e.HttpClient.Request, e) || needsBodyForTools)) { e.HttpClient.Request.KeepBody = true; - await e.GetRequestBody(); + await e.GetRequestBody(CancellationToken.None); } if (SessionScriptHost.ApplyOnRequest(ScriptOnRequest, e)) @@ -718,22 +1019,44 @@ private async Task OnBeforeRequest(object sender, SessionEventArgs e) return; } - // AutoResponder before breakpoints / origin. + string? requestBody = null; + if (needsBodyForTools && e.HttpClient.Request.IsBodyRead) + { + requestBody = await e.GetRequestBodyAsString(CancellationToken.None); + } + + var requestUrl = e.HttpClient.Request.Url ?? ""; + + // AutoResponder / Map Local before breakpoints / origin. + var autoResponded = false; if (AutoResponder is not null && - AutoResponder.TryMatch(e.HttpClient.Request.Url ?? "", out var rule) && - rule is not null) + AutoResponder.TryMatch(requestUrl, requestBody, out var rule) && + rule is not null && + AutoResponderViewModel.TryResolveBody(rule, out var bodyBytes, out _)) { var headers = new List { new("Content-Type", rule.ContentType), }; - e.GenericResponse(rule.Body, (HttpStatusCode)rule.StatusCode, headers); + e.GenericResponse(bodyBytes, (HttpStatusCode)rule.StatusCode, headers); + autoResponded = true; + } + + // Map Remote: rewrite URL before origin (only when not already answered). + if (!autoResponded && + MapRemote is not null && + MapRemote.TryRewrite(requestUrl, requestBody, out var rewritten, out _) && + !string.IsNullOrEmpty(rewritten)) + { + e.HttpClient.Request.Url = rewritten; } if (Breakpoints is { Enabled: true } && + (string.IsNullOrWhiteSpace(Breakpoints.GraphQlOperationName) || + GraphQlOperationMatcher.MatchesOperation(requestBody, Breakpoints.GraphQlOperationName)) && Breakpoints.TryEnter(CreatePreviewSnapshot(e, assignId: false), out var hit)) { - var action = await hit.WaitAsync(); + var action = await hit.WaitAsync(CancellationToken.None); if (action == BreakpointAction.Abort) { e.GenericResponse("Aborted by Titanium Inspector breakpoint", HttpStatusCode.Forbidden); @@ -754,6 +1077,7 @@ private async Task OnBeforeRequest(object sender, SessionEventArgs e) var snap = CreatePreviewSnapshot(e, assignId: true); _live[e.HttpClient] = snap; SessionCaptured?.Invoke(this, snap); + ScheduleProcessResolve(snap, e.HttpClient.ProcessId); } catch (Exception) { @@ -768,7 +1092,7 @@ private async Task OnBeforeResponse(object sender, SessionEventArgs e) if (e.HttpClient.Response.HasBody && ShouldBufferBody(e.HttpClient.Response, e)) { e.HttpClient.Response.KeepBody = true; - await e.GetResponseBody(); + await e.GetResponseBody(CancellationToken.None); } SessionScriptHost.ApplyOnResponse(ScriptOnResponse, e); @@ -777,7 +1101,7 @@ private async Task OnBeforeResponse(object sender, SessionEventArgs e) Breakpoints is { Enabled: true } && Breakpoints.TryEnter(CreatePreviewSnapshot(e, assignId: false), out var hit)) { - var action = await hit.WaitAsync(); + var action = await hit.WaitAsync(CancellationToken.None); if (action == BreakpointAction.Abort) { e.GenericResponse("Aborted by Titanium Inspector response breakpoint", HttpStatusCode.Forbidden); @@ -800,6 +1124,7 @@ private async Task OnBeforeResponse(object sender, SessionEventArgs e) snap = CreatePreviewSnapshot(e, assignId: true); _live[e.HttpClient] = snap; SessionCaptured?.Invoke(this, snap); + ScheduleProcessResolve(snap, e.HttpClient.ProcessId); } FillResponse(snap, e); @@ -837,40 +1162,84 @@ private SessionSnapshot CreatePreviewSnapshot(SessionEventArgs e, bool assignId) var req = e.HttpClient.Request; var bodyBytes = req.IsBodyRead ? TruncateBytes(req.Body) : null; var bodyText = bodyBytes is null ? null : TruncateText(Encoding.UTF8.GetString(bodyBytes)); - var processId = 0; - string? processName = null; - try - { - processId = e.HttpClient.ProcessId.Value; - if (processId > 0) - { - processName = System.Diagnostics.Process.GetProcessById(processId).ProcessName; - } - } - catch - { - // process may have exited - } + GrpcJsonTranscodeSessionMark.TryGet(e.UserData, out var mark); - return new SessionSnapshot + var snap = new SessionSnapshot { Id = assignId ? NextSessionId() : 0, - Method = req.Method ?? "GET", - Url = req.Url ?? "", + Method = mark?.ClientMethod ?? req.Method ?? "GET", + Url = BuildDisplayUrl(req, mark), Host = TryHost(req), StartedUtc = DateTimeOffset.UtcNow, RequestHeadersText = FormatHeaders(req.Headers), RequestBodyBytes = bodyBytes, RequestBodyText = bodyText, - ContentType = req.ContentType, + ContentType = mark?.ClientContentType ?? req.ContentType, Protocol = SessionDisplayFormat.FormatHttpProtocol(req.HttpVersion), - ProcessId = processId, - ProcessName = processName, IsTunnel = req.Method?.Equals("CONNECT", StringComparison.OrdinalIgnoreCase) == true, IsWebSocket = req.UpgradeToWebSocket, - IsGrpc = req.ContentType?.Contains("grpc", StringComparison.OrdinalIgnoreCase) == true, + IsGrpc = req.ContentType?.Contains("grpc", StringComparison.OrdinalIgnoreCase) == true || + mark is not null, + IsTranscoded = mark is not null, IsMultipart = req.ContentType?.Contains("multipart/", StringComparison.OrdinalIgnoreCase) == true, + IsServerSentEvents = + (req.Headers.GetFirstHeader("Accept")?.Value?.Contains("text/event-stream", StringComparison.OrdinalIgnoreCase) == true), }; + + ApplyTranscodeMark(snap, mark); + if (mark?.ClientRequestBody is { Length: > 0 } clientBody) + { + snap.RequestBodyBytes = TruncateBytes(clientBody); + snap.RequestBodyText = TruncateText(Encoding.UTF8.GetString(clientBody)); + } + + if (mark?.UpstreamRequestBody is { Length: > 0 } upstreamReq) + { + snap.UpstreamRequestBodyBytes = TruncateBytes(upstreamReq); + snap.GrpcFrames = ProtocolFrameInspectors.ParseGrpcFrames(snap.UpstreamRequestBodyBytes); + snap.ProtobufDecodedText = ProtobufMessageDecoder.DecodeWireFormat(snap.UpstreamRequestBodyBytes); + } + + if (assignId && snap.IsWebSocket) + { + AttachLiveWebSocketFrames(e, snap); + } + + return snap; + } + + private static void ApplyTranscodeMark(SessionSnapshot snap, GrpcJsonTranscodeSessionMark? mark) + { + if (mark is null) return; + snap.IsTranscoded = true; + snap.ClientMethod = mark.ClientMethod; + snap.ClientPathAndQuery = mark.ClientPathAndQuery; + snap.ClientContentType = mark.ClientContentType; + snap.UpstreamMethod = mark.UpstreamMethod; + snap.UpstreamPath = mark.UpstreamPath; + snap.UpstreamContentType = mark.UpstreamContentType; + } + + private static string BuildDisplayUrl(Request req, GrpcJsonTranscodeSessionMark? mark) + { + if (mark is null) + return req.Url ?? ""; + + // Prefer absolute URL with client path when available. + var url = req.Url ?? ""; + if (Uri.TryCreate(url, UriKind.Absolute, out var abs)) + { + var builder = new UriBuilder(abs) + { + Path = mark.ClientPathAndQuery.Split('?', 2)[0], + Query = mark.ClientPathAndQuery.Contains('?', StringComparison.Ordinal) + ? mark.ClientPathAndQuery.Split('?', 2)[1] + : string.Empty + }; + return builder.Uri.ToString(); + } + + return mark.ClientPathAndQuery; } private long NextSessionId() => Interlocked.Increment(ref _nextId); @@ -878,7 +1247,117 @@ private SessionSnapshot CreatePreviewSnapshot(SessionEventArgs e, bool assignId) /// Reset the session ID sequence (tests / clear-sessions). public void ResetSessionIdSequence() => Interlocked.Exchange(ref _nextId, 0); - private static void FillResponse(SessionSnapshot snap, SessionEventArgs e) + private void StartProcessResolveWorker() + { + StopProcessResolveWorker(); + if (!ClientProcessId.IsSupported) + { + return; + } + + var channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + AllowSynchronousContinuations = false, + }); + var cts = new CancellationTokenSource(); + _processResolveChannel = channel; + _processResolveCts = cts; + _ = Task.Run(() => ProcessResolveLoopAsync(channel.Reader, cts.Token), cts.Token); + } + + private void StopProcessResolveWorker() + { + var cts = _processResolveCts; + var channel = _processResolveChannel; + _processResolveCts = null; + _processResolveChannel = null; + + try + { + channel?.Writer.TryComplete(); + } + catch + { + // ignore + } + + try + { + cts?.Cancel(); + } + catch + { + // ignore + } + + cts?.Dispose(); + } + + private void ScheduleProcessResolve(SessionSnapshot snap, Lazy processId) + { + var channel = _processResolveChannel; + if (channel is null) + { + return; + } + + channel.Writer.TryWrite(new ProcessResolveWork(snap, processId)); + } + + private async Task ProcessResolveLoopAsync( + ChannelReader reader, + CancellationToken cancellationToken) + { + try + { + await foreach (var work in reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + try + { + ApplyResolvedProcess(work); + } + catch + { + // never break the resolve loop for a single session + } + } + } + catch (OperationCanceledException) + { + // expected on stop + } + } + + private void ApplyResolvedProcess(ProcessResolveWork work) + { + var processId = work.ProcessId.Value; + if (processId <= 0) + return; + + string? processName = null; + try + { + processName = System.Diagnostics.Process.GetProcessById(processId).ProcessName; + } + catch + { + // process may have exited; keep pid when known + } + + if (work.Snap.ProcessId == processId && + string.Equals(work.Snap.ProcessName, processName, StringComparison.Ordinal)) + { + return; + } + + work.Snap.ProcessId = processId; + work.Snap.ProcessName = processName; + SessionUpdated?.Invoke(this, work.Snap); + } + + private static void FillResponse(SessionSnapshot snap, SessionEventArgs e) // NOSONAR S3776 -- Snapshot fill walks protocol-specific body/header branches in one place. { var resp = e.HttpClient.Response; snap.StatusCode = resp.StatusCode; @@ -893,14 +1372,39 @@ private static void FillResponse(SessionSnapshot snap, SessionEventArgs e) ApplyTiming(snap, e.Timing, snap.StartedUtc); + if (GrpcJsonTranscodeSessionMark.TryGet(e.UserData, out var mark) && mark is not null) + { + ApplyTranscodeMark(snap, mark); + if (mark.UpstreamResponseBody is { Length: > 0 } upstreamResp) + { + snap.UpstreamResponseBodyBytes = TruncateBytes(upstreamResp); + snap.GrpcFrames = ProtocolFrameInspectors.ParseGrpcFrames(snap.UpstreamResponseBodyBytes); + } + } + if (snap.IsWebSocket) { - snap.WebSocketFrames = ProtocolFrameInspectors.ParseWebSocketFrames(bodyBytes ?? snap.RequestBodyBytes); + // Prefer live frames when present; otherwise best-effort parse. + snap.WebSocketFrames ??= ProtocolFrameInspectors.ParseWebSocketFrames(bodyBytes ?? snap.RequestBodyBytes); + } + + var contentType = resp.ContentType ?? snap.ContentType ?? ""; + if (contentType.Contains("text/event-stream", StringComparison.OrdinalIgnoreCase) || + snap.IsServerSentEvents) + { + snap.IsServerSentEvents = true; + snap.SseEvents = SseEventParser.Parse(snap.ResponseBodyText); } - if (snap.IsGrpc && bodyBytes is { Length: > 0 }) + if (snap.IsGrpc && !snap.IsTranscoded && bodyBytes is { Length: > 0 }) { snap.GrpcFrames = ProtocolFrameInspectors.ParseGrpcFrames(bodyBytes); + snap.ProtobufDecodedText = ProtobufMessageDecoder.DecodeWireFormat(bodyBytes); + } + + if (snap.IsTranscoded && snap.UpstreamResponseBodyBytes is { Length: > 0 }) + { + snap.ProtobufDecodedText = ProtobufMessageDecoder.DecodeWireFormat(snap.UpstreamResponseBodyBytes); } if (snap.IsMultipart && bodyBytes is { Length: > 0 }) @@ -909,6 +1413,56 @@ private static void FillResponse(SessionSnapshot snap, SessionEventArgs e) } } + private void AttachLiveWebSocketFrames(SessionEventArgs e, SessionSnapshot snap) + { + var frames = new List(); + snap.WebSocketFrames = frames; + e.BeforeWebSocketFrame += (_, args) => + { + var direction = args.Direction == WebSocketFrameDirection.ClientToServer ? "Client" : "Server"; + var opcode = args.OpCode.ToString(); + frames.Add(ProtocolFrameInspectors.FromLiveFrame(direction, opcode, args.Data)); + var profile = ThrottleProfile; + if (profile is { IsEnabled: true }) + { + args.Delay = NetworkThrottle.DelayFor(profile, args.Data.Length, applyLatency: true); + } + + SessionUpdated?.Invoke(this, snap); + return Task.CompletedTask; + }; + } + + private async Task OnRequestBodyWriteThrottle(object sender, BeforeBodyWriteEventArgs e) + { + var profile = ThrottleProfile; + if (profile is not { IsEnabled: true }) + { + return; + } + + var delay = NetworkThrottle.DelayFor(profile, e.BodyBytes?.Length ?? 0, applyLatency: !e.IsChunked || e.BodyBytes?.Length > 0); + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay, _processResolveCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + } + } + + private async Task OnResponseBodyWriteThrottle(object sender, BeforeBodyWriteEventArgs e) + { + var profile = ThrottleProfile; + if (profile is not { IsEnabled: true }) + { + return; + } + + var delay = NetworkThrottle.DelayFor(profile, e.BodyBytes?.Length ?? 0, applyLatency: true); + if (delay > TimeSpan.Zero) + { + await Task.Delay(delay, _processResolveCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + } + } + private static string? TryHost(Request req) { try diff --git a/src/Titanium.Inspector/Services/MacSslTrustWaitResult.cs b/src/Titanium.Inspector/Services/MacSslTrustWaitResult.cs new file mode 100644 index 000000000..1a9836f5f --- /dev/null +++ b/src/Titanium.Inspector/Services/MacSslTrustWaitResult.cs @@ -0,0 +1,10 @@ +namespace Titanium.Inspector.Services; + +/// Result of the macOS Keychain Always Trust wait dialog. +public enum MacSslTrustWaitResult +{ + Cancelled = 0, + Trusted = 1, + /// User gave up or confirmed save but policies were still not detected. + NotSavedYet = 2, +} diff --git a/src/Titanium.Inspector/Services/MitmBypass.cs b/src/Titanium.Inspector/Services/MitmBypass.cs index cc67664fe..7cf6a55ea 100644 --- a/src/Titanium.Inspector/Services/MitmBypass.cs +++ b/src/Titanium.Inspector/Services/MitmBypass.cs @@ -1,4 +1,3 @@ -using System.Linq; using Titanium.Web.Proxy; namespace Titanium.Inspector.Services; @@ -6,112 +5,84 @@ namespace Titanium.Inspector.Services; /// Identity / pinning hosts that should bypass system proxy or SSL decrypt. public static class MitmBypass { - public static readonly string[] SystemProxyBypassRules = - [ - "*.microsoftonline.com", - "*.microsoftonline-p.com", - "login.windows.net", - "*.login.microsoft.com", - "login.live.com", - "account.live.com", - "*.msauth.net", - "*.msftauth.net", - "enterpriseregistration.windows.net", - ]; + public static string[] SystemProxyBypassRules => MitmExclusionDefaults.SystemProxyBypassRules; - public static SystemProxySettings CreateSystemProxySettings(bool includeLoopback = true) - { - var settings = new SystemProxySettings(); - foreach (var rule in SystemProxyBypassRules) - { - settings.BypassRules.Add(rule); - } + public static string[] TunnelOnlyPinningDomains => MitmExclusionDefaults.TunnelOnlyPinningDomains; - if (includeLoopback) - { - settings.ProxyLoopback = true; - } + /// + /// Factory OS-bypass defaults with optional loopback (Merge mode — for callers without saved settings). + /// + public static SystemProxySettings CreateSystemProxySettings(bool includeLoopback = true) => + MitmExclusionDefaults.CreateSystemProxySettings(includeLoopback); - return settings; + /// + /// Builds system-proxy settings from the Inspector exclusion lists (Replace mode — + /// factory defaults are not re-merged; seed them into settings instead). + /// + public static SystemProxySettings CreateSystemProxySettings(InspectorSettings settings) + { + return MitmExclusionDefaults.CreateSystemProxySettings( + settings.ProxyLoopback, + settings.SystemProxyBypassHosts, + MitmExclusionMode.Replace); } public static bool ShouldDisableSslDecrypt(string? hostname) => - ShouldDisableSslDecrypt(hostname, userSkipHosts: null, userOnlyHosts: null); + MitmExclusionDefaults.ShouldDisableSslDecrypt(hostname); - /// - /// Returns true when TLS should stay opaque (no MITM decrypt). - /// Built-in SSO/pinning hosts always skip. User skip patterns add more. - /// When is non-empty, only matching hosts decrypt - /// (built-in bypass hosts still never decrypt). - /// public static bool ShouldDisableSslDecrypt( string? hostname, IEnumerable? userSkipHosts, + IEnumerable? userOnlyHosts) => + MitmExclusionDefaults.ShouldDisableSslDecrypt( + hostname, userSkipHosts, userOnlyHosts, MitmExclusionMode.Replace); + + public static bool HostnameMatches(string hostname, string pattern) => + MitmExclusionDefaults.HostnameMatches(hostname, pattern); + + public static OpaqueTunnelReason ResolveOpaqueReason( + string? hostname, + bool decryptHttps, + IEnumerable? userSkipHosts, IEnumerable? userOnlyHosts) { if (string.IsNullOrEmpty(hostname)) { - return false; + return OpaqueTunnelReason.None; } - if (IsBuiltInSslBypass(hostname)) + if (!decryptHttps) { - return true; + return OpaqueTunnelReason.DecryptOff; } - if (MatchesAny(hostname, userSkipHosts)) + // Replace mode: classify using the lists the user (or seed) provided. + if (userSkipHosts is not null && userSkipHosts.Any(p => HostnameMatches(hostname, p))) { - return true; + // Prefer friendlier labels when the pattern matches factory seeds. + if (SystemProxyBypassRules.Any(rule => HostnameMatches(hostname, rule))) + { + return OpaqueTunnelReason.BuiltInIdentity; + } + + if (TunnelOnlyPinningDomains.Any(domain => + hostname.Equals(domain, StringComparison.OrdinalIgnoreCase) + || hostname.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase))) + { + return OpaqueTunnelReason.BuiltInPinning; + } + + return OpaqueTunnelReason.UserSkipList; } var only = userOnlyHosts? .Where(h => !string.IsNullOrWhiteSpace(h)) .ToList(); - if (only is { Count: > 0 } && !MatchesAny(hostname, only)) - { - return true; - } - - return false; - } - - public static bool HostnameMatches(string hostname, string pattern) - { - if (string.IsNullOrWhiteSpace(pattern)) - { - return false; - } - - pattern = pattern.Trim(); - if (pattern.StartsWith("*.", StringComparison.Ordinal)) - { - var suffix = pattern[1..]; - return hostname.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) - || hostname.Equals(pattern[2..], StringComparison.OrdinalIgnoreCase); - } - - return hostname.Equals(pattern, StringComparison.OrdinalIgnoreCase) - || hostname.EndsWith("." + pattern, StringComparison.OrdinalIgnoreCase); - } - - private static bool IsBuiltInSslBypass(string hostname) - { - if (SystemProxyBypassRules.Any(rule => HostnameMatches(hostname, rule))) - { - return true; - } - - return hostname.Contains("dropbox.com", StringComparison.OrdinalIgnoreCase) - || hostname.Contains("webex.com", StringComparison.OrdinalIgnoreCase); - } - - private static bool MatchesAny(string hostname, IEnumerable? patterns) - { - if (patterns is null) + if (only is { Count: > 0 } && !only.Any(p => HostnameMatches(hostname, p))) { - return false; + return OpaqueTunnelReason.UserOnlyList; } - return patterns.Any(pattern => HostnameMatches(hostname, pattern)); + return OpaqueTunnelReason.None; } } diff --git a/src/Titanium.Inspector/Services/NetworkThrottleAndDecode.cs b/src/Titanium.Inspector/Services/NetworkThrottleAndDecode.cs new file mode 100644 index 000000000..696fd79aa --- /dev/null +++ b/src/Titanium.Inspector/Services/NetworkThrottleAndDecode.cs @@ -0,0 +1,240 @@ +using System.Buffers.Binary; +using System.Text; +using System.Text.Json; + +namespace Titanium.Inspector.Services; + +/// +/// Opt-in protobuf decode for inspect panes. Without a descriptor, returns a wire-format field dump. +/// +public static class ProtobufMessageDecoder +{ + private static readonly JsonSerializerOptions WireFormatJsonOptions = new() { WriteIndented = true }; + public static string DecodeWireFormat(byte[]? framedOrRaw, bool stripGrpcFrame = true) // NOSONAR S3776 -- Wire-format dump is a single protobuf walk. + { + if (framedOrRaw is null || framedOrRaw.Length == 0) + { + return ""; + } + + var payload = framedOrRaw.AsSpan(); + if (stripGrpcFrame && framedOrRaw.Length >= 5) + { + var length = BinaryPrimitives.ReadInt32BigEndian(framedOrRaw.AsSpan(1, 4)); + if (length >= 0 && length + 5 <= framedOrRaw.Length) + { + payload = framedOrRaw.AsSpan(5, length); + } + } + + var fields = new List>(); + var offset = 0; + var bytes = payload.ToArray(); + while (offset < bytes.Length) + { + if (!TryReadVarint(bytes, ref offset, out var tag)) + { + break; + } + + var fieldNumber = (int)(tag >> 3); + var wireType = (int)(tag & 0x7); + object? value = null; + switch (wireType) + { + case 0: + if (TryReadVarint(bytes, ref offset, out var v)) + { + value = v; + } + + break; + case 1 when offset + 8 <= bytes.Length: + value = BitConverter.ToUInt64(bytes, offset); + offset += 8; + break; + case 2: + value = ReadLengthDelimited(bytes, ref offset); + break; + case 5 when offset + 4 <= bytes.Length: + value = BitConverter.ToUInt32(bytes, offset); + offset += 4; + break; + } + + if (value is null) + { + break; + } + + fields.Add(new Dictionary + { + ["field"] = fieldNumber, + ["wireType"] = wireType, + ["value"] = value, + }); + } + + return JsonSerializer.Serialize(fields, WireFormatJsonOptions); + } + + private static string? ReadLengthDelimited(byte[] payload, ref int offset) + { + if (!TryReadVarint(payload, ref offset, out var len) || len < 0 || offset + (int)len > payload.Length) + { + return null; + } + + var slice = payload.AsSpan(offset, (int)len).ToArray(); + offset += (int)len; + var text = Encoding.UTF8.GetString(slice); + return text.All(c => !char.IsControl(c) || c is '\n' or '\r' or '\t') + ? text + : Convert.ToHexString(slice); + } + + private static bool TryReadVarint(byte[] data, ref int offset, out ulong value) + { + value = 0; + var shift = 0; + while (offset < data.Length && shift < 64) + { + var b = data[offset++]; + value |= (ulong)(b & 0x7F) << shift; + if ((b & 0x80) == 0) + { + return true; + } + + shift += 7; + } + + return false; + } +} + +/// SSE event parser for inspect tab. +public static class SseEventParser +{ + public static IReadOnlyList Parse(string? text) // NOSONAR S3776 -- SSE event walk is a single line-oriented state machine. + { + var list = new List(); + if (string.IsNullOrEmpty(text)) + { + return list; + } + + string? eventName = null; + string? id = null; + var data = new StringBuilder(); + foreach (var rawLine in text.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n')) + { + var line = rawLine; + if (line.Length == 0) + { + Flush(); + continue; + } + + if (line.StartsWith(':')) + { + continue; + } + + var colon = line.IndexOf(':'); + var field = colon >= 0 ? line[..colon] : line; + var value = colon >= 0 ? line[(colon + 1)..].TrimStart(' ') : ""; + switch (field) + { + case "event": + eventName = value; + break; + case "id": + id = value; + break; + case "data": + if (data.Length > 0) + { + data.Append('\n'); + } + + data.Append(value); + break; + } + } + + Flush(); + return list; + + void Flush() + { + if (data.Length == 0 && eventName is null && id is null) + { + return; + } + + list.Add(new SseEventSnapshot + { + Event = eventName ?? "message", + Id = id, + Data = data.ToString().TrimEnd('\n'), + }); + eventName = null; + id = null; + data.Clear(); + } + } +} + +public sealed class SseEventSnapshot +{ + public string Event { get; init; } = "message"; + public string? Id { get; init; } + public string? Data { get; init; } +} + +/// Named network throttle profiles for Inspector capture. +public static class NetworkThrottle +{ + public static NetworkThrottleProfile None { get; } = new("None", 0, 0); + public static NetworkThrottleProfile Slow3G { get; } = new("Slow 3G", latencyMs: 400, bytesPerSecond: 50_000); + public static NetworkThrottleProfile Fast3G { get; } = new("Fast 3G", latencyMs: 150, bytesPerSecond: 200_000); + public static NetworkThrottleProfile LTE { get; } = new("LTE", latencyMs: 50, bytesPerSecond: 1_500_000); + + public static IReadOnlyList Profiles { get; } = + [None, Slow3G, Fast3G, LTE]; + + public static NetworkThrottleProfile? Find(string? name) => + Profiles.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); + + public static TimeSpan DelayFor(NetworkThrottleProfile profile, int byteCount, bool applyLatency) + { + if (profile.BytesPerSecond <= 0 && profile.LatencyMs <= 0) + { + return TimeSpan.Zero; + } + + var ms = applyLatency ? profile.LatencyMs : 0; + if (profile.BytesPerSecond > 0 && byteCount > 0) + { + ms += (int)Math.Ceiling(byteCount * 1000.0 / profile.BytesPerSecond); + } + + return TimeSpan.FromMilliseconds(Math.Max(0, ms)); + } +} + +public sealed class NetworkThrottleProfile +{ + public NetworkThrottleProfile(string name, int latencyMs, int bytesPerSecond) + { + Name = name; + LatencyMs = latencyMs; + BytesPerSecond = bytesPerSecond; + } + + public string Name { get; } + public int LatencyMs { get; } + public int BytesPerSecond { get; } + public bool IsEnabled => LatencyMs > 0 || BytesPerSecond > 0; +} diff --git a/src/Titanium.Inspector/Services/OpaqueTunnelReason.cs b/src/Titanium.Inspector/Services/OpaqueTunnelReason.cs new file mode 100644 index 000000000..52d5ebee3 --- /dev/null +++ b/src/Titanium.Inspector/Services/OpaqueTunnelReason.cs @@ -0,0 +1,12 @@ +namespace Titanium.Inspector.Services; + +/// Why an HTTPS session stayed opaque (CONNECT tunnel or undecrypted). +public enum OpaqueTunnelReason +{ + None = 0, + DecryptOff, + BuiltInIdentity, + BuiltInPinning, + UserSkipList, + UserOnlyList, +} diff --git a/src/Titanium.Inspector/Services/OsTrustUxCopy.cs b/src/Titanium.Inspector/Services/OsTrustUxCopy.cs new file mode 100644 index 000000000..d3db2e606 --- /dev/null +++ b/src/Titanium.Inspector/Services/OsTrustUxCopy.cs @@ -0,0 +1,177 @@ +using Titanium.Web.Proxy.Network; + +namespace Titanium.Inspector.Services; + +/// Shared user-facing copy for root CA trust — always current-OS only. +public static class OsTrustUxCopy +{ + public const string MacSslTrustWaitBody = + "Keychain Get Info can already show Always Trust even when SSL policies were not saved " + + "(Chrome and Inspector still treat the CA as untrusted).\n\n" + + "Force a save:\n" + + "1. Keychain Access → login → Certificates → double-click Titanium Root Certificate Authority\n" + + "2. Expand Trust\n" + + "3. Set When using this certificate to Use System Defaults, then change it to Always Trust again\n" + + "4. Close Get Info — you must get a password prompt; that writes the real SSL trust policies\n\n" + + "This window closes automatically when those policies are detected. " + + "If you already saved, click I’ve saved Always Trust."; + + public const string MacSslTrustWaitStatusWaiting = "Waiting for the certificate in Keychain…"; + public const string MacSslTrustWaitStatusInKeychain = + "Waiting for saved SSL policies (toggle Always Trust, close Get Info, enter password)…"; + + public const string MacSslTrustNotSavedYet = + "Always Trust display is not enough — toggle Use System Defaults → Always Trust, close Get Info, " + + "enter your password, then try Install root CA / Decrypt HTTPS again"; + + public const string MacSslTrustWaitConfirmSaved = "I’ve saved Always Trust"; + + private const string ExportCaLabel = "Export CA"; + + /// Install-root confirm body for the OS this process is running on. + public static string ConfirmInstallRootCaBody() + { + if (OperatingSystem.IsMacOS()) + { + return "Decrypt HTTPS requires trusting the Titanium Inspector root CA in Keychain Access (login keychain). Install now?"; + } + + if (OperatingSystem.IsLinux()) + { + return "Decrypt HTTPS requires trusting the Titanium Inspector root CA in your user certificate store (NSS). Install now?"; + } + + if (OperatingSystem.IsWindows()) + { + return "Decrypt HTTPS requires trusting the Titanium Inspector root CA in your current-user Trusted Root store. Install now?" + + "\n\nWindows may show a Trusted Root Yes/No security dialog (not UAC) — choose Yes to trust the CA."; + } + + return "Decrypt HTTPS requires trusting the Titanium Inspector root CA on this computer. Install now?"; + } + + public static string ConfirmRemoveRootCaBody() + { + if (OperatingSystem.IsMacOS()) + return "Remove the Titanium Inspector root CA from Keychain? HTTPS decrypt will be turned off."; + if (OperatingSystem.IsLinux()) + return "Remove the Titanium Inspector root CA from your user certificate store (NSS)? HTTPS decrypt will be turned off."; + if (OperatingSystem.IsWindows()) + return "Remove the Titanium Inspector root CA from the current-user Trusted Root store? HTTPS decrypt will be turned off."; + return "Remove the Titanium Inspector root CA? HTTPS decrypt will be turned off."; + } + + public static string ConfirmElevateRootCaBody() + { + if (OperatingSystem.IsMacOS()) + return "User-level trust failed or was insufficient. Continue to show a macOS admin password prompt? Cancel leaves certificate settings unchanged."; + if (OperatingSystem.IsLinux()) + return "User-level trust failed or was insufficient. Continue to show a polkit admin prompt? Cancel leaves certificate settings unchanged."; + if (OperatingSystem.IsWindows()) + return "User-level trust failed or was insufficient. Continue to show UAC? Cancel leaves certificate settings unchanged."; + return "User-level trust failed or was insufficient. Continue with an admin prompt? Cancel leaves certificate settings unchanged."; + } + + public static string TrustRecoveryAdminBody(string message) + { + if (OperatingSystem.IsMacOS()) + return message + "\n\nContinue to show a macOS admin password prompt? Not now leaves certificate settings unchanged."; + if (OperatingSystem.IsLinux()) + return message + "\n\nContinue to show a polkit admin prompt? Not now leaves certificate settings unchanged."; + if (OperatingSystem.IsWindows()) + return message + "\n\nContinue to show UAC? Not now leaves certificate settings unchanged."; + return message + "\n\nContinue with an admin prompt? Not now leaves certificate settings unchanged."; + } + + public static string ExcludedHostsIntro() + { + if (OperatingSystem.IsMacOS()) + return "OS bypass needs Capture → System proxy (uses macOS network proxy settings). Tunnel-only rules apply to every client that hits Inspector. Factory defaults are seeded into the lists below — edit freely or reset."; + if (OperatingSystem.IsLinux()) + return "OS bypass needs Capture → System proxy (uses desktop / environment proxy settings). Tunnel-only rules apply to every client that hits Inspector. Factory defaults are seeded into the lists below — edit freely or reset."; + if (OperatingSystem.IsWindows()) + return "OS bypass needs Capture → System proxy (uses WinINET). Tunnel-only rules apply to every client that hits Inspector. Factory defaults are seeded into the lists below — edit freely or reset."; + return "OS bypass needs Capture → System proxy. Tunnel-only rules apply to every client that hits Inspector. Factory defaults are seeded into the lists below — edit freely or reset."; + } + + public static string ExcludedHostsLoopbackHint() + { + if (OperatingSystem.IsMacOS()) + return "When off, localhost is omitted from the macOS proxy bypass list so loopback can use the system proxy."; + if (OperatingSystem.IsLinux()) + return "When off, localhost is omitted from NO_PROXY so loopback can use the system proxy."; + if (OperatingSystem.IsWindows()) + return "When off, adds the Windows <-loopback> bypass rule so loopback skips the system proxy."; + return "Controls whether localhost traffic uses the system proxy."; + } + + public static string FormatStatus(CertificateOsTrustResult? result) + { + if (result is null) + return "Root CA is not trusted yet — try again, or Export CA"; + + return result.Kind switch + { + CertificateOsTrustKind.Cancelled => + "Root CA install cancelled", + CertificateOsTrustKind.CertutilMissing => + "Browser certificate tools are missing — install them, try again, or Export CA", + CertificateOsTrustKind.HomebrewMissing => + string.IsNullOrWhiteSpace(result.Message) + ? "Homebrew is required to install certificate tools — Export CA to trust manually" + : result.Message, + CertificateOsTrustKind.MacNeedsManualTrustConfirm => + "Set Always Trust for the Titanium Inspector root CA in Keychain Access", + CertificateOsTrustKind.MacKeychainFailed => + "Keychain trust failed — try again, or Export CA and trust it manually", + _ => string.IsNullOrWhiteSpace(result.Message) + ? "Root CA is not trusted yet — try again, or Export CA" + : result.Message, + }; + } + + public static (string Title, string Body, string Primary, string? Secondary, double Height) + FormatDecryptTrustFailed(CertificateOsTrustResult? result) + { + var kind = result?.Kind ?? CertificateOsTrustKind.Failed; + var detail = string.IsNullOrWhiteSpace(result?.Message) + ? null + : result.Message.Trim(); + + return kind switch + { + CertificateOsTrustKind.MacNeedsManualTrustConfirm => ( + "Confirm trust in Keychain", + detail ?? MacSslTrustWaitBody, + "Continue in Keychain Access", + ExportCaLabel, + 360), + + CertificateOsTrustKind.CertutilMissing => ( + "Certificate tools needed", + detail ?? + (OperatingSystem.IsLinux() + ? "Inspector needs certutil (NSS tools) to finish trusting the root CA." + : "Inspector needs browser certificate tools to finish trusting the root CA."), + "Try again", + ExportCaLabel, + 280), + + CertificateOsTrustKind.HomebrewMissing => ( + "Certificate tools needed", + detail ?? + "Homebrew is required to install certificate tools. Export the CA to trust it manually.", + ExportCaLabel, + null, + 260), + + _ => ( + "Can't decrypt HTTPS yet", + detail ?? + "The Titanium Inspector root CA is not trusted on this computer yet.", + "Try again", + ExportCaLabel, + 260), + }; + } +} diff --git a/src/Titanium.Inspector/Services/ProtocolFrameInspectors.cs b/src/Titanium.Inspector/Services/ProtocolFrameInspectors.cs index d79aa7021..5e051b2c1 100644 --- a/src/Titanium.Inspector/Services/ProtocolFrameInspectors.cs +++ b/src/Titanium.Inspector/Services/ProtocolFrameInspectors.cs @@ -1,12 +1,12 @@ -using System.Buffers.Binary; using System.Text; +using System.Buffers.Binary; namespace Titanium.Inspector.Services; /// WebSocket frame and gRPC length-prefixed frame inspectors. public static class ProtocolFrameInspectors { - public static IReadOnlyList ParseWebSocketFrames(byte[]? payload) + public static IReadOnlyList ParseWebSocketFrames(byte[]? payload) // NOSONAR S3776 -- RFC6455 frame walk is a single offset state machine. { var list = new List(); if (payload is null || payload.Length == 0) @@ -14,16 +14,110 @@ public static IReadOnlyList ParseWebSocketFrames(byte[]? return list; } - // Best-effort: treat text payloads as a single text frame preview. - list.Add(new WebSocketFrameSnapshot + // Best-effort RFC6455 frame walk when bytes look framed; else single text preview. + var offset = 0; + var parsedAny = false; + while (offset + 2 <= payload.Length) + { + var b0 = payload[offset]; + var b1 = payload[offset + 1]; + var opcode = b0 & 0x0F; + var masked = (b1 & 0x80) != 0; + ulong len = (ulong)(b1 & 0x7F); + var header = 2; + if (len == 126) + { + if (offset + 4 > payload.Length) break; + len = BinaryPrimitives.ReadUInt16BigEndian(payload.AsSpan(offset + 2, 2)); + header = 4; + } + else if (len == 127) + { + if (offset + 10 > payload.Length) break; + len = BinaryPrimitives.ReadUInt64BigEndian(payload.AsSpan(offset + 2, 8)); + header = 10; + } + + if (masked) + { + header += 4; + } + + if (offset + header + (int)len > payload.Length || len > int.MaxValue) + { + break; + } + + var dataStart = offset + header; + var data = payload.AsSpan(dataStart, (int)len).ToArray(); + if (masked) + { + var mask = payload.AsSpan(offset + header - 4, 4); + for (var i = 0; i < data.Length; i++) + { + data[i] ^= mask[i % 4]; + } + } + + list.Add(new WebSocketFrameSnapshot + { + Direction = "Unknown", + Opcode = OpcodeName(opcode), + PayloadPreview = Preview(data, opcode), + }); + parsedAny = true; + offset = dataStart + (int)len; + } + + if (!parsedAny) { - Direction = "Unknown", - Opcode = "Text", - PayloadPreview = Encoding.UTF8.GetString(payload, 0, Math.Min(payload.Length, 512)), - }); + list.Add(new WebSocketFrameSnapshot + { + Direction = "Unknown", + Opcode = "Text", + PayloadPreview = Encoding.UTF8.GetString(payload, 0, Math.Min(payload.Length, 512)), + }); + } + return list; } + public static WebSocketFrameSnapshot FromLiveFrame(string direction, string opcode, byte[] data) => + new() + { + Direction = direction, + Opcode = opcode, + PayloadPreview = Preview(data, opcode.Equals("Binary", StringComparison.OrdinalIgnoreCase) ? 2 : 1), + }; + + private static string OpcodeName(int opcode) => opcode switch + { + 0 => "Continuation", + 1 => "Text", + 2 => "Binary", + 8 => "Close", + 9 => "Ping", + 10 => "Pong", + _ => "Op" + opcode, + }; + + private static string Preview(byte[] data, int opcode) + { + if (data.Length == 0) + { + return ""; + } + + if (opcode == 1) + { + var text = Encoding.UTF8.GetString(data); + return text.Length > 512 ? text[..512] + "…" : text; + } + + var hex = Convert.ToHexString(data.AsSpan(0, Math.Min(data.Length, 64))); + return data.Length > 64 ? hex + "…" : hex; + } + public static IReadOnlyList ParseGrpcFrames(byte[]? payload) { var list = new List(); diff --git a/src/Titanium.Inspector/Services/SessionArchive.cs b/src/Titanium.Inspector/Services/SessionArchive.cs index b58e9a062..ba5c15b9e 100644 --- a/src/Titanium.Inspector/Services/SessionArchive.cs +++ b/src/Titanium.Inspector/Services/SessionArchive.cs @@ -13,12 +13,15 @@ public static Task ExportHarAsync(IEnumerable sessions, string { ct.ThrowIfCancellationRequested(); var entries = sessions.Select(ToHarEntry).ToList(); + var creatorVersion = typeof(SessionArchive).Assembly.GetName().Version is { } v + ? $"{v.Major}.{v.Minor}.{v.Build}" + : "0.0.0"; var har = new { log = new { version = "1.2", - creator = new { name = "Titanium Inspector", version = "7.0.4" }, + creator = new { name = "Titanium Inspector", version = creatorVersion }, entries, }, }; diff --git a/src/Titanium.Inspector/Services/SessionDiff.cs b/src/Titanium.Inspector/Services/SessionDiff.cs new file mode 100644 index 000000000..06a3bc11f --- /dev/null +++ b/src/Titanium.Inspector/Services/SessionDiff.cs @@ -0,0 +1,252 @@ +using System.Text; + +namespace Titanium.Inspector.Services; + +/// Offline compare of two captured sessions (headers + bodies). No hot path. +public static class SessionDiff +{ + public static SessionDiffResult Compare(SessionSnapshot left, SessionSnapshot right) + { + ArgumentNullException.ThrowIfNull(left); + ArgumentNullException.ThrowIfNull(right); + + var sb = new StringBuilder(); + var changes = 0; + + sb.AppendLine("=== Session Diff ==="); + sb.Append("A: #").Append(left.Id).Append(' ').Append(left.Method).Append(' ').AppendLine(left.Url); + sb.Append(" status=").Append(FormatStatus(left.StatusCode)).AppendLine(); + sb.Append("B: #").Append(right.Id).Append(' ').Append(right.Method).Append(' ').AppendLine(right.Url); + sb.Append(" status=").Append(FormatStatus(right.StatusCode)).AppendLine(); + sb.AppendLine(); + + if (!string.Equals(left.Method, right.Method, StringComparison.OrdinalIgnoreCase)) + { + changes++; + sb.AppendLine($"Method: {left.Method} → {right.Method}"); + } + + if (!string.Equals(left.Url, right.Url, StringComparison.Ordinal)) + { + changes++; + sb.AppendLine($"URL: {left.Url}"); + sb.AppendLine($" → {right.Url}"); + } + + if (left.StatusCode != right.StatusCode) + { + changes++; + sb.AppendLine($"Status: {FormatStatus(left.StatusCode)} → {FormatStatus(right.StatusCode)}"); + } + + changes += AppendHeaderDiff(sb, "Request headers", left.RequestHeadersText, right.RequestHeadersText); + changes += AppendHeaderDiff(sb, "Response headers", left.ResponseHeadersText, right.ResponseHeadersText); + changes += AppendBodyDiff(sb, "Request body", ResolveBody(left, request: true), ResolveBody(right, request: true)); + changes += AppendBodyDiff(sb, "Response body", ResolveBody(left, request: false), ResolveBody(right, request: false)); + + if (changes == 0) + { + sb.AppendLine("= (identical)"); + } + else + { + sb.AppendLine(); + sb.Append(changes).Append(changes == 1 ? " difference." : " differences."); + } + + return new SessionDiffResult(changes > 0, sb.ToString().TrimEnd()); + } + + private static string FormatStatus(int? code) => code?.ToString() ?? "(none)"; + + private static string ResolveBody(SessionSnapshot snap, bool request) + { + if (request) + { + if (!string.IsNullOrEmpty(snap.RequestBodyText)) + { + return snap.RequestBodyText; + } + + if (snap.RequestBodyBytes is { Length: > 0 }) + { + return Encoding.UTF8.GetString(snap.RequestBodyBytes); + } + + return ""; + } + + if (!string.IsNullOrEmpty(snap.ResponseBodyText)) + { + return snap.ResponseBodyText; + } + + if (snap.ResponseBodyBytes is { Length: > 0 }) + { + return Encoding.UTF8.GetString(snap.ResponseBodyBytes); + } + + return ""; + } + + private static int AppendHeaderDiff(StringBuilder sb, string title, string? leftText, string? rightText) + { + var left = SessionInspectors.ParseHeaderBlock(leftText); + var right = SessionInspectors.ParseHeaderBlock(rightText); + var names = left.Keys.Union(right.Keys, StringComparer.OrdinalIgnoreCase) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var section = new StringBuilder(); + var count = 0; + foreach (var name in names) + { + left.TryGetValue(name, out var lv); + right.TryGetValue(name, out var rv); + lv ??= ""; + rv ??= ""; + if (string.Equals(lv, rv, StringComparison.Ordinal)) + { + continue; + } + + count++; + if (string.IsNullOrEmpty(lv)) + { + section.Append("+ ").Append(name).Append(": ").AppendLine(rv); + } + else if (string.IsNullOrEmpty(rv)) + { + section.Append("- ").Append(name).Append(": ").AppendLine(lv); + } + else + { + section.Append("- ").Append(name).Append(": ").AppendLine(lv); + section.Append("+ ").Append(name).Append(": ").AppendLine(rv); + } + } + + if (count == 0) + { + return 0; + } + + sb.Append("--- ").Append(title).AppendLine(" ---"); + sb.Append(section); + sb.AppendLine(); + return count; + } + + private static int AppendBodyDiff(StringBuilder sb, string title, string left, string right) // NOSONAR S3776 -- Offline line diff; splitting would hide the bounded-lookahead contract. + { + if (string.Equals(left, right, StringComparison.Ordinal)) + { + return 0; + } + + sb.Append("--- ").Append(title).AppendLine(" ---"); + var leftLines = SplitLines(left); + var rightLines = SplitLines(right); + + if (leftLines.Count == 1 && rightLines.Count == 1 + && leftLines[0].Length < 200 && rightLines[0].Length < 200) + { + sb.Append("- ").AppendLine(leftLines[0]); + sb.Append("+ ").AppendLine(rightLines[0]); + sb.AppendLine(); + return 1; + } + + var i = 0; + var j = 0; + var changes = 0; + while (i < leftLines.Count && j < rightLines.Count) + { + if (string.Equals(leftLines[i], rightLines[j], StringComparison.Ordinal)) + { + sb.Append(" ").AppendLine(leftLines[i]); + i++; + j++; + continue; + } + + var later = IndexOf(rightLines, leftLines[i], j + 1); + if (later >= 0 && later - j <= 8) + { + while (j < later) + { + sb.Append("+ ").AppendLine(rightLines[j++]); + changes++; + } + + continue; + } + + var laterLeft = IndexOf(leftLines, rightLines[j], i + 1); + if (laterLeft >= 0 && laterLeft - i <= 8) + { + while (i < laterLeft) + { + sb.Append("- ").AppendLine(leftLines[i++]); + changes++; + } + + continue; + } + + sb.Append("- ").AppendLine(leftLines[i++]); + sb.Append("+ ").AppendLine(rightLines[j++]); + changes += 2; + } + + while (i < leftLines.Count) + { + sb.Append("- ").AppendLine(leftLines[i++]); + changes++; + } + + while (j < rightLines.Count) + { + sb.Append("+ ").AppendLine(rightLines[j++]); + changes++; + } + + if (changes == 0) + { + changes = 1; + sb.AppendLine("(content differs)"); + } + + sb.AppendLine(); + return Math.Max(1, changes); + } + + private static List SplitLines(string text) + { + if (string.IsNullOrEmpty(text)) + { + return []; + } + + return text.Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n') + .ToList(); + } + + private static int IndexOf(List lines, string value, int start) + { + for (var i = start; i < lines.Count; i++) + { + if (string.Equals(lines[i], value, StringComparison.Ordinal)) + { + return i; + } + } + + return -1; + } +} + +/// True when any metadata, header, or body difference was found. +/// Human-readable report. +public readonly record struct SessionDiffResult(bool HasDifferences, string Text); diff --git a/src/Titanium.Inspector/Services/SessionRequestCodegen.cs b/src/Titanium.Inspector/Services/SessionRequestCodegen.cs new file mode 100644 index 000000000..a8cd542ba --- /dev/null +++ b/src/Titanium.Inspector/Services/SessionRequestCodegen.cs @@ -0,0 +1,171 @@ +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; + +namespace Titanium.Inspector.Services; + +/// Generates curl and fetch snippets from a captured (offline; no hot path). +public static class SessionRequestCodegen +{ + private static readonly JsonSerializerOptions JsStringOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + private static readonly HashSet SkippedHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "Content-Length", + "Transfer-Encoding", + "Host", + "Connection", + "Proxy-Connection", + "Keep-Alive", + "Proxy-Authorization", + }; + + /// True when the session can be turned into curl/fetch (non-tunnel with a URL). + public static bool CanGenerate(SessionSnapshot? session) => + session is not null + && !session.IsTunnel + && !string.IsNullOrWhiteSpace(session.Url); + + /// Builds a shell-safe curl command for the request side of the session. + public static string ToCurl(SessionSnapshot session) + { + if (!CanGenerate(session)) + { + throw new ArgumentException("Session has no replayable URL (or is a CONNECT tunnel).", nameof(session)); + } + + var sb = new StringBuilder(); + sb.Append("curl ").Append(ShellSingleQuote(session.Url)); + + var method = string.IsNullOrWhiteSpace(session.Method) ? "GET" : session.Method.Trim().ToUpperInvariant(); + if (!string.Equals(method, "GET", StringComparison.Ordinal)) + { + sb.Append(" \\\n -X ").Append(ShellSingleQuote(method)); + } + + foreach (var (name, value) in EnumerateHeaders(session.RequestHeadersText)) + { + sb.Append(" \\\n -H ").Append(ShellSingleQuote(name + ": " + value)); + } + + var body = ResolveBody(session); + if (body is not null) + { + sb.Append(" \\\n --data-binary ").Append(ShellSingleQuote(body)); + } + + return sb.ToString(); + } + + /// Builds a JavaScript fetch(...) call for the request side of the session. + public static string ToFetch(SessionSnapshot session) + { + if (!CanGenerate(session)) + { + throw new ArgumentException("Session has no replayable URL (or is a CONNECT tunnel).", nameof(session)); + } + + var method = string.IsNullOrWhiteSpace(session.Method) ? "GET" : session.Method.Trim().ToUpperInvariant(); + var headers = EnumerateHeaders(session.RequestHeadersText).ToList(); + var body = ResolveBody(session); + + var sb = new StringBuilder(); + sb.Append("fetch(").Append(JsonSerializer.Serialize(session.Url, JsStringOptions)); + + var needsInit = !string.Equals(method, "GET", StringComparison.Ordinal) + || headers.Count > 0 + || body is not null; + if (!needsInit) + { + sb.Append(");"); + return sb.ToString(); + } + + sb.Append(", {\n"); + sb.Append(" \"method\": ").Append(JsonSerializer.Serialize(method, JsStringOptions)); + + if (headers.Count > 0) + { + sb.Append(",\n \"headers\": {\n"); + for (var i = 0; i < headers.Count; i++) + { + var (name, value) = headers[i]; + sb.Append(" ") + .Append(JsonSerializer.Serialize(name, JsStringOptions)) + .Append(": ") + .Append(JsonSerializer.Serialize(value, JsStringOptions)); + if (i < headers.Count - 1) + { + sb.Append(','); + } + + sb.Append('\n'); + } + + sb.Append(" }"); + } + + if (body is not null) + { + sb.Append(",\n \"body\": ").Append(JsonSerializer.Serialize(body, JsStringOptions)); + } + + sb.Append("\n});"); + return sb.ToString(); + } + + private static IEnumerable<(string Name, string Value)> EnumerateHeaders(string? headerBlock) + { + if (string.IsNullOrWhiteSpace(headerBlock)) + { + yield break; + } + + foreach (var line in headerBlock.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + var idx = line.IndexOf(':'); + if (idx <= 0) + { + continue; + } + + var name = line[..idx].Trim(); + var value = line[(idx + 1)..].Trim(); + if (name.Length == 0 || SkippedHeaders.Contains(name)) + { + continue; + } + + yield return (name, value); + } + } + + private static string? ResolveBody(SessionSnapshot session) + { + if (!string.IsNullOrEmpty(session.RequestBodyText)) + { + return session.RequestBodyText; + } + + if (session.RequestBodyBytes is { Length: > 0 }) + { + return Encoding.UTF8.GetString(session.RequestBodyBytes); + } + + return null; + } + + /// POSIX single-quote escaping: wrap in '...', with embedded quotes as '\''. + internal static string ShellSingleQuote(string value) + { + if (string.IsNullOrEmpty(value)) + { + return "''"; + } + + return "'" + value.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; + } +} diff --git a/src/Titanium.Inspector/Services/SessionSearch.cs b/src/Titanium.Inspector/Services/SessionSearch.cs index fc4c4a861..7f65aef72 100644 --- a/src/Titanium.Inspector/Services/SessionSearch.cs +++ b/src/Titanium.Inspector/Services/SessionSearch.cs @@ -165,6 +165,86 @@ public static string SetKeyedToken(string? query, string key, string value) /// Clear the entire search/filter query. public static string ClearFilters(string? _) => ""; + /// True when the query includes a body: token. + public static bool HasBodyToken(string? query) + { + if (string.IsNullOrWhiteSpace(query)) + { + return false; + } + + return Tokenize(query).Any(t => t.Key == "body"); + } + + /// + /// Hint when body: is active but some session bodies live only on disk + /// (search does not hydrate spilled bodies). + /// + public static string? FormatBodySearchScopeHint(string? query, int spilledCount) + { + if (spilledCount <= 0 || !HasBodyToken(query)) + { + return null; + } + + return spilledCount == 1 + ? "body search: in-memory only, 1 on disk skipped" + : $"body search: in-memory only, {spilledCount} on disk skipped"; + } + + /// + /// Status-bar session count / search-scope text. Metadata search always covers listed rows; + /// body: is in-memory only unless bodies are hot. + /// + public static string BuildSessionCountText( + int visibleCount, + int totalCount, + string? searchQuery, + int spilledCount, + int retentionEvictedTotal, + DateTimeOffset? oldestStartedUtc) + { + var searching = !string.IsNullOrWhiteSpace(searchQuery); + var text = searching + ? $"Sessions: {visibleCount} / {totalCount}" + : $"Sessions: {totalCount}"; + + if (spilledCount > 0) + { + text += $" ({spilledCount} bodies on disk)"; + } + + if (retentionEvictedTotal > 0 && oldestStartedUtc is { } oldest) + { + text += $" · since {oldest.ToLocalTime():HH:mm}"; + } + + var bodyHint = FormatBodySearchScopeHint(searchQuery, spilledCount); + if (bodyHint is not null) + { + text += $" · {bodyHint}"; + } + + if (searching && visibleCount == 0 && totalCount > 0) + text += FormatEmptySearchRetentionHint(bodyHint, retentionEvictedTotal); + + return text; + } + + private static string FormatEmptySearchRetentionHint(string? bodyHint, int retentionEvictedTotal) + { + if (retentionEvictedTotal <= 0) + return ""; + + var retention = retentionEvictedTotal == 1 + ? "1 removed by retention" + : $"{retentionEvictedTotal} removed by retention"; + + return bodyHint is not null + ? $" · {retention}" + : $" · no matches in current list · {retention}"; + } + private static List<(string Key, string Value)> Tokenize(string query) { var list = new List<(string, string)>(); @@ -200,9 +280,13 @@ private static bool MatchToken(SessionSnapshot s, (string Key, string Value) tok { "ws" or "websocket" => s.IsWebSocket, "grpc" => s.IsGrpc, + "transcoded" => s.IsTranscoded, "tunnel" => s.IsTunnel, "multipart" => s.IsMultipart, "error" or "errors" => IsErrorStatus(s.StatusCode), + "opaque" or "encrypted" => s.IsTunnel && s.OpaqueReason != OpaqueTunnelReason.None, + _ when token.Value.StartsWith("opaque-reason:", StringComparison.OrdinalIgnoreCase) => + MatchOpaqueReason(s, token.Value["opaque-reason:".Length..]), _ => true, }, "hide" => token.Value.ToLowerInvariant() switch @@ -286,4 +370,24 @@ internal static bool IsImageOrStatic(SessionSnapshot s) return ImageOrStaticExtensions.Any(ext => path.EndsWith(ext, StringComparison.OrdinalIgnoreCase)); } + + private static bool MatchOpaqueReason(SessionSnapshot s, string reasonToken) + { + if (!s.IsTunnel || s.OpaqueReason == OpaqueTunnelReason.None) + { + return false; + } + + return reasonToken.ToLowerInvariant() switch + { + "builtin" or "built-in" => s.OpaqueReason is OpaqueTunnelReason.BuiltInIdentity + or OpaqueTunnelReason.BuiltInPinning, + "identity" or "microsoft" => s.OpaqueReason == OpaqueTunnelReason.BuiltInIdentity, + "pinning" => s.OpaqueReason == OpaqueTunnelReason.BuiltInPinning, + "skip" or "skiplist" => s.OpaqueReason == OpaqueTunnelReason.UserSkipList, + "only" or "onlylist" => s.OpaqueReason == OpaqueTunnelReason.UserOnlyList, + "decrypt-off" or "decryptoff" => s.OpaqueReason == OpaqueTunnelReason.DecryptOff, + _ => s.OpaqueReason.ToString().Equals(reasonToken, StringComparison.OrdinalIgnoreCase), + }; + } } diff --git a/src/Titanium.Inspector/Services/SessionSnapshot.cs b/src/Titanium.Inspector/Services/SessionSnapshot.cs index 4391c811d..903263c77 100644 --- a/src/Titanium.Inspector/Services/SessionSnapshot.cs +++ b/src/Titanium.Inspector/Services/SessionSnapshot.cs @@ -36,11 +36,45 @@ public sealed class SessionSnapshot : INotifyPropertyChanged public bool BodiesOnDisk { get; set; } public bool IsWebSocket { get; set; } public bool IsGrpc { get; set; } + public bool IsTranscoded { get; set; } public bool IsTunnel { get; set; } + public OpaqueTunnelReason OpaqueReason { get; set; } + + /// Client-facing HTTP method before gRPC-JSON rewrite (when ). + public string? ClientMethod { get; set; } + + /// Client-facing path/query before gRPC-JSON rewrite (when ). + public string? ClientPathAndQuery { get; set; } + + /// Client Content-Type before gRPC-JSON rewrite (when ). + public string? ClientContentType { get; set; } + + /// Upstream gRPC method (usually POST) after rewrite. + public string? UpstreamMethod { get; set; } + + /// Upstream gRPC path (/package.Service/Method). + public string? UpstreamPath { get; set; } + + /// Upstream Content-Type (application/grpc). + public string? UpstreamContentType { get; set; } + + /// Framed protobuf request bytes sent upstream (Inspector hex/frames). + public byte[]? UpstreamRequestBodyBytes { get; set; } + + /// Framed protobuf response bytes from upstream before JSON rewrite. + public byte[]? UpstreamResponseBodyBytes { get; set; } + + /// Human-readable opaque tunnel explanation for tooltips and inspect pane. + public string OpaqueReasonDisplay => ExclusionPreview.DescribeOpaqueReason(OpaqueReason); + public bool IsMultipart { get; set; } + public bool IsServerSentEvents { get; set; } public IReadOnlyList? WebSocketFrames { get; set; } + public IReadOnlyList? SseEvents { get; set; } public IReadOnlyList? GrpcFrames { get; set; } public IReadOnlyList? MultipartParts { get; set; } + /// Optional protobuf wire-format or descriptor decode text for inspect. + public string? ProtobufDecodedText { get; set; } public int? StatusCode { diff --git a/src/Titanium.Inspector/Services/SessionStore.cs b/src/Titanium.Inspector/Services/SessionStore.cs index 3da2c0f3c..337fa5445 100644 --- a/src/Titanium.Inspector/Services/SessionStore.cs +++ b/src/Titanium.Inspector/Services/SessionStore.cs @@ -117,7 +117,12 @@ public long? PinnedSessionId /// Insert a new session, or refresh body budget if the id already exists. public void Add(SessionSnapshot snapshot) { - ObjectDisposedException.ThrowIf(_disposed, this); + // Late UI-marshaled pipeline events may arrive after EnsureShutdown disposed the store. + if (_disposed) + { + return; + } + var isNew = false; List? removed = null; lock (_gate) @@ -158,7 +163,12 @@ public void Add(SessionSnapshot snapshot) public void NotifyUpdated(SessionSnapshot snapshot) { - ObjectDisposedException.ThrowIf(_disposed, this); + // Same shutdown race as Add — do not crash Avalonia's dispatcher. + if (_disposed) + { + return; + } + List? removed = null; lock (_gate) { diff --git a/src/Titanium.Inspector/Services/SettingsService.cs b/src/Titanium.Inspector/Services/SettingsService.cs index 9b6c49227..4006b4cbf 100644 --- a/src/Titanium.Inspector/Services/SettingsService.cs +++ b/src/Titanium.Inspector/Services/SettingsService.cs @@ -1,8 +1,16 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Titanium.Web.Proxy; namespace Titanium.Inspector.Services; +public enum ThemeMode +{ + Automatic, + Light, + Dark, +} + public sealed class AutoResponderRuleDto { public string MatchUrl { get; set; } = "*"; @@ -10,6 +18,20 @@ public sealed class AutoResponderRuleDto public string Body { get; set; } = string.Empty; public string ContentType { get; set; } = "text/plain"; public bool Enabled { get; set; } = true; + + /// When set, response body is read from this file (Map Local) instead of . + public string? LocalFilePath { get; set; } + + /// Optional GraphQL operationName; when set, rule matches only that operation. + public string? GraphQlOperationName { get; set; } +} + +public sealed class MapRemoteRuleDto +{ + public string MatchUrl { get; set; } = "*"; + public string TargetUrl { get; set; } = "http://127.0.0.1/"; + public bool Enabled { get; set; } = true; + public string? GraphQlOperationName { get; set; } } public sealed class InspectorSettings @@ -18,16 +40,28 @@ public sealed class InspectorSettings public DateTimeOffset? LastUpdateCheckUtc { get; set; } public string UpdateChannel { get; set; } = "Stable"; + /// Release tag last applied via in-app update (e.g. 7.0.5-beta). Null when unknown / manual install. + public string? InstalledReleaseTag { get; set; } + + /// Channel of (Stable or Beta). Null when unknown. + public string? InstalledReleaseChannel { get; set; } + public string BindAddress { get; set; } = "127.0.0.1"; public int BindPort { get; set; } = 8866; public bool AutoResponderEnabled { get; set; } public List AutoResponderRules { get; set; } = new(); + public bool MapRemoteEnabled { get; set; } + public List MapRemoteRules { get; set; } = new(); + public bool BreakpointEnabled { get; set; } public string BreakpointUrlFilter { get; set; } = "*"; public bool BreakpointOnResponse { get; set; } + /// Optional GraphQL operationName for breakpoints. + public string? BreakpointGraphQlOperationName { get; set; } + public string? ScriptOnRequest { get; set; } public string? ScriptOnResponse { get; set; } @@ -53,6 +87,9 @@ public sealed class InspectorSettings /// When false, HTTPS stays opaque CONNECT tunnels (Fiddler-like default). public bool DecryptHttps { get; set; } + /// App color theme: follow OS (Automatic), Light, or Dark. + public ThemeMode ThemeMode { get; set; } = ThemeMode.Automatic; + /// Session grid column widths, order, and sort across launches. public SessionGridLayoutDto? SessionGridLayout { get; set; } @@ -74,13 +111,34 @@ public sealed class InspectorSettings /// Delete spill files older than this many days on startup. public int DiskCacheMaxAgeDays { get; set; } = 7; - /// Extra host patterns that skip HTTPS decryption (one pattern per entry; supports *.example.com). + /// Host patterns that skip HTTPS decryption (tunnel only). Supports *.example.com. public List DecryptSkipHosts { get; set; } = new(); /// - /// When non-empty, only these host patterns are decrypted (built-in bypass hosts still never decrypt). + /// Legacy decrypt-only allowlist. Inspector no longer edits or applies this; kept for settings back-compat. /// public List DecryptOnlyHosts { get; set; } = new(); + + /// OS system-proxy bypass patterns when System proxy is on (Replace mode — full list). + public List SystemProxyBypassHosts { get; set; } = new(); + + /// When true, localhost uses the proxy (WinINET <-loopback> / Unix NO_PROXY parity). + public bool ProxyLoopback { get; set; } = true; + + /// + /// When true, and were seeded + /// from factory defaults (or saved by the user). When false, load applies factory seed once. + /// + public bool ExclusionsInitialized { get; set; } + + /// User acknowledged PAC replace warning when enabling System proxy. + public bool WarnedAboutPacReplace { get; set; } + + /// Optional FileDescriptorSet path for protobuf decode on inspect. + public string? ProtobufDescriptorSetPath { get; set; } + + /// Active network throttle profile name (None, Slow 3G, …). + public string NetworkThrottleProfile { get; set; } = "None"; } public sealed class SettingsService @@ -113,6 +171,62 @@ public void Save() File.WriteAllText(_path, JsonSerializer.Serialize(Current, JsonOptions)); } + /// + /// Seeds OS-bypass and tunnel-only lists from when not yet initialized. + /// Persists when seeding changes settings. + /// + public bool EnsureExclusionsSeeded() + { + if (Current.ExclusionsInitialized) + { + return false; + } + + ApplyFactoryExclusionDefaults(Current); + Current.ExclusionsInitialized = true; + Save(); + return true; + } + + /// Restores factory OS-bypass and tunnel-only lists (and loopback). + public void ResetExclusionsToFactoryDefaults() + { + ApplyFactoryExclusionDefaults(Current); + Current.DecryptOnlyHosts = []; + Current.ExclusionsInitialized = true; + Save(); + } + + public static void ApplyFactoryExclusionDefaults(InspectorSettings settings) + { + settings.SystemProxyBypassHosts = MitmExclusionDefaults.SystemProxyBypassRules.ToList(); + settings.DecryptSkipHosts = MitmExclusionDefaults.TunnelOnlyPinningDomains.ToList(); + settings.ProxyLoopback = true; + } + + /// + /// Adds any factory OS-bypass hosts missing from the saved list (does not remove user entries). + /// Returns true when the list changed. + /// + internal static bool MergeMissingFactoryOsBypassHosts(InspectorSettings settings) + { + settings.SystemProxyBypassHosts ??= []; + var changed = false; + foreach (var rule in MitmExclusionDefaults.SystemProxyBypassRules) + { + if (settings.SystemProxyBypassHosts.Any(h => + string.Equals(h, rule, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + settings.SystemProxyBypassHosts.Add(rule); + changed = true; + } + + return changed; + } + /// /// Replace preferences with factory defaults and write settings.json. /// Does not touch the root CA, OS trust stores, or captured sessions / disk body cache. @@ -120,6 +234,8 @@ public void Save() public void ResetToFactoryDefaults() { Current = new InspectorSettings(); + ApplyFactoryExclusionDefaults(Current); + Current.ExclusionsInitialized = true; Save(); } @@ -127,7 +243,10 @@ private InspectorSettings LoadFromDisk() { if (!File.Exists(_path)) { - return new InspectorSettings(); + var fresh = new InspectorSettings(); + ApplyFactoryExclusionDefaults(fresh); + fresh.ExclusionsInitialized = true; + return fresh; } try @@ -135,11 +254,46 @@ private InspectorSettings LoadFromDisk() var json = File.ReadAllText(_path); var loaded = JsonSerializer.Deserialize(json, JsonOptions) ?? new InspectorSettings(); + if (!loaded.ExclusionsInitialized) + { + // Migrate: empty lists previously relied on silent Merge of factory defaults. + if (loaded.SystemProxyBypassHosts.Count == 0 && loaded.DecryptSkipHosts.Count == 0) + { + ApplyFactoryExclusionDefaults(loaded); + } + + loaded.ExclusionsInitialized = true; + try + { + File.WriteAllText(_path, JsonSerializer.Serialize(loaded, JsonOptions)); + } + catch + { + // best effort + } + } + else if (MergeMissingFactoryOsBypassHosts(loaded)) + { + // Additive: new factory SSO/identity hosts must land in existing settings.json or + // Inspector Replace-mode system proxy omits them. + try + { + File.WriteAllText(_path, JsonSerializer.Serialize(loaded, JsonOptions)); + } + catch + { + // best effort + } + } + return loaded; } catch { - return new InspectorSettings(); + var fallback = new InspectorSettings(); + ApplyFactoryExclusionDefaults(fallback); + fallback.ExclusionsInitialized = true; + return fallback; } } } diff --git a/src/Titanium.Inspector/Services/StatusSeverity.cs b/src/Titanium.Inspector/Services/StatusSeverity.cs new file mode 100644 index 000000000..030ebc630 --- /dev/null +++ b/src/Titanium.Inspector/Services/StatusSeverity.cs @@ -0,0 +1,11 @@ +namespace Titanium.Inspector.Services; + +/// Semantic severity for the main-window status bar (and optional toasts). +public enum StatusSeverity +{ + Neutral, + Busy, + Success, + Warning, + Error, +} diff --git a/src/Titanium.Inspector/Services/SystemProxyPacHelper.cs b/src/Titanium.Inspector/Services/SystemProxyPacHelper.cs new file mode 100644 index 000000000..0cfcf6d60 --- /dev/null +++ b/src/Titanium.Inspector/Services/SystemProxyPacHelper.cs @@ -0,0 +1,98 @@ +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Win32; + +namespace Titanium.Inspector.Services; + +/// Detects PAC / WPAD scripts before Inspector replaces system proxy settings. +public static class SystemProxyPacHelper +{ + private const string RegKeyInternetSettings = @"Software\Microsoft\Windows\CurrentVersion\Internet Settings"; + private const string RegAutoConfigUrl = "AutoConfigURL"; + + public static bool HasActivePacScript() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return HasWindowsPac(); + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + return HasMacPac(); + + return false; + } + + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private static bool HasWindowsPac() + { + try + { + using var reg = Registry.CurrentUser.OpenSubKey(RegKeyInternetSettings, false); + var url = reg?.GetValue(RegAutoConfigUrl) as string; + return !string.IsNullOrWhiteSpace(url); + } + catch + { + return false; + } + } + + private static bool HasMacPac() + { + try + { + var scutil = File.Exists("/usr/sbin/scutil") ? "/usr/sbin/scutil" : null; + if (scutil is null) + return false; + + var psi = new ProcessStartInfo + { + FileName = scutil, + Arguments = "--proxy", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + using var process = Process.Start(psi); + if (process is null) + return false; + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(3000); + return ScutilIndicatesPacOrWpad(output); + } + catch + { + return false; + } + } + + /// True when scutil --proxy shows PAC or WPAD enabled (Firefox would ignore manual HTTP proxy). + internal static bool ScutilIndicatesPacOrWpad(string? output) + { + if (string.IsNullOrWhiteSpace(output)) + return false; + + foreach (var raw in output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + var line = raw.Trim(); + var sep = line.IndexOf(':'); + if (sep < 0) continue; + var key = line[..sep].Trim(); + var value = line[(sep + 1)..].Trim(); + if (!key.Equals("ProxyAutoConfigEnable", StringComparison.OrdinalIgnoreCase) && + !key.Equals("ProxyAutoDiscoveryEnable", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (value == "1" || value.Equals("true", StringComparison.OrdinalIgnoreCase) || + value.Equals("yes", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/src/Titanium.Inspector/Services/ThemeService.cs b/src/Titanium.Inspector/Services/ThemeService.cs new file mode 100644 index 000000000..6cc60b693 --- /dev/null +++ b/src/Titanium.Inspector/Services/ThemeService.cs @@ -0,0 +1,22 @@ +using Avalonia; +using Avalonia.Styling; + +namespace Titanium.Inspector.Services; + +public static class ThemeService +{ + public static void ApplyThemeMode(ThemeMode mode) + { + if (Application.Current is null) + { + return; + } + + Application.Current.RequestedThemeVariant = mode switch + { + ThemeMode.Light => ThemeVariant.Light, + ThemeMode.Dark => ThemeVariant.Dark, + _ => ThemeVariant.Default, + }; + } +} diff --git a/src/Titanium.Inspector/Services/TrustRecoveryChoice.cs b/src/Titanium.Inspector/Services/TrustRecoveryChoice.cs new file mode 100644 index 000000000..f0e8de88d --- /dev/null +++ b/src/Titanium.Inspector/Services/TrustRecoveryChoice.cs @@ -0,0 +1,9 @@ +namespace Titanium.Inspector.Services; + +/// User choice from the adaptive OS CA trust recovery dialog. +public enum TrustRecoveryChoice +{ + Cancel = 0, + Primary = 1, + Secondary = 2, +} diff --git a/src/Titanium.Inspector/Services/UpdateService.cs b/src/Titanium.Inspector/Services/UpdateService.cs index 00750f82c..a173c47ee 100644 --- a/src/Titanium.Inspector/Services/UpdateService.cs +++ b/src/Titanium.Inspector/Services/UpdateService.cs @@ -4,6 +4,7 @@ using System.Text; using System.Text.Json; using Microsoft.Win32; +using Titanium.Web.Proxy.Abstractions.Updates; namespace Titanium.Inspector.Services; @@ -13,6 +14,15 @@ public enum UpdateApplyKind Zip, } +/// How an offered channel install should be described to the user. +public enum UpdateOfferKind +{ + None, + Upgrade, + ChannelSwitch, + Downgrade, +} + public sealed class UpdateCheckResult { public bool UpdateAvailable { get; init; } @@ -22,6 +32,9 @@ public sealed class UpdateCheckResult public string? AssetUrl { get; init; } public string? AssetSha256 { get; init; } public UpdateApplyKind ApplyKind { get; init; } = UpdateApplyKind.Zip; + /// True when remote semver is lower than the running build (channel switch / downgrade). + public bool IsDowngrade { get; init; } + public UpdateOfferKind OfferKind { get; init; } } /// GitHub Releases + release-manifest updater for Stable/Beta channels. @@ -77,14 +90,15 @@ public async Task CheckAsync(CancellationToken cancellationTo }; } - var remoteText = manifest.Version?.TrimStart('v') ?? "0.0.0"; - if (!Version.TryParse(remoteText.Split('-')[0], out var remote)) - { - remote = new Version(0, 0); - } + var remoteText = NormalizeReleaseTag(manifest.Version); + var remote = ReleaseVersion.ParseComparable(remoteText); + var localComparable = ReleaseVersion.ToComparable(local); - if (remote <= local) + var installedTag = _settings.Current.InstalledReleaseTag; + var installedChannel = _settings.Current.InstalledReleaseChannel; + if (!ShouldOfferChannelInstall(local, remoteText, channelDisplay, installedTag, installedChannel)) { + SeedInstalledIdentity(remoteText, channelDisplay); return new UpdateCheckResult { RemoteVersion = remoteText, @@ -93,6 +107,7 @@ public async Task CheckAsync(CancellationToken cancellationTo }; } + var offerKind = ClassifyOfferKind(local, remoteText, channelDisplay, installedTag, installedChannel); var (kind, asset) = ResolveAsset(manifest); if (asset?.Url is null) { @@ -101,11 +116,35 @@ public async Task CheckAsync(CancellationToken cancellationTo UpdateAvailable = true, RemoteVersion = remoteText, ChannelDisplay = channelDisplay, + IsDowngrade = offerKind == UpdateOfferKind.Downgrade, + OfferKind = offerKind, + Message = + $"Install {remoteText} ({channelDisplay}) is available, but no package was found for this install.", + }; + } + + // Windows MSI cannot MajorUpgrade to the same or older ProductVersion. + if (kind == UpdateApplyKind.Msi && remote <= localComparable) + { + return new UpdateCheckResult + { + RemoteVersion = remoteText, + ChannelDisplay = channelDisplay, + OfferKind = UpdateOfferKind.None, Message = - $"Update {remoteText} ({channelDisplay}) is available, but no package was found for this install.", + $"Windows Installer cannot replace this install with {remoteText} ({channelDisplay}) " + + "(same or older version). Uninstall Titanium Inspector first, or download from the website.", }; } + var message = offerKind switch + { + UpdateOfferKind.Upgrade => $"Update available: {remoteText} ({channelDisplay})", + UpdateOfferKind.Downgrade => + $"Install older {channelDisplay} {remoteText} (replaces your current build)", + _ => $"Switch to {channelDisplay} {remoteText} (replaces your current build)", + }; + return new UpdateCheckResult { UpdateAvailable = true, @@ -114,7 +153,9 @@ public async Task CheckAsync(CancellationToken cancellationTo AssetUrl = asset.Url, AssetSha256 = asset.Sha256, ApplyKind = kind, - Message = $"Update available: {remoteText} ({channelDisplay})", + IsDowngrade = offerKind == UpdateOfferKind.Downgrade, + OfferKind = offerKind, + Message = message, }; } catch (Exception ex) @@ -127,6 +168,126 @@ public async Task CheckAsync(CancellationToken cancellationTo } } + /// + /// Whether the selected channel's latest release should be offered — upgrades and intentional + /// channel/build switches (not phantom same-version reinstalls from 3-part vs 4-part Version). + /// + public static bool ShouldOfferChannelInstall( + Version local, + string remoteText, + string channelDisplay, + string? installedReleaseTag, + string? installedReleaseChannel) + { + remoteText = NormalizeReleaseTag(remoteText); + var remoteSemver = ReleaseVersion.ParseComparable(remoteText); + var localSemver = ReleaseVersion.ToComparable(local); + var isBetaChannel = channelDisplay.Equals("Beta", StringComparison.OrdinalIgnoreCase); + + var tagMatches = !string.IsNullOrEmpty(installedReleaseTag) + && installedReleaseTag.Equals(remoteText, StringComparison.OrdinalIgnoreCase); + var channelMatches = !string.IsNullOrEmpty(installedReleaseChannel) + && installedReleaseChannel.Equals(channelDisplay, StringComparison.OrdinalIgnoreCase); + + // Exact channel build already installed and assembly matches remote semver. + if (tagMatches && channelMatches && remoteSemver == localSemver) + { + return false; + } + + // Persisted tag matches remote but assembly does not (e.g. failed MSI/UAC) — re-offer. + if (tagMatches && channelMatches && remoteSemver != localSemver) + { + return true; + } + + if (remoteSemver > localSemver) + { + return true; + } + + if (remoteSemver == localSemver) + return ShouldOfferSameSemverSwitch(channelDisplay, installedReleaseChannel, isBetaChannel, remoteText, tagMatches); + + // remote < local: only intentional channel / known-origin switches. + if (!string.IsNullOrEmpty(installedReleaseChannel) + && !installedReleaseChannel.Equals(channelDisplay, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + + private static bool ShouldOfferSameSemverSwitch( + string channelDisplay, + string? installedReleaseChannel, + bool isBetaChannel, + string remoteText, + bool tagMatches) + { + if (!string.IsNullOrEmpty(installedReleaseChannel) + && !installedReleaseChannel.Equals(channelDisplay, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return isBetaChannel && remoteText.Contains('-', StringComparison.Ordinal) && !tagMatches; + } + + /// Classify an offered install for dialog copy. + public static UpdateOfferKind ClassifyOfferKind( + Version local, + string remoteText, + string channelDisplay, + string? installedReleaseTag, + string? installedReleaseChannel) + { + if (!ShouldOfferChannelInstall(local, remoteText, channelDisplay, installedReleaseTag, installedReleaseChannel)) + { + return UpdateOfferKind.None; + } + + var remoteSemver = ReleaseVersion.ParseComparable(remoteText); + var localSemver = ReleaseVersion.ToComparable(local); + if (remoteSemver > localSemver) + { + return UpdateOfferKind.Upgrade; + } + + if (remoteSemver < localSemver) + { + return UpdateOfferKind.Downgrade; + } + + return UpdateOfferKind.ChannelSwitch; + } + + public static string NormalizeReleaseTag(string? tag) => ReleaseVersion.NormalizeTag(tag); + + public static string StripPrerelease(string tag) => ReleaseVersion.StripPrerelease(tag); + + private void SeedInstalledIdentity(string remoteText, string channelDisplay) + { + var changed = false; + if (!string.Equals(_settings.Current.InstalledReleaseTag, remoteText, StringComparison.OrdinalIgnoreCase)) + { + _settings.Current.InstalledReleaseTag = remoteText; + changed = true; + } + + if (!string.Equals(_settings.Current.InstalledReleaseChannel, channelDisplay, StringComparison.OrdinalIgnoreCase)) + { + _settings.Current.InstalledReleaseChannel = channelDisplay; + changed = true; + } + + if (changed) + { + _settings.Save(); + } + } + /// Download package, verify SHA256, spawn apply helper, return true if helper started. public async Task<(bool Ok, string Message)> DownloadAndStartApplyAsync( UpdateCheckResult check, @@ -170,7 +331,7 @@ public async Task CheckAsync(CancellationToken cancellationTo } UpdateApplyHelper.StartDetached( - Process.GetCurrentProcess().Id, + Environment.ProcessId, check.ApplyKind, packagePath, installDir, @@ -178,6 +339,12 @@ public async Task CheckAsync(CancellationToken cancellationTo check.RemoteVersion ?? "", check.ChannelDisplay); + // Persist after the helper starts so a failed spawn does not claim the build is installed. + // If MSI UAC is cancelled later, tag may ahead of assembly — ShouldOffer re-offers when they differ. + _settings.Current.InstalledReleaseTag = check.RemoteVersion; + _settings.Current.InstalledReleaseChannel = check.ChannelDisplay; + _settings.Save(); + return (true, $"Installing {check.RemoteVersion} ({check.ChannelDisplay})…"); } catch (Exception ex) @@ -240,7 +407,7 @@ public static string SuggestRid() return arm ? "linux-arm64" : "linux-x64"; } - public (UpdateApplyKind Kind, ManifestAsset? Asset) ResolveAsset(InspectorReleaseManifest manifest) + public static (UpdateApplyKind Kind, ManifestAsset? Asset) ResolveAsset(InspectorReleaseManifest manifest) { var assets = manifest.Products?.Inspector?.Assets; if (assets is null) @@ -272,7 +439,7 @@ public static string SuggestRid() return (UpdateApplyKind.Zip, null); } - private async Task TryGetManifestAsync( + private static async Task TryGetManifestAsync( HttpClient http, string channelDisplay, CancellationToken cancellationToken) diff --git a/src/Titanium.Inspector/Titanium.Inspector.csproj b/src/Titanium.Inspector/Titanium.Inspector.csproj index b96cbadd9..9709dceef 100644 --- a/src/Titanium.Inspector/Titanium.Inspector.csproj +++ b/src/Titanium.Inspector/Titanium.Inspector.csproj @@ -8,7 +8,7 @@ enable true false - 7.0.4 + 7.0.5 Jehonathan Thomas Titanium Inspector desktop traffic debugger (PolyForm Noncommercial). LICENSE @@ -18,6 +18,9 @@ Assets\app.ico + + + @@ -34,4 +37,19 @@ + + + + + + <_Http3LaunchSettings>$(MSBuildProjectDirectory)\Properties\launchSettings.json + <_Http3TargetDir>$([System.IO.Path]::GetFullPath('$(TargetDir)').TrimEnd('\').TrimEnd('/')) + + + + diff --git a/src/Titanium.Inspector/ViewModels/AutoResponderViewModel.cs b/src/Titanium.Inspector/ViewModels/AutoResponderViewModel.cs index dc038b072..d4caaea7c 100644 --- a/src/Titanium.Inspector/ViewModels/AutoResponderViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/AutoResponderViewModel.cs @@ -1,12 +1,13 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Runtime.CompilerServices; +using System.Text; using System.Text.RegularExpressions; using Titanium.Inspector.Services; namespace Titanium.Inspector.ViewModels; -/// AutoResponder rules — evaluated before breakpoints. +/// AutoResponder rules — evaluated before breakpoints. Optional Map Local file body. public sealed class AutoResponderViewModel : INotifyPropertyChanged { private bool _enabled; @@ -63,6 +64,8 @@ public void LoadFromDtos(IEnumerable dtos) Body = dto.Body, ContentType = string.IsNullOrEmpty(dto.ContentType) ? "text/plain" : dto.ContentType, Enabled = dto.Enabled, + LocalFilePath = dto.LocalFilePath ?? string.Empty, + GraphQlOperationName = dto.GraphQlOperationName ?? string.Empty, }); } } @@ -75,9 +78,11 @@ public List ToDtos() => Body = r.Body, ContentType = r.ContentType, Enabled = r.Enabled, + LocalFilePath = string.IsNullOrWhiteSpace(r.LocalFilePath) ? null : r.LocalFilePath, + GraphQlOperationName = string.IsNullOrWhiteSpace(r.GraphQlOperationName) ? null : r.GraphQlOperationName, }).ToList(); - public bool TryMatch(string url, out AutoResponderRule? matched) + public bool TryMatch(string url, string? requestBody, out AutoResponderRule? matched) { matched = null; if (!Enabled) @@ -97,6 +102,11 @@ public bool TryMatch(string url, out AutoResponderRule? matched) continue; } + if (!GraphQlOperationMatcher.MatchesOperation(requestBody, rule.GraphQlOperationName)) + { + continue; + } + matched = rule; return true; } @@ -104,8 +114,45 @@ public bool TryMatch(string url, out AutoResponderRule? matched) return false; } + public bool TryMatch(string url, out AutoResponderRule? matched) + => TryMatch(url, requestBody: null, out matched); + public bool TryRespond(SessionSnapshot session, out AutoResponderRule? matched) - => TryMatch(session.Url, out matched); + => TryMatch(session.Url, session.RequestBodyText, out matched); + + /// + /// Resolves the response body for a matched rule. Map Local () + /// wins when the path is non-empty; otherwise uses the inline . + /// + public static bool TryResolveBody(AutoResponderRule rule, out byte[] body, out string? error) + { + error = null; + if (!string.IsNullOrWhiteSpace(rule.LocalFilePath)) + { + try + { + var path = rule.LocalFilePath.Trim(); + if (!File.Exists(path)) + { + error = $"Map Local file not found: {path}"; + body = Array.Empty(); + return false; + } + + body = File.ReadAllBytes(path); + return true; + } + catch (Exception ex) + { + error = $"Map Local read failed: {ex.Message}"; + body = Array.Empty(); + return false; + } + } + + body = Encoding.UTF8.GetBytes(rule.Body ?? string.Empty); + return true; + } private static bool Matches(string filter, string url) { @@ -125,6 +172,8 @@ public sealed class AutoResponderRule : INotifyPropertyChanged private int _statusCode = 200; private string _body = string.Empty; private string _contentType = "text/plain"; + private string _localFilePath = string.Empty; + private string _graphQlOperationName = string.Empty; private bool _enabled = true; public string MatchUrl @@ -151,13 +200,35 @@ public string ContentType set => SetField(ref _contentType, value); } + /// Optional absolute path; when set, body is loaded from disk (Map Local). + public string LocalFilePath + { + get => _localFilePath; + set => SetField(ref _localFilePath, value ?? string.Empty); + } + + /// Optional GraphQL operationName filter for same-URL APIs. + public string GraphQlOperationName + { + get => _graphQlOperationName; + set => SetField(ref _graphQlOperationName, value ?? string.Empty); + } + public bool Enabled { get => _enabled; set => SetField(ref _enabled, value); } - public string Display => $"{(Enabled ? "✓" : "✗")} {StatusCode} {MatchUrl}"; + public string Display + { + get + { + var map = string.IsNullOrWhiteSpace(LocalFilePath) ? string.Empty : " [Map Local]"; + var gql = string.IsNullOrWhiteSpace(GraphQlOperationName) ? string.Empty : $" gql:{GraphQlOperationName}"; + return $"{(Enabled ? "✓" : "✗")} {StatusCode}{map}{gql} {MatchUrl}"; + } + } public event PropertyChangedEventHandler? PropertyChanged; diff --git a/src/Titanium.Inspector/ViewModels/BreakpointViewModel.cs b/src/Titanium.Inspector/ViewModels/BreakpointViewModel.cs index 00b391527..3bc9cacc5 100644 --- a/src/Titanium.Inspector/ViewModels/BreakpointViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/BreakpointViewModel.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Json; using System.Text.RegularExpressions; +using System.Threading; namespace Titanium.Inspector.ViewModels; @@ -11,6 +12,7 @@ public sealed class BreakpointViewModel : System.ComponentModel.INotifyPropertyC private BreakpointHit? _active; private bool _enabled; private string _urlFilter = "*"; + private string _graphQlOperationName = ""; public bool Enabled { @@ -32,6 +34,17 @@ public string UrlFilter } } + /// Optional GraphQL operationName; when set, breakpoints only match that operation. + public string GraphQlOperationName + { + get => _graphQlOperationName; + set + { + _graphQlOperationName = value ?? ""; + PropertyChanged?.Invoke(this, new(nameof(GraphQlOperationName))); + } + } + public TimeSpan Timeout { get; } = TimeSpan.FromSeconds(120); public BreakpointHit? Active => _active; @@ -126,7 +139,8 @@ public BreakpointHit(Services.SessionSnapshot session, TimeSpan timeout) public string? EditedBody { get; set; } public int? ContentLength { get; set; } - public Task WaitAsync() => _tcs.Task; + public Task WaitAsync(CancellationToken cancellationToken = default) => + _tcs.Task.WaitAsync(cancellationToken); public void Complete(BreakpointAction action) => _tcs.TrySetResult(action); } diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs new file mode 100644 index 000000000..29aaecf54 --- /dev/null +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Sessions.cs @@ -0,0 +1,816 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Net; +using System.Runtime.CompilerServices; +using System.Text; +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using Titanium.Inspector.Services; +using Titanium.Inspector.Views; +using Titanium.Web.Proxy; +using Titanium.Web.Proxy.Network; + +namespace Titanium.Inspector.ViewModels; + +public sealed partial class MainWindowViewModel +{ + private Task ClearSessionsAsync() + { + // Drop selection before mutating the grid so the DataGrid cannot cascade-select + // a neighbor row (SelectedSession setter would reopen a closed details pane). + _selectedSessions.Clear(); + SelectedSession = null; + + _userRemovalDepth++; + try + { + _store.Clear(); + } + finally + { + _userRemovalDepth--; + } + + Sessions.Clear(); + _retentionEvictedTotal = 0; + _interception.ResetSessionIdSequence(); + RefreshSessionCountText(); + NotifyFilterSelectionProperties(); + SetOutcomeStatus("Sessions cleared", StatusSeverity.Success, toastImportant: true); + return Task.CompletedTask; + } + private Task RemoveSelectedSessionsAsync() + { + var selected = ResolveExportSelection(); + if (selected.Count == 0) + { + SetGuardStatus("Select one or more sessions to remove"); + return Task.CompletedTask; + } + + var ids = selected.Select(s => s.Id).ToHashSet(); + // Clear selection of removed rows before store/grid mutation (same cascade as Clear). + _selectedSessions.RemoveAll(s => ids.Contains(s.Id)); + if (SelectedSession is not null && ids.Contains(SelectedSession.Id)) + { + SelectedSession = null; + } + + _userRemovalDepth++; + try + { + _store.Remove(ids); + } + finally + { + _userRemovalDepth--; + } + + for (var i = Sessions.Count - 1; i >= 0; i--) + { + if (ids.Contains(Sessions[i].Id)) + { + Sessions.RemoveAt(i); + } + } + + RefreshSessionCountText(); + NotifyFilterSelectionProperties(); + SetOutcomeStatus( + selected.Count == 1 ? "Removed 1 session" : $"Removed {selected.Count} sessions", + StatusSeverity.Success, + toastImportant: true); + return Task.CompletedTask; + } + private async Task LoadFromSelectedAsync() + { + var selected = SelectedSession; + if (selected is null) + { + StatusText = "Select a session to load into Composer"; + return; + } + + await _store.EnsureBodiesLoadedAsync(selected, _statusRevertCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + await MarshalToUiAsync(() => + { + ComposerMethod = selected.Method; + ComposerUrl = selected.Url; + ComposerHeaders = selected.RequestHeadersText ?? ""; + ComposerBody = selected.RequestBodyText ?? ""; + StatusText = "Composer loaded from selected session"; + }, _statusRevertCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + } + private async Task LoadIntoComposerAsync() + { + var selected = SelectedSession; + if (selected is null) + { + StatusText = "Select a session to load into Composer"; + return; + } + + await _store.EnsureBodiesLoadedAsync(selected, _statusRevertCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + await MarshalToUiAsync(() => + { + ComposerMethod = selected.Method; + ComposerUrl = selected.Url; + ComposerHeaders = selected.RequestHeadersText ?? ""; + ComposerBody = selected.RequestBodyText ?? ""; + StatusText = "Composer loaded from selected session"; + }, StatusCancelToken).ConfigureAwait(false); + await OpenToolsTabAsync(0).ConfigureAwait(false); + } + private async Task CopyUrlAsync() + { + var urls = ResolveCopyUrls(); + if (urls.Count == 0) + { + StatusText = "Select a session with a URL to copy"; + return; + } + + var text = string.Join(Environment.NewLine, urls); + var window = TryGetMainWindow(); + if (window?.Clipboard is { } clipboard) + { + await clipboard.SetTextAsync(text); + } + + StatusText = urls.Count == 1 ? "Copied URL" : $"Copied {urls.Count} URLs"; + } + private async Task CopyAsCurlAsync() + { + if (!TryBuildCopyAsCurl(out var curl)) + { + StatusText = "Select one session with a URL to copy as curl"; + return; + } + + await CopyTextToClipboardAsync(curl).ConfigureAwait(false); + StatusText = "Copied as curl"; + } + private async Task CopyAsFetchAsync() + { + if (!TryBuildCopyAsFetch(out var fetch)) + { + StatusText = "Select one session with a URL to copy as fetch"; + return; + } + + await CopyTextToClipboardAsync(fetch).ConfigureAwait(false); + StatusText = "Copied as fetch"; + } + private async Task DiffSessionsAsync() + { + if (!TryBuildSessionDiff(out var diff)) + { + StatusText = "Select exactly two sessions to diff"; + return; + } + + SessionDiffText = diff.Text; + await CopyTextToClipboardAsync(diff.Text).ConfigureAwait(false); + ShowSessionDetails = true; + SelectedOuterPaneIndex = 0; + SelectedInspectTabIndex = 3; // Diff tab + StatusText = diff.HasDifferences ? "Session Diff: differences found (copied)" : "Session Diff: identical (copied)"; + } + /// Compares exactly two selected sessions (E2E / probe). + public bool TryBuildSessionDiff(out SessionDiffResult diff) + { + diff = new SessionDiffResult(false, ""); + var selection = ResolveFilterSelection(); + if (selection.Count != 2) + { + return false; + } + + diff = SessionDiff.Compare(selection[0], selection[1]); + return true; + } + /// Builds curl for the single selected session (E2E / probe). + public bool TryBuildCopyAsCurl(out string curl) + { + curl = ""; + var session = ResolveSingleCopySession(); + if (session is null || !SessionRequestCodegen.CanGenerate(session)) + { + return false; + } + + curl = SessionRequestCodegen.ToCurl(session); + return true; + } + /// Builds fetch for the single selected session (E2E / probe). + public bool TryBuildCopyAsFetch(out string fetch) + { + fetch = ""; + var session = ResolveSingleCopySession(); + if (session is null || !SessionRequestCodegen.CanGenerate(session)) + { + return false; + } + + fetch = SessionRequestCodegen.ToFetch(session); + return true; + } + private static async Task CopyTextToClipboardAsync(string text) + { + var window = TryGetMainWindow(); + if (window?.Clipboard is { } clipboard) + { + await clipboard.SetTextAsync(text).ConfigureAwait(false); + } + } + private Task FilterByHostAsync() + { + var host = ResolveUnanimousFilterHost(); + if (string.IsNullOrEmpty(host)) + { + StatusText = "Filter by host needs one shared host in the selection"; + return Task.CompletedTask; + } + + SearchQuery = SessionSearch.SetKeyedToken(SearchQuery, "host", host); + StatusText = $"Filtered by host:{host}"; + return Task.CompletedTask; + } + private Task FilterByProcessAsync() + { + var process = ResolveUnanimousFilterProcess(); + if (string.IsNullOrEmpty(process)) + { + StatusText = "Filter by process needs one shared process in the selection"; + return Task.CompletedTask; + } + + SearchQuery = SessionSearch.SetKeyedToken(SearchQuery, "process", process); + StatusText = $"Filtered by process:{process}"; + return Task.CompletedTask; + } + /// True when selection shares one non-empty host (single or multi-select). + public bool CanFilterByHost => ResolveUnanimousFilterHost() is not null; + /// True when at least one session is selected. + public bool HasSelectedSessions => ResolveFilterSelection().Count > 0; + /// True when exactly one session is selected (Replay / Composer). + public bool HasSingleSelectedSession => ResolveFilterSelection().Count == 1; + /// True when at least one selected session has a URL to copy. + public bool CanCopyUrl => ResolveCopyUrls().Count > 0; + /// True when exactly two sessions are selected for Session Diff. + public bool CanDiffSessions => ResolveFilterSelection().Count == 2; + private string? ResolveUnanimousFilterHost() + { + var selection = ResolveFilterSelection(); + if (selection.Count == 0) + { + return null; + } + + string? host = null; + foreach (var session in selection) + { + var value = ResolveSessionHost(session); + if (string.IsNullOrEmpty(value)) + { + return null; + } + + if (host is null) + { + host = value; + } + else if (!host.Equals(value, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + } + + return host; + } + private string? ResolveUnanimousFilterProcess() + { + var selection = ResolveFilterSelection(); + if (selection.Count == 0) + { + return null; + } + + string? process = null; + foreach (var session in selection) + { + var value = ResolveSessionProcess(session); + if (string.IsNullOrEmpty(value)) + { + return null; + } + + if (process is null) + { + process = value; + } + else if (!process.Equals(value, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + } + + return process; + } + private IReadOnlyList ResolveFilterSelection() + { + if (_selectedSessions.Count > 0) + { + return _selectedSessions; + } + + return SelectedSession is null + ? Array.Empty() + : new List { SelectedSession }; + } + private static string? ResolveSessionHost(SessionSnapshot session) + { + if (!string.IsNullOrWhiteSpace(session.Host)) + { + return session.Host.Trim(); + } + + return Uri.TryCreate(session.Url, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host) + ? uri.Host + : null; + } + private static string? ResolveSessionProcess(SessionSnapshot session) + { + if (!string.IsNullOrWhiteSpace(session.ProcessName)) + { + return session.ProcessName.Trim(); + } + + return session.ProcessId > 0 ? session.ProcessId.ToString() : null; + } + private void NotifyFilterSelectionProperties() + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanFilterByHost))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanExcludeHost))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanFilterByProcess))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasSelectedSessions))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasSingleSelectedSession))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanCopyUrl))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanCopyAsCurl))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanDiffSessions))); + RaiseSessionCommandCanExecuteChanged(); + } + private void RaiseSessionCommandCanExecuteChanged() + { + _clearSessionsCommand.RaiseCanExecuteChanged(); + _removeSelectedSessionsCommand.RaiseCanExecuteChanged(); + _exportSelectedHarCommand.RaiseCanExecuteChanged(); + _exportSelectedArchiveCommand.RaiseCanExecuteChanged(); + _copyAsCurlCommand.RaiseCanExecuteChanged(); + _copyAsFetchCommand.RaiseCanExecuteChanged(); + _diffSessionsCommand.RaiseCanExecuteChanged(); + } + private List ResolveCopyUrls() => + ResolveFilterSelection() + .Where(snap => !string.IsNullOrEmpty(snap.Url)) + .Select(snap => snap.Url) + .ToList(); + private Task AddAutoResponderRuleAsync() + { + AutoResponder.Rules.Add(new AutoResponderRule + { + MatchUrl = AutoResponderMatch, + StatusCode = AutoResponderStatus, + Body = AutoResponderBody, + ContentType = AutoResponderContentType, + LocalFilePath = AutoResponderLocalFilePath, + GraphQlOperationName = AutoResponderGraphQlOperation, + Enabled = true, + }); + PersistAutoResponder(); + StatusText = $"AutoResponder rule added ({AutoResponder.Rules.Count} total)"; + return Task.CompletedTask; + } + private Task DeleteAutoResponderRuleAsync() + { + if (AutoResponder.SelectedRule is null) + { + StatusText = "Select an AutoResponder rule to delete"; + return Task.CompletedTask; + } + + AutoResponder.Rules.Remove(AutoResponder.SelectedRule); + AutoResponder.SelectedRule = null; + PersistAutoResponder(); + StatusText = "AutoResponder rule deleted"; + return Task.CompletedTask; + } + private Task UpdateAutoResponderRuleAsync() + { + if (AutoResponder.SelectedRule is null) + { + StatusText = "Select an AutoResponder rule to update"; + return Task.CompletedTask; + } + + var rule = AutoResponder.SelectedRule; + rule.MatchUrl = AutoResponderMatch; + rule.StatusCode = AutoResponderStatus; + rule.Body = AutoResponderBody; + rule.ContentType = AutoResponderContentType; + rule.LocalFilePath = AutoResponderLocalFilePath; + rule.GraphQlOperationName = AutoResponderGraphQlOperation; + PersistAutoResponder(); + StatusText = "AutoResponder rule updated"; + return Task.CompletedTask; + } + private async Task BrowseAutoResponderLocalFileAsync() + { + var path = await _pathPicker.PickOpenPathAsync( + "Map Local — choose response file", + "All files", + "*.*").ConfigureAwait(true); + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + AutoResponderLocalFilePath = path; + StatusText = $"Map Local file: {path}"; + } + private Task AddMapRemoteRuleAsync() + { + MapRemote.Rules.Add(new MapRemoteRule + { + MatchUrl = MapRemoteMatch, + TargetUrl = MapRemoteTarget, + GraphQlOperationName = MapRemoteGraphQlOperation, + Enabled = true, + }); + PersistMapRemote(); + StatusText = $"Map Remote rule added ({MapRemote.Rules.Count} total)"; + return Task.CompletedTask; + } + private Task DeleteMapRemoteRuleAsync() + { + if (MapRemote.SelectedRule is null) + { + StatusText = "Select a Map Remote rule to delete"; + return Task.CompletedTask; + } + + MapRemote.Rules.Remove(MapRemote.SelectedRule); + MapRemote.SelectedRule = null; + PersistMapRemote(); + StatusText = "Map Remote rule deleted"; + return Task.CompletedTask; + } + private Task UpdateMapRemoteRuleAsync() + { + if (MapRemote.SelectedRule is null) + { + StatusText = "Select a Map Remote rule to update"; + return Task.CompletedTask; + } + + var rule = MapRemote.SelectedRule; + rule.MatchUrl = MapRemoteMatch; + rule.TargetUrl = MapRemoteTarget; + rule.GraphQlOperationName = MapRemoteGraphQlOperation; + PersistMapRemote(); + StatusText = "Map Remote rule updated"; + return Task.CompletedTask; + } + private void OnSessionAddedToFilter(SessionSnapshot snapshot) + { + // Store already holds the row — append to the filtered grid in place. + if (SessionSearch.Matches(snapshot, SearchQuery)) + { + Sessions.Add(snapshot); + } + + RefreshSessionCountText(); + } + private void OnSessionsRemoved(IReadOnlyList removed) + { + if (removed.Count == 0) + { + return; + } + + var ids = removed.Select(s => s.Id).ToHashSet(); + // Clear selection before removing rows — otherwise Avalonia DataGrid selects a + // neighbor and SelectedSession reopens the details pane. + _selectedSessions.RemoveAll(s => ids.Contains(s.Id)); + if (SelectedSession is not null && ids.Contains(SelectedSession.Id)) + { + SelectedSession = null; + } + + for (var i = Sessions.Count - 1; i >= 0; i--) + { + if (ids.Contains(Sessions[i].Id)) + { + Sessions.RemoveAt(i); + } + } + + if (_userRemovalDepth > 0) + { + RefreshSessionCountText(); + return; + } + + _retentionEvictedTotal += removed.Count; + RefreshSessionCountText(); + if (removed.Count == 1) + { + StatusText = "Removed 1 oldest session to stay under limits"; + } + else + { + StatusText = $"Removed {removed.Count} oldest sessions to stay under limits"; + } + } + private async Task LoadSelectedBodiesAsync(SessionSnapshot snap) + { + try + { + await _store.EnsureBodiesLoadedAsync(snap, _statusRevertCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + await MarshalToUiAsync(() => + { + if (ReferenceEquals(_selected, snap)) + { + RefreshSelectedInspectors(); + } + }, StatusCancelToken).ConfigureAwait(false); + } + catch + { + await MarshalToUiAsync(() => + { + if (ReferenceEquals(_selected, snap)) + { + RefreshSelectedInspectors(); + } + }, StatusCancelToken).ConfigureAwait(false); + } + } + private void RefreshSessionCountText() + { + DateTimeOffset? oldest = null; + if (_retentionEvictedTotal > 0 && _all.Count > 0) + { + oldest = _all[0].StartedUtc; + for (var i = 1; i < _all.Count; i++) + { + var t = _all[i].StartedUtc; + if (t < oldest.Value) + { + oldest = t; + } + } + } + + SessionCountText = SessionSearch.BuildSessionCountText( + Sessions.Count, + _all.Count, + SearchQuery, + _store.SpilledCount, + _retentionEvictedTotal, + oldest); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasSessions))); + RaiseSessionCommandCanExecuteChanged(); + } + private void NotifyQuickFilterProperties() + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HideTunnelsFilter))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HideImagesFilter))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ErrorsOnlyFilter))); + } + private void ApplyFilter() + { + var previouslySelected = SelectedSession; + var detailsWereOpen = ShowSessionDetails; + Sessions.Clear(); + foreach (var s in SessionSearch.Filter(_all, SearchQuery)) + { + Sessions.Add(s); + } + + // Restore single selection used by the detail pane when the row still matches the filter. + // Do not force the pane open — the user may have closed it, and Sessions.Clear() can + // briefly null SelectedSession via the DataGrid binding. + if (previouslySelected is not null && Sessions.Contains(previouslySelected)) + { + if (!ReferenceEquals(_selected, previouslySelected)) + { + _suppressOpenSessionDetails = true; + try + { + SelectedSession = previouslySelected; + } + finally + { + _suppressOpenSessionDetails = false; + } + } + + ShowSessionDetails = detailsWereOpen; + } + else if (previouslySelected is not null) + { + SelectedSession = null; + } + } + private async Task ExportHarAsync() + { + if (_all.Count == 0) + { + SetGuardStatus("No sessions to export"); + return; + } + + var path = await _pathPicker.PickSavePathAsync("Export all HAR", "titanium-inspector.har", "HAR", "*.har"); + if (path is null) + { + SetTransientStatus("Export HAR cancelled", StatusSeverity.Neutral, revertMs: GuardStatusRevertMs); + return; + } + + try + { + var sessions = _all.ToList(); + // Stay on the UI sync context (RelayCommand). ConfigureAwait(false) + StatusText update + // raced with headless WaitUntil pumps on macOS (file written, StatusText stayed Ready). + SetStatus("Exporting HAR…", StatusSeverity.Busy); + await _store.EnsureBodiesLoadedAsync(sessions, _statusRevertCts?.Token ?? CancellationToken.None); + await SessionArchive.ExportHarAsync(sessions, path, _statusRevertCts?.Token ?? CancellationToken.None); + SetOutcomeStatus($"Exported {sessions.Count} sessions to {path}", StatusSeverity.Success, toastImportant: true); + } + catch (Exception ex) + { + SetOutcomeStatus("Export HAR failed: " + Truncate(ex.Message, 160), StatusSeverity.Error, toastImportant: true); + } + } + private async Task ExportSelectedHarAsync() + { + var sessions = ResolveExportSelection(); + if (sessions.Count == 0) + { + SetGuardStatus("Select a session to export"); + return; + } + + var path = await _pathPicker.PickSavePathAsync("Export selected HAR", "titanium-inspector.har", "HAR", "*.har"); + if (path is null) + { + SetTransientStatus("Export HAR cancelled", StatusSeverity.Neutral, revertMs: GuardStatusRevertMs); + return; + } + + try + { + SetStatus("Exporting HAR…", StatusSeverity.Busy); + await _store.EnsureBodiesLoadedAsync(sessions, _statusRevertCts?.Token ?? CancellationToken.None); + await SessionArchive.ExportHarAsync(sessions, path, _statusRevertCts?.Token ?? CancellationToken.None); + SetOutcomeStatus($"Exported {sessions.Count} sessions to {path}", StatusSeverity.Success, toastImportant: true); + } + catch (Exception ex) + { + SetOutcomeStatus("Export HAR failed: " + Truncate(ex.Message, 160), StatusSeverity.Error, toastImportant: true); + } + } + private async Task ImportHarAsync() + { + var path = await _pathPicker.PickOpenPathAsync("Import HAR", "HAR", "*.har", ZipFileFilter); + if (path is null) + { + SetGuardStatus("No .har or archive to import"); + return; + } + + SetStatus("Importing…", StatusSeverity.Busy); + List imported; + if (path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + imported = await SessionArchive.ImportNativeArchiveAsync(path, _statusRevertCts?.Token ?? CancellationToken.None); + } + else + { + imported = await SessionArchive.ImportHarAsync(path, _statusRevertCts?.Token ?? CancellationToken.None); + } + + foreach (var snap in imported) + { + _store.Add(snap); + } + + ApplyFilter(); + RefreshSessionCountText(); + SetOutcomeStatus($"Appended {imported.Count} sessions from {Path.GetFileName(path)}", StatusSeverity.Success, toastImportant: true); + } + private async Task ExportArchiveAsync() + { + if (_all.Count == 0) + { + SetGuardStatus("No sessions to export"); + return; + } + + var path = await _pathPicker.PickSavePathAsync("Export all archive", "titanium-inspector.zip", "ZIP", ZipFileFilter); + if (path is null) + { + SetTransientStatus("Export archive cancelled", StatusSeverity.Neutral, revertMs: GuardStatusRevertMs); + return; + } + + try + { + var sessions = _all.ToList(); + // Stay on the UI sync context (RelayCommand). ConfigureAwait(false) + StatusText update + // raced with headless WaitUntil pumps on macOS (file written, StatusText stayed Ready). + SetStatus("Exporting archive…", StatusSeverity.Busy); + await _store.EnsureBodiesLoadedAsync(sessions, _statusRevertCts?.Token ?? CancellationToken.None); + await SessionArchive.ExportNativeArchiveAsync(sessions, path, _statusRevertCts?.Token ?? CancellationToken.None); + SetOutcomeStatus($"Exported {sessions.Count} sessions to {path}", StatusSeverity.Success, toastImportant: true); + } + catch (Exception ex) + { + SetOutcomeStatus("Export archive failed: " + Truncate(ex.Message, 160), StatusSeverity.Error, toastImportant: true); + } + } + private async Task ExportSelectedArchiveAsync() + { + var sessions = ResolveExportSelection(); + if (sessions.Count == 0) + { + SetGuardStatus("Select a session to export"); + return; + } + + var path = await _pathPicker.PickSavePathAsync("Export selected archive", "titanium-inspector.zip", "ZIP", ZipFileFilter); + if (path is null) + { + SetTransientStatus("Export archive cancelled", StatusSeverity.Neutral, revertMs: GuardStatusRevertMs); + return; + } + + try + { + SetStatus("Exporting archive…", StatusSeverity.Busy); + await _store.EnsureBodiesLoadedAsync(sessions, _statusRevertCts?.Token ?? CancellationToken.None); + await SessionArchive.ExportNativeArchiveAsync(sessions, path, _statusRevertCts?.Token ?? CancellationToken.None); + SetOutcomeStatus($"Exported {sessions.Count} sessions to {path}", StatusSeverity.Success, toastImportant: true); + } + catch (Exception ex) + { + SetOutcomeStatus("Export archive failed: " + Truncate(ex.Message, 160), StatusSeverity.Error, toastImportant: true); + } + } + private async Task ImportArchiveAsync() + { + var path = await _pathPicker.PickOpenPathAsync("Import archive", "ZIP", ZipFileFilter); + if (path is null) + { + SetGuardStatus("No titanium-inspector archive to import"); + return; + } + + SetStatus("Importing archive…", StatusSeverity.Busy); + try + { + // Stay on the UI sync context (RelayCommand). ConfigureAwait(false) + off-thread + // StatusText throws Avalonia "Call from invalid thread" on Windows CI, and + // nested MarshalToUiAsync StatusText updates flaked on macOS headless. + var imported = await SessionArchive.ImportNativeArchiveAsync(path, _statusRevertCts?.Token ?? CancellationToken.None); + foreach (var snap in imported) + { + _store.Add(snap); + } + + ApplyFilter(); + RefreshSessionCountText(); + SetOutcomeStatus($"Appended {imported.Count} sessions from {Path.GetFileName(path)}", StatusSeverity.Success, toastImportant: true); + } + catch (Exception ex) + { + SetOutcomeStatus("Import archive failed: " + Truncate(ex.Message, 160), StatusSeverity.Error, toastImportant: true); + } + } + private IReadOnlyList ResolveExportSelection() + { + if (_selectedSessions.Count > 0) + { + return _selectedSessions.ToList(); + } + + return SelectedSession is null + ? Array.Empty() + : new List { SelectedSession }; + } +} diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs new file mode 100644 index 000000000..851932c0f --- /dev/null +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Trust.cs @@ -0,0 +1,660 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Net; +using System.Runtime.CompilerServices; +using System.Text; +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using Titanium.Inspector.Services; +using Titanium.Inspector.Views; +using Titanium.Web.Proxy; +using Titanium.Web.Proxy.Network; + +namespace Titanium.Inspector.ViewModels; + +public sealed partial class MainWindowViewModel +{ + private async Task InstallCaAsync() + { + if (!_interception.IsRunning) + { + SetGuardStatus(StartProxyFirstStatus); + return; + } + + SetBusyTrustingRootCa(); + var ok = await EnsureRootCaTrustedAsync(promptIfNeeded: true); + if (ok) + { + SetOsTrustSuccessStatus(); + return; + } + + if (_interception.LastOsTrustResult?.Kind == CertificateOsTrustKind.Cancelled || + string.IsNullOrEmpty(_interception.LastOsTrustResult?.Message)) + { + SetGuardStatus("Root CA install cancelled"); + return; + } + + if (await ResolveTerminalTrustFailureAsync(_interception.LastOsTrustResult)) + SetOsTrustSuccessStatus(); + else if (_interception.LastOsTrustResult?.Kind == CertificateOsTrustKind.Cancelled) + SetGuardStatus("Root CA install cancelled"); + else + SetOutcomeStatus( + OsTrustUxCopy.FormatStatus(_interception.LastOsTrustResult), + StatusSeverity.Error, + toastImportant: true); + } + private async Task TrustFirefoxCaAsync() + { + if (!_interception.IsRunning) + { + SetGuardStatus(StartProxyFirstStatus); + return; + } + + var owner = TryGetMainWindow(); + if (!await TryEnsureRootBeforeFirefoxAsync(owner)) + return; + + if (!FirefoxCertificateTrust.IsFirefoxProfilePresent()) + { + SetOutcomeStatus( + "Firefox profile not found — open Firefox once to create a profile " + + "(classic, Snap, or Flatpak), or use Export CA → Firefox Authorities", + StatusSeverity.Warning, + toastImportant: true); + return; + } + + SetStatus("Updating Firefox trust…", StatusSeverity.Busy); + var result = await TrustFirefoxWithRecoveryAsync(owner); + SetOutcomeStatus( + FormatFirefoxTrustOutcome(result), + result.Succeeded ? StatusSeverity.Success : StatusSeverity.Error, + toastImportant: true); + } + private async Task TryEnsureRootBeforeFirefoxAsync(Window? owner) + { + if (_interception.IsRootTrusted) + return true; + + if (!await AwaitCancellableAsync(_dialogs.ConfirmInstallRootCaBeforeFirefoxAsync(owner))) + { + SetGuardStatus("Trust CA in Firefox cancelled — install root CA first"); + return false; + } + + SetBusyTrustingRootCa(); + if (await EnsureRootCaTrustedAsync(promptIfNeeded: true)) + return true; + + SetOutcomeStatus( + FormatOsTrustFailureStatus(_interception.LastOsTrustResult), + StatusSeverity.Error, + toastImportant: true); + return false; + } + private static string FormatFirefoxTrustOutcome(CertificateOsTrustResult result) + { + if (result.Succeeded) + return result.Message; + if (result.Kind is CertificateOsTrustKind.CertutilMissing or CertificateOsTrustKind.HomebrewMissing) + return result.Message + " — try Export CA"; + return result.Message; + } + private async Task TrustFirefoxWithRecoveryAsync(Window? owner) + { + for (var attempt = 0; attempt < 3; attempt++) + { + var result = _interception.TrustFirefox(); + if (result.Succeeded) + return result; + + if (result.Kind is CertificateOsTrustKind.CertutilMissing or CertificateOsTrustKind.HomebrewMissing) + { + var recovered = await TryRecoverFirefoxCertutilAsync(owner, result); + if (recovered) + continue; + return result; + } + + if (IsFirefoxRunningTrustError(result)) + { + var quitOk = await TryQuitFirefoxForTrustAsync(owner); + if (quitOk is null) + continue; + return quitOk; + } + + return result; + } + + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, "Firefox trust failed after retries"); + } + + private async Task TryRecoverFirefoxCertutilAsync(Window? owner, CertificateOsTrustResult result) + { + var choice = await AwaitCancellableAsync(_dialogs.ShowTrustRecoveryAsync(owner, result)); + if (choice == TrustRecoveryChoice.Primary && + result.Kind == CertificateOsTrustKind.CertutilMissing && + (OperatingSystem.IsLinux() || result.BrewAvailable)) + { + SetStatus("Installing browser certificate tools…", StatusSeverity.Busy); + _ = _interception.InstallNssToolsAndRetryTrust(); + return true; + } + + if (choice == TrustRecoveryChoice.Secondary || + (choice == TrustRecoveryChoice.Primary && result.Kind == CertificateOsTrustKind.HomebrewMissing)) + { + await ExportCaAsync(); + } + + return false; + } + + private static bool IsFirefoxRunningTrustError(CertificateOsTrustResult result) => + result.Message.Contains("Quit Firefox", StringComparison.OrdinalIgnoreCase) || + result.Message.Contains("running", StringComparison.OrdinalIgnoreCase); + + private async Task TryQuitFirefoxForTrustAsync(Window? owner) + { + if (!await AwaitCancellableAsync(_dialogs.ConfirmQuitFirefoxForTrustAsync(owner))) + return CertificateOsTrustResult.Fail(CertificateOsTrustKind.Cancelled, "Firefox trust cancelled"); + + SetStatus("Quitting Firefox…", StatusSeverity.Busy); + if (!FirefoxCertificateTrust.TryRequestFirefoxQuit()) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, + "Firefox is still running — close it fully, then retry Trust CA in Firefox"); + } + + SetStatus("Updating Firefox trust…", StatusSeverity.Busy); + return null; + } + /// + /// Attempts user OS trust and adaptive recovery (certutil install / Keychain / elevate). + /// + private async Task EnsureRootCaTrustedAsync(bool promptIfNeeded) // NOSONAR S3776 -- Adaptive OS-trust recovery loop shares dialog/state; splitting would hide the retry contract. + { + var owner = TryGetMainWindow(); + var ok = _interception.InstallRootCertificate(machineStore: false); + var result = _interception.LastOsTrustResult; + + if (ok && result?.Kind != CertificateOsTrustKind.MacNeedsManualTrustConfirm) + return true; + + if (!promptIfNeeded) + return ok; + + // Adaptive recovery loop (certutil / Keychain / elevate). + for (var i = 0; i < 4; i++) + { + result = _interception.LastOsTrustResult; + if (_interception.IsRootTrusted && + result?.Kind != CertificateOsTrustKind.MacNeedsManualTrustConfirm) + return true; + + if (result?.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm) + return await TryCompleteMacManualTrustAsync(owner); + + if (!ok) + { + var recovered = await TryRecoverFailedOsTrustAsync(owner, result); + if (recovered == true) + return true; + if (recovered == false) + return false; + ok = _interception.IsRootTrusted || + _interception.LastOsTrustResult?.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm; + continue; + } + + break; + } + + return _interception.IsRootTrusted; + } + + private async Task TryCompleteMacManualTrustAsync(Window? owner) + { + var wait = await WaitForMacSslTrustAsync(owner); + if (wait == MacSslTrustWaitResult.Trusted || _interception.VerifyOsUserSslTrust()) + return true; + + _interception.SetLastOsTrustCancelled(); + if (wait == MacSslTrustWaitResult.NotSavedYet || _interception.IsRootInLoginKeychain()) + SetGuardStatus(OsTrustUxCopy.MacSslTrustNotSavedYet); + return false; + } + + private async Task TryRecoverFailedOsTrustAsync(Window? owner, CertificateOsTrustResult? result) + { + var choice = await AwaitCancellableAsync(_dialogs.ShowTrustRecoveryAsync(owner, result)); + if (choice == TrustRecoveryChoice.Cancel) + { + _interception.SetLastOsTrustCancelled(); + return false; + } + + if (result?.Kind == CertificateOsTrustKind.CertutilMissing && + (result.BrewAvailable || OperatingSystem.IsLinux())) + { + return await TryRecoverCertutilMissingAsync(choice); + } + + if (result?.Kind == CertificateOsTrustKind.HomebrewMissing) + { + if (choice == TrustRecoveryChoice.Primary) + await ExportCaAsync(); + return false; + } + + if (choice == TrustRecoveryChoice.Primary) + { + SetStatus("Trusting root CA (administrator)…", StatusSeverity.Busy); + var ok = _interception.InstallRootCertificateAsAdmin(machineStore: false); + if (ok && _interception.LastOsTrustResult?.Kind != + CertificateOsTrustKind.MacNeedsManualTrustConfirm) + return true; + return null; + } + + if (choice == TrustRecoveryChoice.Secondary) + await ExportCaAsync(); + return false; + } + + private async Task TryRecoverCertutilMissingAsync(TrustRecoveryChoice choice) + { + if (choice == TrustRecoveryChoice.Primary) + { + SetStatus("Installing browser certificate tools…", StatusSeverity.Busy); + var install = _interception.InstallNssToolsAndRetryTrust(); + if (install.Succeeded) + return true; + if (install.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm) + return null; + + SetOutcomeStatus(install.Message, StatusSeverity.Error); + return null; + } + + if (choice == TrustRecoveryChoice.Secondary) + await ExportCaAsync(); + return false; + } + + private Task WaitForMacSslTrustAsync(Window? owner) + { + // Waiting UX is entirely in the modal — clear main-window Busy so the status bar + // spinner / "Trusting root CA…" does not compete with the dialog status line. + if (IsStatusBusy) + { + SetSteadyStatus( + _interception.IsRunning + ? $"Proxy running on {FormatBindDisplay()}:{BindPort}" + : StatusReady); + } + + return AwaitCancellableAsync(_dialogs.ShowMacSslTrustWaitAsync( + owner, + () => _interception.VerifyOsUserSslTrust(), + () => _interception.OpenMacKeychainGuidance(), + () => _interception.IsRootInLoginKeychain())); + } + private void SetOsTrustSuccessStatus() + { + var msg = "Root CA trusted — ready to decrypt HTTPS"; + if (!_firefoxTrustHintShown && InterceptionService.IsFirefoxProfilePresent) + { + _firefoxTrustHintShown = true; + msg += " · Restart Firefox to use OS-root trust (or Capture → Trust CA in Firefox…)"; + } + + SetOutcomeStatus(msg, StatusSeverity.Success, toastImportant: true); + } + private static string FormatOsTrustFailureStatus(CertificateOsTrustResult? result) => + OsTrustUxCopy.FormatStatus(result); + private void SetBusyTrustingRootCa() => + SetStatus( + OperatingSystem.IsWindows() ? TrustingRootCaWindowsStatus : TrustingRootCaStatus, + StatusSeverity.Busy); + private static string FormatUntrustStillPresentStatus() + { + if (OperatingSystem.IsMacOS()) + return "Remove incomplete — Titanium CA still in Keychain (approve the admin password prompt to clear System.keychain)"; + if (OperatingSystem.IsLinux()) + return "Remove requested but CA still present in the certificate store"; + return "Remove requested but CA still present in store"; + } + private static string FormatUntrustRemovedStatus() + { + if (OperatingSystem.IsMacOS()) + return "Root CA removed from Keychain; Decrypt HTTPS is off until you install the CA again"; + if (OperatingSystem.IsLinux()) + return "Root CA removed from the user certificate store; Decrypt HTTPS is off until you install the CA again"; + return "Root CA removed from current user store; Decrypt HTTPS is off until you install the CA again"; + } + private static string FormatRotateCaTrustedStatus(bool changed) => + changed + ? "Root CA cleared and trusted — ready to enable Decrypt HTTPS" + : "Root CA trusted — ready to enable Decrypt HTTPS"; + private async Task UntrustCaAsync() + { + if (!_interception.IsRunning) + { + SetGuardStatus(StartProxyFirstStatus); + return; + } + + var owner = TryGetMainWindow(); + if (!await AwaitCancellableAsync(_dialogs.ConfirmRemoveRootCaAsync(owner))) + { + SetTransientStatus("Remove root CA cancelled", StatusSeverity.Neutral, revertMs: GuardStatusRevertMs); + return; + } + + _interception.UntrustRootCertificate(machineStore: false); + if (DecryptHttps) + { + SetDecryptHttpsCore(false); + } + + var stillPresent = _interception.IsRootTrusted; + string message = stillPresent + ? FormatUntrustStillPresentStatus() + : FormatUntrustRemovedStatus(); + + SetOutcomeStatus( + message, + stillPresent ? StatusSeverity.Warning : StatusSeverity.Success, + toastImportant: true); + } + private async Task RotateCaAsync() + { + if (!_interception.IsRunning) + { + SetGuardStatus(StartProxyFirstStatus); + return; + } + + var owner = TryGetMainWindow(); + if (!await AwaitCancellableAsync(_dialogs.ConfirmRotateRootCaAsync(owner))) + { + SetTransientStatus("Clear and reinstall root CA cancelled", StatusSeverity.Neutral, revertMs: GuardStatusRevertMs); + return; + } + + if (DecryptHttps) + SetDecryptHttpsCore(false); + + var oldThumb = _interception.RootCertificate?.Thumbprint; + var ok = _interception.RotateRootCertificate(machineStore: false); + if (!ok) + { + SetOutcomeStatus("Clear and reinstall root CA failed — see logs", StatusSeverity.Error, toastImportant: true); + return; + } + + var newThumb = _interception.RootCertificate?.Thumbprint; + var changed = !string.IsNullOrEmpty(newThumb) && + !string.Equals(oldThumb, newThumb, StringComparison.OrdinalIgnoreCase); + + if (await AwaitCancellableAsync(_dialogs.ConfirmInstallRootCaAsync(owner))) + { + SetBusyTrustingRootCa(); + var trusted = await EnsureRootCaTrustedAsync(promptIfNeeded: true); + var message = trusted + ? FormatRotateCaTrustedStatus(changed) + : FormatOsTrustFailureStatus(_interception.LastOsTrustResult); + if (trusted) + SetOsTrustSuccessStatus(); + else + SetOutcomeStatus(message, StatusSeverity.Error, toastImportant: true); + return; + } + + SetOutcomeStatus(FormatRotateCaDeferredTrustStatus(changed), StatusSeverity.Warning, toastImportant: true); + } + private static string FormatRotateCaDeferredTrustStatus(bool changed) => + changed ? "Root CA cleared — Install root CA (or enable Decrypt HTTPS) to trust the new certificate" : "Root CA recreate completed — Install root CA to trust"; + private async Task ExportCaAsync() + { + if (_interception.RootCertificate is null) + { + SetGuardStatus("No root certificate yet — Start the proxy first"); + return; + } + + var path = await _pathPicker.PickSavePathAsync( + "Export root CA", + "TitaniumInspector-RootCA.cer", + [ + new PathPickerFileType("Certificate", "*.cer"), + new PathPickerFileType("PEM", "*.pem"), + ]); + if (path is null) + { + SetTransientStatus("Export CA cancelled", StatusSeverity.Neutral, revertMs: GuardStatusRevertMs); + return; + } + + try + { + var exported = _interception.ExportRootCertificate(path); + if (exported is null) + { + SetGuardStatus("No root certificate yet — Start the proxy first"); + return; + } + + SetOutcomeStatus("Exported CA: " + exported, StatusSeverity.Success, toastImportant: true); + } + catch (Exception ex) + { + SetOutcomeStatus("Export CA failed: " + Truncate(ex.Message, 160), StatusSeverity.Error, toastImportant: true); + } + } + private async Task DeviceCaSetupAsync() + { + var message = + "To decrypt HTTPS from a phone or other device:\n\n" + + "1. Export the root CA (use Export CA below, or Capture → Export root CA…).\n" + + "2. Install the exported .cer (or .pem) on the device as a trusted CA.\n" + + $"3. Set the device HTTP proxy to this PC's LAN IP on port {BindPort} " + + $"(current bind is {BindAddress}:{BindPort}).\n\n" + + "Use Bind address 0.0.0.0 so other devices can reach the proxy."; + + var owner = TryGetMainWindow(); + if (await AwaitCancellableAsync(_dialogs.ShowDeviceCaSetupAsync(owner, message))) + { + await ExportCaAsync(); + } + } + private async Task EnableDecryptHttpsAsync() + { + _decryptHttpsBusy = true; + try + { + if (!await TryStartProxyForDecryptAsync()) + return; + if (!await TryTrustRootForDecryptAsync()) + return; + if (!await TryCompleteMacSslTrustForDecryptAsync()) + return; + + SetDecryptHttpsCore(true); + SetOutcomeStatus("Decrypting HTTPS", StatusSeverity.Success, toastImportant: true); + } + finally + { + _decryptHttpsBusy = false; + } + } + private void NotifyDecryptHttpsUnchanged() => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DecryptHttps))); + private async Task TryStartProxyForDecryptAsync() + { + if (_interception.IsRunning) + return true; + + var owner = TryGetMainWindow(); + if (!await AwaitCancellableAsync(_dialogs.ConfirmStartProxyForDecryptAsync(owner))) + { + SetGuardStatus("Decrypt HTTPS cancelled — start the proxy first"); + NotifyDecryptHttpsUnchanged(); + return false; + } + + await StartCaptureAsync(); + if (_interception.IsRunning) + return true; + + SetOutcomeStatus( + "Could not start the proxy — Decrypt HTTPS stays off", + StatusSeverity.Error, + toastImportant: true); + NotifyDecryptHttpsUnchanged(); + return false; + } + private async Task TryTrustRootForDecryptAsync() + { + _interception.RefreshTrustState(); + if (_interception.IsRootTrusted) + return true; + + var owner = TryGetMainWindow(); + if (!await AwaitCancellableAsync(_dialogs.ConfirmInstallRootCaAsync(owner))) + { + SetGuardStatus("Decrypt HTTPS cancelled — root CA not installed"); + NotifyDecryptHttpsUnchanged(); + return false; + } + + SetBusyTrustingRootCa(); + if (await EnsureRootCaTrustedAsync(promptIfNeeded: true)) + return true; + + if (_interception.LastOsTrustResult?.Kind == CertificateOsTrustKind.Cancelled) + { + SetGuardStatus("Decrypt HTTPS cancelled — root CA not trusted"); + NotifyDecryptHttpsUnchanged(); + return false; + } + + if (await ResolveTerminalTrustFailureAsync(_interception.LastOsTrustResult)) + return true; + + if (_interception.LastOsTrustResult?.Kind == CertificateOsTrustKind.Cancelled) + SetGuardStatus("Decrypt HTTPS cancelled — root CA not trusted"); + else + SetOutcomeStatus( + OsTrustUxCopy.FormatStatus(_interception.LastOsTrustResult), + StatusSeverity.Error, + toastImportant: true); + NotifyDecryptHttpsUnchanged(); + return false; + } + private async Task TryCompleteMacSslTrustForDecryptAsync() + { + if (_interception.VerifyOsUserSslTrust() || OperatingSystem.IsWindows()) + return true; + + var incomplete = CertificateOsTrustResult.Fail( + CertificateOsTrustKind.MacNeedsManualTrustConfirm, + "Root CA needs Always Trust in Keychain Access before Decrypt HTTPS"); + if (await ResolveTerminalTrustFailureAsync(incomplete) && + (_interception.VerifyOsUserSslTrust() || OperatingSystem.IsWindows())) + return true; + + SetOutcomeStatus( + OsTrustUxCopy.FormatStatus(incomplete), + StatusSeverity.Error, + toastImportant: true); + NotifyDecryptHttpsUnchanged(); + return false; + } + /// + /// Terminal trust-failure modal: retry / Keychain / Export. Returns true when trust is established. + /// + private async Task ResolveTerminalTrustFailureAsync(CertificateOsTrustResult? result) + { + if (result?.Kind == CertificateOsTrustKind.Cancelled) + return false; + + var owner = TryGetMainWindow(); + for (var i = 0; i < 4; i++) + { + var choice = await AwaitCancellableAsync(_dialogs.ShowDecryptTrustFailedAsync(owner, result)); + if (choice == TrustRecoveryChoice.Cancel) + { + _interception.SetLastOsTrustCancelled(); + return false; + } + + var kind = result?.Kind ?? CertificateOsTrustKind.Failed; + var handled = await TryHandleTerminalTrustChoiceAsync(owner, choice, kind); + if (handled.HasValue) + return handled.Value; + + // Primary = Try again + SetBusyTrustingRootCa(); + if (await EnsureRootCaTrustedAsync(promptIfNeeded: true)) + return true; + + result = _interception.LastOsTrustResult; + if (result?.Kind == CertificateOsTrustKind.Cancelled) + return false; + } + + return _interception.IsRootTrusted || _interception.VerifyOsUserSslTrust(); + } + + private async Task TryHandleTerminalTrustChoiceAsync( + Window? owner, TrustRecoveryChoice choice, CertificateOsTrustKind kind) + { + if (kind == CertificateOsTrustKind.HomebrewMissing) + { + if (choice is TrustRecoveryChoice.Primary or TrustRecoveryChoice.Secondary) + await ExportCaAsync(); + return false; + } + + if (kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm) + { + if (choice == TrustRecoveryChoice.Secondary) + { + await ExportCaAsync(); + return false; + } + + return await TryCompleteMacManualTrustAsync(owner); + } + + if (choice == TrustRecoveryChoice.Secondary) + { + await ExportCaAsync(); + return false; + } + + return null; + } + + private void SetDecryptHttpsCore(bool enabled) + { + _decryptHttps = enabled; + _interception.DecryptHttps = enabled; + PersistSettings(); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DecryptHttps))); + } +} diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Updates.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Updates.cs new file mode 100644 index 000000000..f94aa479a --- /dev/null +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.Updates.cs @@ -0,0 +1,175 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Net; +using System.Runtime.CompilerServices; +using System.Text; +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using Titanium.Inspector.Services; +using Titanium.Inspector.Views; +using Titanium.Web.Proxy; +using Titanium.Web.Proxy.Network; + +namespace Titanium.Inspector.ViewModels; + +public sealed partial class MainWindowViewModel +{ + private static async Task OpenAboutAsync() + { + var owner = TryGetMainWindow(); + if (owner is null) + { + return; + } + + await AboutWindow.ShowAsync(owner); + } + /// Startup or Help → Check for updates. When , offer install dialog. + public async Task CheckUpdatesAsync(bool promptIfAvailable = true) + { + var channel = _updates.ChannelDisplayName; + SetStatus($"Checking for updates ({channel})…", StatusSeverity.Busy); + var result = await _updates.CheckAsync(_statusRevertCts?.Token ?? CancellationToken.None); + if (!result.UpdateAvailable || string.IsNullOrEmpty(result.AssetUrl)) + { + var upToDate = result.Message.Contains("up to date", StringComparison.OrdinalIgnoreCase); + if (upToDate) + { + SetTransientStatus( + result.Message, + StatusSeverity.Neutral, + toastImportant: true, + revertMs: 4000, + toastSeverity: StatusSeverity.Success); + } + else + { + SetOutcomeStatus(result.Message, StatusSeverity.Warning, toastImportant: true); + } + + return; + } + + SetOutcomeStatus(result.Message, StatusSeverity.Success, toastImportant: true); + if (!promptIfAvailable) + { + return; + } + + var owner = TryGetMainWindow(); + var version = result.RemoteVersion ?? ""; + if (!await AwaitCancellableAsync(_dialogs.ConfirmInstallUpdateAsync(owner, version, result.ChannelDisplay, result.OfferKind))) + { + SetOutcomeStatus(result.Message, StatusSeverity.Success); + return; + } + + SetStatus("Downloading update…", StatusSeverity.Busy); + var (ok, message) = await _updates.DownloadAndStartApplyAsync(result, _statusRevertCts?.Token ?? CancellationToken.None); + SetOutcomeStatus(message, ok ? StatusSeverity.Success : StatusSeverity.Error, toastImportant: true); + if (!ok) + { + return; + } + + SetStatus($"Installing {version} ({result.ChannelDisplay})… restarting.", StatusSeverity.Busy); + BeginBackgroundShutdown(); + if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.Shutdown(); + } + else + { + TryGetMainWindow()?.Close(); + } + } + private async Task ReplaySelectedAsync() + { + if (SelectedSession is null) + { + SetGuardStatus("Select a session to replay"); + return; + } + + SetStatus("Replaying…", StatusSeverity.Busy); + await _store.EnsureBodiesLoadedAsync(SelectedSession, _statusRevertCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + var result = await ReplayService.ReplayAsync( + SelectedSession, + ignoreServerCertificateErrors: _interception.IgnoreServerCertificateErrors, + cancellationToken: _statusRevertCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + await MarshalToUiAsync(() => + { + SetOutcomeStatus( + result.Ok + ? $"Replay → HTTP {result.StatusCode}: {Truncate(result.Message, 120)}" + : "Replay failed: " + result.Message, + result.Ok ? StatusSeverity.Success : StatusSeverity.Error, + toastImportant: !result.Ok); + }, StatusCancelToken).ConfigureAwait(false); + } + private async Task SendComposerAsync() + { + if (string.IsNullOrWhiteSpace(ComposerUrl)) + { + SetGuardStatus("Composer URL is required"); + return; + } + + SetStatus("Composer sending…", StatusSeverity.Busy); + var template = new SessionSnapshot + { + Method = string.IsNullOrWhiteSpace(ComposerMethod) ? "GET" : ComposerMethod, + Url = ComposerUrl, + RequestHeadersText = ComposerHeaders, + RequestBodyText = ComposerBody, + ContentType = GuessContentType(ComposerHeaders), + }; + + var result = await ReplayService.ReplayAsync( + template, + editedUrl: ComposerUrl, + editedMethod: ComposerMethod, + editedBody: ComposerBody, + editedHeaders: ComposerHeaders, + ignoreServerCertificateErrors: _interception.IgnoreServerCertificateErrors, + cancellationToken: _statusRevertCts?.Token ?? CancellationToken.None); + + if (!result.Ok) + { + SetOutcomeStatus("Composer failed: " + result.Message, StatusSeverity.Error, toastImportant: true); + return; + } + + var snap = new SessionSnapshot + { + Id = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + Method = template.Method, + Url = ComposerUrl, + Host = TryHost(ComposerUrl), + StartedUtc = DateTimeOffset.UtcNow, + RequestHeadersText = ComposerHeaders, + RequestBodyText = ComposerBody, + StatusCode = result.StatusCode, + ResponseHeadersText = result.ResponseHeaders, + ResponseBodyText = result.ResponseBody, + ContentType = template.ContentType, + BodySize = result.ResponseBody?.Length, + Protocol = "Composer", + }; + + _store.Add(snap); + ApplyFilter(); + RefreshSessionCountText(); + SelectedSession = snap; + SetOutcomeStatus($"Composer → HTTP {result.StatusCode} (session #{snap.Id})", StatusSeverity.Success); + } + private static string? GuessContentType(string headers) + { + var map = SessionInspectors.ParseHeaderBlock(headers); + return map.TryGetValue("Content-Type", out var ct) ? ct : null; + } +} diff --git a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs index fc7b9bf53..c4904464a 100644 --- a/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs +++ b/src/Titanium.Inspector/ViewModels/MainWindowViewModel.cs @@ -11,10 +11,12 @@ using Avalonia.Threading; using Titanium.Inspector.Services; using Titanium.Inspector.Views; +using Titanium.Web.Proxy; +using Titanium.Web.Proxy.Network; namespace Titanium.Inspector.ViewModels; -public sealed class MainWindowViewModel : INotifyPropertyChanged +public sealed partial class MainWindowViewModel : INotifyPropertyChanged { private const string ZipFileFilter = "*.zip"; @@ -26,11 +28,42 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged private readonly InterceptionService _interception; private readonly IInspectorDialogs _dialogs; private readonly IInspectorPathPicker _pathPicker; + private IStatusNotifier _statusNotifier; private readonly ObservableCollection _all; private readonly List _selectedSessions = new(); - private string _statusText = "Ready"; + private readonly RelayCommand _clearSessionsCommand; + private readonly RelayCommand _removeSelectedSessionsCommand; + private readonly RelayCommand _exportSelectedHarCommand; + private readonly RelayCommand _exportSelectedArchiveCommand; + private readonly RelayCommand _copyAsCurlCommand; + private readonly RelayCommand _copyAsFetchCommand; + private readonly RelayCommand _diffSessionsCommand; + private string _sessionDiffText = ""; + private const string StatusReady = "Ready"; + private const string StartProxyFirstStatus = "Start the proxy first"; + private const string SystemProxyRestoredStatus = "System proxy restored"; + private const string TrustingRootCaWindowsStatus = + "Trusting root CA… if Windows asks Trusted Root Yes/No, choose Yes"; + private const string TrustingRootCaStatus = "Trusting root CA…"; + private string _statusText = StatusReady; + private StatusSeverity _statusSeverity = StatusSeverity.Neutral; + private bool _isStatusBusy; + private int _statusAttentionTick; + private int _themeRefreshTick; + private bool _settingStatus; + private CancellationTokenSource? _statusRevertCts; + private CancellationToken StatusCancelToken => _statusRevertCts?.Token ?? CancellationToken.None; + private const int GuardStatusRevertMs = 3000; + private const int OutcomeSuccessRevertMs = 5000; + private const int OutcomeErrorRevertMs = 8000; private string _sessionCountText = "Sessions: 0"; + private string _exclusionSummaryText = ""; private string _searchQuery = ""; + private bool _firefoxTrustHintShown; + /// Sessions hard-evicted by retention this process (not user clear/remove). + private int _retentionEvictedTotal; + /// When > 0, skips retention accounting/status. + private int _userRemovalDepth; private SessionSnapshot? _selected; private string _selectedHeaders = ""; private string _selectedBody = ""; @@ -49,7 +82,12 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged private string _autoResponderMatch = "*"; private string _autoResponderBody = "OK"; private string _autoResponderContentType = "text/plain"; + private string _autoResponderLocalFilePath = string.Empty; private int _autoResponderStatus = 200; + private string _mapRemoteMatch = "*"; + private string _mapRemoteTarget = "http://127.0.0.1/"; + private string _mapRemoteGraphQlOperation = string.Empty; + private string _autoResponderGraphQlOperation = string.Empty; private string _plusPanelsSummary = ""; private string _bindAddress = "127.0.0.1"; private int _bindPort = 8866; @@ -66,7 +104,17 @@ public sealed class MainWindowViewModel : INotifyPropertyChanged private int _selectedInspectTabIndex; private int _selectedToolsTabIndex; private bool _showSessionDetails; + /// + /// When true, assigning must not force the details pane open + /// (filter restore / bulk removal — DataGrid may briefly re-select a neighbor row). + /// + private bool _suppressOpenSessionDetails; private bool _showWsFramesTab; + private bool _showSseTab; + private bool _showProtobufTab; + private string _selectedSseEvents = ""; + private string _selectedProtobufDecoded = ""; + private string _networkThrottleProfile = "None"; private string _composerMethod = "GET"; private string _composerUrl = ""; private string _composerHeaders = ""; @@ -80,111 +128,154 @@ public MainWindowViewModel( InterceptionService? interception = null, IInspectorDialogs? dialogs = null, IInspectorPathPicker? pathPicker = null) + : this(new InspectorViewModelServices(buffer, registry, updates, settings, interception, dialogs, pathPicker)) + { + } + + public MainWindowViewModel(InspectorViewModelServices services) { - _buffer = buffer; - _registry = registry; - _store = registry.Store; + _buffer = services.Buffer; + _registry = services.Registry; + _store = services.Registry.Store; _all = _store.Sessions; - _updates = updates; - _settings = settings; - _interception = interception ?? new InterceptionService(); - _dialogs = dialogs ?? new AvaloniaInspectorDialogs(); - _pathPicker = pathPicker ?? new AvaloniaInspectorPathPicker(); + _updates = services.Updates; + _settings = services.Settings; + _interception = services.Interception ?? new InterceptionService(); + _dialogs = services.Dialogs ?? new AvaloniaInspectorDialogs(); + _pathPicker = services.PathPicker ?? new AvaloniaInspectorPathPicker(); + _statusNotifier = services.StatusNotifier ?? NullStatusNotifier.Instance; Sessions = new ObservableCollection(); Breakpoints = new BreakpointViewModel(); AutoResponder = new AutoResponderViewModel(); + MapRemote = new MapRemoteViewModel(); _interception.AutoResponder = AutoResponder; + _interception.MapRemote = MapRemote; _interception.Breakpoints = Breakpoints; LoadFromSettings(); - CheckForUpdatesCommand = new RelayCommand(async () => await CheckUpdatesAsync(promptIfAvailable: true)); - SetUpdateChannelStableCommand = new RelayCommand(() => + CheckForUpdatesCommand = Cmd(async () => await CheckUpdatesAsync(promptIfAvailable: true)); + SetUpdateChannelStableCommand = Cmd(() => { UpdateChannelIsBeta = false; return Task.CompletedTask; }); - SetUpdateChannelBetaCommand = new RelayCommand(() => + SetUpdateChannelBetaCommand = Cmd(() => { UpdateChannelIsBeta = true; return Task.CompletedTask; }); - ToggleCheckForUpdatesOnStartupCommand = new RelayCommand(() => + SetThemeLightCommand = Cmd(() => + { + SetThemeMode(ThemeMode.Light); + return Task.CompletedTask; + }); + SetThemeDarkCommand = Cmd(() => + { + SetThemeMode(ThemeMode.Dark); + return Task.CompletedTask; + }); + SetThemeAutomaticCommand = Cmd(() => + { + SetThemeMode(ThemeMode.Automatic); + return Task.CompletedTask; + }); + ToggleCheckForUpdatesOnStartupCommand = Cmd(() => { CheckForUpdatesOnStartup = !CheckForUpdatesOnStartup; return Task.CompletedTask; }); - ExportHarCommand = new RelayCommand(async () => await ExportHarAsync()); - ExportSelectedHarCommand = new RelayCommand(async () => await ExportSelectedHarAsync()); - ImportHarCommand = new RelayCommand(async () => await ImportHarAsync()); - ExportArchiveCommand = new RelayCommand(async () => await ExportArchiveAsync()); - ExportSelectedArchiveCommand = new RelayCommand(async () => await ExportSelectedArchiveAsync()); - ImportArchiveCommand = new RelayCommand(async () => await ImportArchiveAsync()); - StartCaptureCommand = new RelayCommand(async () => await StartCaptureAsync()); - StopCaptureCommand = new RelayCommand(StopCaptureAsync); - ToggleInterceptCommand = new RelayCommand(ToggleInterceptAsync); - ToggleCapturingCommand = new RelayCommand(ToggleCapturingAsync); - ToggleAutoStartCaptureCommand = new RelayCommand(() => + ExportHarCommand = Cmd(async () => await ExportHarAsync()); + _exportSelectedHarCommand = Cmd(async () => await ExportSelectedHarAsync(), () => HasSelectedSessions); + ExportSelectedHarCommand = _exportSelectedHarCommand; + ImportHarCommand = Cmd(async () => await ImportHarAsync()); + ExportArchiveCommand = Cmd(async () => await ExportArchiveAsync()); + _exportSelectedArchiveCommand = Cmd(async () => await ExportSelectedArchiveAsync(), () => HasSelectedSessions); + ExportSelectedArchiveCommand = _exportSelectedArchiveCommand; + ImportArchiveCommand = Cmd(async () => await ImportArchiveAsync()); + ExitCommand = Cmd(ExitAsync); + StartCaptureCommand = Cmd(async () => await StartCaptureAsync()); + StopCaptureCommand = Cmd(StopCaptureAsync); + ToggleInterceptCommand = Cmd(ToggleInterceptAsync); + ToggleCapturingCommand = Cmd(ToggleCapturingAsync); + ToggleAutoStartCaptureCommand = Cmd(() => { AutoStartCapture = !AutoStartCapture; return Task.CompletedTask; }); - ToggleAutoSystemProxyOnStartCommand = new RelayCommand(() => + ToggleAutoSystemProxyOnStartCommand = Cmd(() => { AutoSystemProxyOnStart = !AutoSystemProxyOnStart; return Task.CompletedTask; }); - ToggleDecryptHttpsCommand = new RelayCommand(() => + ToggleDecryptHttpsCommand = Cmd(() => { DecryptHttps = !DecryptHttps; return Task.CompletedTask; }); - ToggleIgnoreServerCertificateErrorsCommand = new RelayCommand(() => + ToggleIgnoreServerCertificateErrorsCommand = Cmd(() => { IgnoreServerCertificateErrors = !IgnoreServerCertificateErrors; return Task.CompletedTask; }); - ClearSessionsCommand = new RelayCommand(ClearSessionsAsync); - RemoveSelectedSessionsCommand = new RelayCommand(RemoveSelectedSessionsAsync); - ToggleSystemProxyCommand = new RelayCommand(ToggleSystemProxyAsync); - InstallCaCommand = new RelayCommand(InstallCaAsync); - UntrustCaCommand = new RelayCommand(UntrustCaAsync); - RotateCaCommand = new RelayCommand(RotateCaAsync); - ExportCaCommand = new RelayCommand(ExportCaAsync); - DeviceCaSetupCommand = new RelayCommand(DeviceCaSetupAsync); - OpenLoopbackExemptCommand = new RelayCommand(OpenLoopbackExemptAsync); - OpenSessionRetentionCommand = new RelayCommand(OpenSessionRetentionAsync); - OpenLoggingSettingsCommand = new RelayCommand(OpenLoggingSettingsAsync); - OpenHttpsDecryptHostsCommand = new RelayCommand(OpenHttpsDecryptHostsAsync); - ResetSettingsCommand = new RelayCommand(ResetSettingsAsync); - ReplayCommand = new RelayCommand(async () => await ReplaySelectedAsync()); - LoadFromSelectedCommand = new RelayCommand(LoadFromSelectedAsync); - LoadIntoComposerCommand = new RelayCommand(LoadIntoComposerAsync); - CopyUrlCommand = new RelayCommand(CopyUrlAsync); - FilterByHostCommand = new RelayCommand(FilterByHostAsync); - FilterByProcessCommand = new RelayCommand(FilterByProcessAsync); - SendComposerCommand = new RelayCommand(async () => await SendComposerAsync()); - AddAutoResponderRuleCommand = new RelayCommand(AddAutoResponderRuleAsync); - DeleteAutoResponderRuleCommand = new RelayCommand(DeleteAutoResponderRuleAsync); - UpdateAutoResponderRuleCommand = new RelayCommand(UpdateAutoResponderRuleAsync); - ContinueBreakpointCommand = new RelayCommand(() => + _clearSessionsCommand = Cmd(ClearSessionsAsync, () => HasSessions); + ClearSessionsCommand = _clearSessionsCommand; + _removeSelectedSessionsCommand = Cmd(RemoveSelectedSessionsAsync, () => HasSelectedSessions); + RemoveSelectedSessionsCommand = _removeSelectedSessionsCommand; + ToggleSystemProxyCommand = Cmd(ToggleSystemProxyAsync); + InstallCaCommand = Cmd(InstallCaAsync); + TrustFirefoxCaCommand = Cmd(TrustFirefoxCaAsync); + UntrustCaCommand = Cmd(UntrustCaAsync); + RotateCaCommand = Cmd(RotateCaAsync); + ExportCaCommand = Cmd(ExportCaAsync); + DeviceCaSetupCommand = Cmd(DeviceCaSetupAsync); + OpenLoopbackExemptCommand = Cmd(OpenLoopbackExemptAsync); + OpenSessionRetentionCommand = Cmd(OpenSessionRetentionAsync); + OpenLoggingSettingsCommand = Cmd(OpenLoggingSettingsAsync); + OpenAboutCommand = Cmd(OpenAboutAsync); + OpenHttpsDecryptHostsCommand = Cmd(OpenExcludedHostsAsync); + ExcludeHostCommand = Cmd(ExcludeHostAsync); + ResetSettingsCommand = Cmd(ResetSettingsAsync); + ReplayCommand = Cmd(async () => await ReplaySelectedAsync()); + LoadFromSelectedCommand = Cmd(LoadFromSelectedAsync); + LoadIntoComposerCommand = Cmd(LoadIntoComposerAsync); + CopyUrlCommand = Cmd(CopyUrlAsync); + _copyAsCurlCommand = Cmd(CopyAsCurlAsync, () => CanCopyAsCurl); + CopyAsCurlCommand = _copyAsCurlCommand; + _copyAsFetchCommand = Cmd(CopyAsFetchAsync, () => CanCopyAsCurl); + CopyAsFetchCommand = _copyAsFetchCommand; + _diffSessionsCommand = Cmd(DiffSessionsAsync, () => CanDiffSessions); + DiffSessionsCommand = _diffSessionsCommand; + FilterByHostCommand = Cmd(FilterByHostAsync); + FilterByProcessCommand = Cmd(FilterByProcessAsync); + OpenExclusionSummaryCommand = Cmd(OpenExcludedHostsAsync); + SendComposerCommand = Cmd(async () => await SendComposerAsync()); + AddAutoResponderRuleCommand = Cmd(AddAutoResponderRuleAsync); + DeleteAutoResponderRuleCommand = Cmd(DeleteAutoResponderRuleAsync); + UpdateAutoResponderRuleCommand = Cmd(UpdateAutoResponderRuleAsync); + BrowseAutoResponderLocalFileCommand = Cmd(BrowseAutoResponderLocalFileAsync); + AddMapRemoteRuleCommand = Cmd(AddMapRemoteRuleAsync); + DeleteMapRemoteRuleCommand = Cmd(DeleteMapRemoteRuleAsync); + UpdateMapRemoteRuleCommand = Cmd(UpdateMapRemoteRuleAsync); + ContinueBreakpointCommand = Cmd(() => { Breakpoints.Continue(); return Task.CompletedTask; }); - AbortBreakpointCommand = new RelayCommand(() => + AbortBreakpointCommand = Cmd(() => { Breakpoints.Abort(); return Task.CompletedTask; }); - ApplyEditBodyCommand = new RelayCommand(ApplyEditBodyAsync); - ToggleDebugLoggingCommand = new RelayCommand(ToggleDebugLoggingAsync); - CloseSessionDetailsCommand = new RelayCommand(CloseSessionDetailsAsync); - OpenToolsComposerCommand = new RelayCommand(() => OpenToolsTabAsync(0)); - OpenToolsBreakpointsCommand = new RelayCommand(() => OpenToolsTabAsync(1)); - OpenToolsAutoResponderCommand = new RelayCommand(() => OpenToolsTabAsync(2)); - OpenToolsScriptsCommand = new RelayCommand(() => OpenToolsTabAsync(3)); - ClearFiltersCommand = new RelayCommand(() => + ApplyEditBodyCommand = Cmd(ApplyEditBodyAsync); + ToggleDebugLoggingCommand = Cmd(ToggleDebugLoggingAsync); + CloseSessionDetailsCommand = Cmd(CloseSessionDetailsAsync); + OpenToolsComposerCommand = Cmd(() => OpenToolsTabAsync(0)); + OpenToolsBreakpointsCommand = Cmd(() => OpenToolsTabAsync(1)); + OpenToolsAutoResponderCommand = Cmd(() => OpenToolsTabAsync(2)); + OpenToolsScriptsCommand = Cmd(() => OpenToolsTabAsync(3)); + OpenToolsMapRemoteCommand = Cmd(() => OpenToolsTabAsync(4)); + ClearFiltersCommand = Cmd(() => { SearchQuery = SessionSearch.ClearFilters(SearchQuery); return Task.CompletedTask; @@ -195,8 +286,9 @@ public MainWindowViewModel( _interception.ConfigureLogging(_settings.Current); _interception.IgnoreServerCertificateErrors = _settings.Current.IgnoreServerCertificateErrors; _interception.DecryptHttps = _decryptHttps; - ApplyDecryptHostListsFromSettings(); + ApplyExclusionSettingsFromSettings(); ShowLoopbackExemptMenu = AppContainerLoopback.IsSupported; + ShowProcessColumn = ClientProcessId.IsSupported; } /// Exposed for E2E / headless tests. @@ -208,6 +300,10 @@ public MainWindowViewModel( /// Exposed for E2E / headless tests. public IInspectorPathPicker PathPicker => _pathPicker; + /// Attach window toast host after the main window template is ready. + public void AttachStatusNotifier(IStatusNotifier notifier) => + _statusNotifier = notifier ?? NullStatusNotifier.Instance; + /// Exposed for E2E / headless tests — seeds the in-memory capture list. public void SeedSession(SessionSnapshot snapshot) { @@ -223,6 +319,153 @@ public void SetSelectedSessions(IReadOnlyList selected) NotifyFilterSelectionProperties(); } + /// True when the store has at least one session (Clear sessions). + public bool HasSessions => _all.Count > 0; + + /// Semantic color / busy state for the status bar. + public StatusSeverity StatusSeverity + { + get => _statusSeverity; + private set => SetField(ref _statusSeverity, value); + } + + /// True while an async menu/action is waiting for a result. + public bool IsStatusBusy + { + get => _isStatusBusy; + private set => SetField(ref _isStatusBusy, value); + } + + /// Increments when a non-busy result should briefly pulse the status text. + public int StatusAttentionTick + { + get => _statusAttentionTick; + private set => SetField(ref _statusAttentionTick, value); + } + + /// Increments when the active theme variant changes so status-code brushes rebind. + public int ThemeRefreshTick + { + get => _themeRefreshTick; + private set => SetField(ref _themeRefreshTick, value); + } + + /// + /// Update status bar text, severity, busy indicator, and optionally toast important outcomes. + /// + public void SetStatus(string text, StatusSeverity severity = StatusSeverity.Neutral, bool toastImportant = false) + { + CancelStatusRevert(); + _settingStatus = true; + try + { + SetField(ref _statusText, text, nameof(StatusText)); + StatusSeverity = severity; + IsStatusBusy = severity == StatusSeverity.Busy; + if (severity is StatusSeverity.Success or StatusSeverity.Warning or StatusSeverity.Error) + { + StatusAttentionTick++; + } + + if (toastImportant) + { + _statusNotifier.Show(text, severity); + } + } + finally + { + _settingStatus = false; + } + } + + private void SetSteadyStatus(string text) => SetStatus(text, StatusSeverity.Neutral); + + internal void SetTransientStatus( + string text, + StatusSeverity severity, + bool toastImportant = false, + int revertMs = OutcomeSuccessRevertMs, + StatusSeverity? toastSeverity = null) + { + SetStatus(text, severity); + if (toastImportant) + { + _statusNotifier.Show(text, toastSeverity ?? severity); + } + + ScheduleStatusRevert(revertMs); + } + + private void SetGuardStatus(string text) => + SetTransientStatus(text, StatusSeverity.Warning, revertMs: GuardStatusRevertMs); + + private void SetOutcomeStatus( + string text, + StatusSeverity severity, + bool toastImportant = false, + StatusSeverity? toastSeverity = null) + { + var revertMs = severity == StatusSeverity.Error ? OutcomeErrorRevertMs : OutcomeSuccessRevertMs; + SetTransientStatus(text, severity, toastImportant, revertMs, toastSeverity); + } + + private void RestoreBaselineStatus() + { + if (_interception.IsRunning) + { + SetSteadyStatus(StatusReady); + } + else + { + SetSteadyStatus("Proxy stopped"); + } + } + + private void CancelStatusRevert() + { + if (_statusRevertCts is null) + { + return; + } + + _statusRevertCts.Cancel(); + _statusRevertCts.Dispose(); + _statusRevertCts = null; + } + + private void ScheduleStatusRevert(int revertMs) + { + CancelStatusRevert(); + if (revertMs <= 0) + { + RestoreBaselineStatus(); + return; + } + + _statusRevertCts = new CancellationTokenSource(); + var token = _statusRevertCts.Token; + _ = RevertStatusAfterDelayAsync(revertMs, token); + } + + private async Task RevertStatusAfterDelayAsync(int revertMs, CancellationToken token) + { + try + { + await Task.Delay(revertMs, token).ConfigureAwait(false); + await MarshalToUiAsync(() => + { + if (!token.IsCancellationRequested) + { + RestoreBaselineStatus(); + } + }, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // superseded by a newer status message + } + } + /// /// After the main window is shown: optionally start capture and system proxy. /// Idempotent if already running. @@ -249,17 +492,13 @@ public async Task TryAutoStartAsync() } SystemProxy = true; - if (SystemProxy) - { - StatusText = - $"Proxy running on {FormatBindDisplay()}:{BindPort}; system proxy on. HTTPS shown as encrypted tunnels until Decrypt HTTPS is enabled." + - " Chrome/Edge: --disable-quic or HTTP/3 may bypass the proxy."; - } - else + if (!SystemProxy) { - StatusText = - $"Proxy running on {FormatBindDisplay()}:{BindPort}, but system proxy failed to enable — use the System proxy checkbox."; + SetStatus( + $"Proxy running on {FormatBindDisplay()}:{BindPort}, but system proxy failed to enable — use the System proxy checkbox.", + StatusSeverity.Warning); } + // On success the SystemProxy setter already shows restart-browser guidance — do not overwrite with Ready. } /// @@ -302,6 +541,7 @@ public void EnsureShutdown() } _interception.EnsureShutdown(); + CancelStatusRevert(); SetSystemProxyCore(false); RefreshEndpointAndBindUi(); _registry.Dispose(); @@ -325,11 +565,13 @@ public void BeginBackgroundShutdown() // UI flag only — do not call SetSystemProxy on the UI thread (WinINET deadlock risk). SetSystemProxyCore(false); _interception.BeginBackgroundShutdown(); + CancelStatusRevert(); } private void WireEventHandlers() { WireAutoResponderHandlers(); + WireMapRemoteHandlers(); WireBreakpointHandlers(); WireSessionPipelineHandlers(); } @@ -345,17 +587,37 @@ private void WireAutoResponderHandlers() AutoResponderStatus = selected.StatusCode; AutoResponderBody = selected.Body; AutoResponderContentType = selected.ContentType; + AutoResponderLocalFilePath = selected.LocalFilePath; + AutoResponderGraphQlOperation = selected.GraphQlOperationName; } }; AutoResponder.EnabledChanged += (_, _) => PersistAutoResponder(); AutoResponder.Rules.CollectionChanged += (_, _) => { /* persistence via explicit commands */ }; } + private void WireMapRemoteHandlers() + { + MapRemote.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(MapRemoteViewModel.SelectedRule) && + MapRemote.SelectedRule is { } selected) + { + MapRemoteMatch = selected.MatchUrl; + MapRemoteTarget = selected.TargetUrl; + MapRemoteGraphQlOperation = selected.GraphQlOperationName; + } + }; + MapRemote.EnabledChanged += (_, _) => PersistMapRemote(); + MapRemote.Rules.CollectionChanged += (_, _) => { /* persistence via explicit commands */ }; + } + private void WireBreakpointHandlers() { Breakpoints.PropertyChanged += (_, e) => { - if (e.PropertyName is nameof(BreakpointViewModel.Enabled) or nameof(BreakpointViewModel.UrlFilter)) + if (e.PropertyName is nameof(BreakpointViewModel.Enabled) + or nameof(BreakpointViewModel.UrlFilter) + or nameof(BreakpointViewModel.GraphQlOperationName)) { PersistSettings(); } @@ -399,7 +661,7 @@ private static void MarshalToUi(Action action) } } - private static async Task MarshalToUiAsync(Action action) + private static async Task MarshalToUiAsync(Action action, CancellationToken cancellationToken = default) { if (Application.Current is null || Dispatcher.UIThread.CheckAccess()) { @@ -429,7 +691,7 @@ private static async Task MarshalToUiAsync(Action action) try { - await tcs.Task.ConfigureAwait(false); + await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); return; } catch (InvalidOperationException ex) when ( @@ -437,14 +699,11 @@ private static async Task MarshalToUiAsync(Action action) && ex.Message.Contains("IFontManagerImpl", StringComparison.Ordinal)) { last = ex; - await Task.Delay(25 * attempt).ConfigureAwait(false); + await Task.Delay(25 * attempt, cancellationToken).ConfigureAwait(false); } } - if (last is not null) - { - throw last; - } + throw last!; } private void LoadPlusPanels() @@ -489,19 +748,19 @@ private async Task StopCaptureCoreAsync(string statusAfterStop) _stopBusy = true; _reenableSystemProxyOnStart = SystemProxy; - StatusText = "Stopping…"; + SetStatus("Stopping…", StatusSeverity.Busy); try { - await Task.Run(() => _interception.Stop()).ConfigureAwait(false); + await Task.Run(() => _interception.Stop(), _statusRevertCts?.Token ?? CancellationToken.None).ConfigureAwait(false); await MarshalToUiAsync(() => { SetSystemProxyCore(false); PersistSettings(); RefreshEndpointAndBindUi(); - StatusText = statusAfterStop; - }).ConfigureAwait(false); + SetSteadyStatus(statusAfterStop); + }, StatusCancelToken).ConfigureAwait(false); } finally { @@ -515,167 +774,59 @@ private Task ToggleCapturingAsync() return Task.CompletedTask; } - private Task ClearSessionsAsync() - { - _store.Clear(); - Sessions.Clear(); - _selectedSessions.Clear(); - SelectedSession = null; - _interception.ResetSessionIdSequence(); - RefreshSessionCountText(); - StatusText = "Sessions cleared"; - return Task.CompletedTask; - } - - private Task RemoveSelectedSessionsAsync() - { - var selected = ResolveExportSelection(); - if (selected.Count == 0) - { - StatusText = "Select one or more sessions to remove"; - return Task.CompletedTask; - } - var ids = selected.Select(s => s.Id).ToHashSet(); - _store.Remove(ids); - for (var i = Sessions.Count - 1; i >= 0; i--) - { - if (ids.Contains(Sessions[i].Id)) - { - Sessions.RemoveAt(i); - } - } + private Task ToggleSystemProxyAsync() => TryToggleSystemProxyAsync(); - _selectedSessions.Clear(); - if (SelectedSession is not null && ids.Contains(SelectedSession.Id)) + private async Task TryToggleSystemProxyAsync() + { + if (SystemProxy) { - SelectedSession = null; + SystemProxy = false; + return; } - RefreshSessionCountText(); - StatusText = selected.Count == 1 ? "Removed 1 session" : $"Removed {selected.Count} sessions"; - return Task.CompletedTask; - } - - private Task ToggleSystemProxyAsync() - { - SystemProxy = !SystemProxy; - return Task.CompletedTask; - } - - private async Task InstallCaAsync() - { if (!_interception.IsRunning) { - StatusText = "Start the proxy first"; + SetGuardStatus("Start the proxy before enabling system proxy"); return; } - var ok = _interception.InstallRootCertificate(machineStore: false); - if (!ok) + var s = _settings.Current; + if (!s.WarnedAboutPacReplace && SystemProxyPacHelper.HasActivePacScript()) { var owner = TryGetMainWindow(); - if (await _dialogs.ConfirmElevateRootCaAsync(owner)) - ok = _interception.InstallRootCertificateAsAdmin(machineStore: false); - else + if (!await AwaitCancellableAsync(_dialogs.ConfirmPacReplaceAsync(owner))) { - StatusText = "Root CA install cancelled elevation - try Export CA and install manually (Keychain / NSS / cert store)"; + StatusText = "System proxy not enabled (PAC replace cancelled)"; return; } + + s.WarnedAboutPacReplace = true; + _settings.Save(); } - StatusText = ok - ? "Root CA trusted - ready to enable Decrypt HTTPS" - : "Root CA install failed (store / Keychain / NSS) - try Export CA, or allow the admin prompt"; + SystemProxy = true; } + private Task AwaitCancellableAsync(Task task) => task.WaitAsync(StatusCancelToken); - private async Task UntrustCaAsync() - { - if (!_interception.IsRunning) - { - StatusText = "Start the proxy first"; - return; - } - var owner = TryGetMainWindow(); - if (!await _dialogs.ConfirmRemoveRootCaAsync(owner)) - { - StatusText = "Remove root CA cancelled"; - return; - } - _interception.UntrustRootCertificate(machineStore: false); - if (DecryptHttps) - { - SetDecryptHttpsCore(false); - } - StatusText = _interception.IsRootTrusted - ? "Remove requested but CA still present in store" - : "Root CA removed from current user store; Decrypt HTTPS is off until you install the CA again"; - } - private async Task RotateCaAsync() - { - if (!_interception.IsRunning) - { - StatusText = "Start the proxy first"; - return; - } - var owner = TryGetMainWindow(); - if (!await _dialogs.ConfirmRotateRootCaAsync(owner)) - { - StatusText = "Clear and reinstall root CA cancelled"; - return; - } - if (DecryptHttps) - SetDecryptHttpsCore(false); - var oldThumb = _interception.RootCertificate?.Thumbprint; - var ok = _interception.RotateRootCertificate(machineStore: false); - if (!ok) - { - StatusText = "Clear and reinstall root CA failed — see logs"; - return; - } + private Task AwaitCancellableAsync(Task task) => task.WaitAsync(StatusCancelToken); + - var newThumb = _interception.RootCertificate?.Thumbprint; - var changed = !string.IsNullOrEmpty(newThumb) && - !string.Equals(oldThumb, newThumb, StringComparison.OrdinalIgnoreCase); - if (await _dialogs.ConfirmInstallRootCaAsync(owner)) - { - var trusted = _interception.InstallRootCertificate(machineStore: false); - if (!trusted && await _dialogs.ConfirmElevateRootCaAsync(owner)) - trusted = _interception.InstallRootCertificateAsAdmin(machineStore: false); - StatusText = FormatRotateCaInstallStatus(trusted, changed); - return; - } - StatusText = FormatRotateCaDeferredTrustStatus(changed); - } - private static string FormatRotateCaInstallStatus(bool trusted, bool changed) - { - if (!trusted) - return "Root CA cleared but trust failed — use Install root CA or Export CA"; - return changed ? "Root CA cleared and reinstalled — enable Decrypt HTTPS when ready" : "Root CA recreate completed and trusted"; - } - private static string FormatRotateCaDeferredTrustStatus(bool changed) => - changed ? "Root CA cleared — Install root CA (or enable Decrypt HTTPS) to trust the new certificate" : "Root CA recreate completed — Install root CA to trust"; - private Task ExportCaAsync() - { - var path = _interception.ExportRootCertificate(); - StatusText = path is null ? "No root certificate yet — Start the proxy first" : "Exported CA: " + path; - return Task.CompletedTask; - } private async Task OpenLoopbackExemptAsync() { @@ -700,7 +851,7 @@ private async Task OpenLoopbackExemptAsync() return; } - await LoopbackExemptWindow.ShowAsync(owner); + await AwaitCancellableAsync(LoopbackExemptWindow.ShowAsync(owner)); StatusText = "Allow Store apps dialog closed"; } @@ -713,12 +864,13 @@ private async Task OpenSessionRetentionAsync() return; } - var saved = await SessionRetentionWindow.ShowAsync(owner, _settings); + var saved = await AwaitCancellableAsync(SessionRetentionWindow.ShowAsync(owner, _settings)); StatusText = saved ? "Session retention saved — restart Inspector to apply" : "Session retention cancelled"; } + private async Task OpenLoggingSettingsAsync() { var owner = TryGetMainWindow(); @@ -729,14 +881,14 @@ private async Task OpenLoggingSettingsAsync() return; } - var saved = await LoggingSettingsWindow.ShowAsync( + var saved = await AwaitCancellableAsync(LoggingSettingsWindow.ShowAsync( owner, _settings, s => { _interception.ConfigureLogging(s); DebugFileLogging = IsDebugFileLoggingEnabled(s); - }); + })); if (saved) { var path = _settings.Current.LoggingFilePath ?? LoggingSettingsWindow.DefaultLogPath(); @@ -750,28 +902,78 @@ private async Task OpenLoggingSettingsAsync() } } - private async Task OpenHttpsDecryptHostsAsync() + private async Task OpenExcludedHostsAsync() { var owner = TryGetMainWindow(); if (owner is null) { - StatusText = "HTTPS sites to decrypt requires the main window"; + StatusText = "Excluded hosts requires the main window"; return; } - var saved = await HttpsDecryptHostsWindow.ShowAsync( + var saved = await AwaitCancellableAsync(ExcludedHostsWindow.ShowAsync( owner, _settings, - ApplyDecryptHostListsFromSettings); - StatusText = saved - ? "HTTPS sites to decrypt saved (applies to new connections)" - : "HTTPS sites to decrypt cancelled"; + readOnly: false, + ApplyExclusionSettingsFromSettings)); + if (saved) + { + if (SystemProxy && !_interception.ReapplySystemProxyIfEnabled()) + { + StatusText = "Exclusions saved; re-toggle System proxy to apply OS bypass changes"; + } + else + { + StatusText = "Excluded hosts saved (applies to new connections)"; + } + + UpdateExclusionSummary(); + } + else + { + StatusText = "Excluded hosts cancelled"; + } + } + + private async Task ExcludeHostAsync() + { + var selected = SelectedSession; + if (selected is null || string.IsNullOrWhiteSpace(selected.Host)) + { + StatusText = "Select a session with a host to exclude"; + return; + } + + var owner = TryGetMainWindow(); + if (owner is null) + { + StatusText = "Exclude host requires the main window"; + return; + } + + var (saved, kind, _) = await AwaitCancellableAsync(ExcludeHostDialog.ShowAsync(owner, _settings, selected.Host)); + if (!saved) + { + StatusText = "Exclude host cancelled"; + return; + } + + ApplyExclusionSettingsFromSettings(); + if (kind == ExcludeHostKind.BypassProxy && SystemProxy) + { + _interception.ReapplySystemProxyIfEnabled(); + } + + UpdateExclusionSummary(); + StatusText = kind == ExcludeHostKind.BypassProxy + ? $"Added {selected.Host} to OS bypass exclusions (new connections)" + : $"Added {selected.Host} to tunnel-only exclusions (new connections)"; } private async Task ResetSettingsAsync() { var owner = TryGetMainWindow(); - if (!await _dialogs.ConfirmResetSettingsAsync(owner)) + if (!await AwaitCancellableAsync(_dialogs.ConfirmResetSettingsAsync(owner))) { StatusText = "Reset settings cancelled"; return; @@ -784,287 +986,109 @@ private async Task ResetSettingsAsync() "Settings restored to defaults — restart Inspector so retention limits fully apply. Root CA and sessions were not changed."; } - private void ApplyDecryptHostListsFromSettings() + private void ApplyExclusionSettingsFromSettings() { var s = _settings.Current; _interception.DecryptSkipHosts = s.DecryptSkipHosts?.ToList() ?? []; _interception.DecryptOnlyHosts = s.DecryptOnlyHosts?.ToList() ?? []; + _interception.SystemProxyBypassHosts = s.SystemProxyBypassHosts?.ToList() ?? []; + _interception.ProxyLoopback = s.ProxyLoopback; + _interception.SystemProxySettings = s; + UpdateExclusionSummary(); } - private async Task DeviceCaSetupAsync() + private void UpdateExclusionSummary() { - var message = - "To decrypt HTTPS from a phone or other device:\n\n" + - "1. Export the root CA (use Export CA below, or Capture → Export root CA…).\n" + - "2. Install the .cer on the device as a trusted CA.\n" + - $"3. Set the device HTTP proxy to this PC's LAN IP on port {BindPort} " + - $"(current bind is {BindAddress}:{BindPort}).\n\n" + - "Use Bind address 0.0.0.0 so other devices can reach the proxy."; - - var owner = TryGetMainWindow(); - if (await _dialogs.ShowDeviceCaSetupAsync(owner, message)) - { - await ExportCaAsync(); - } + ExclusionSummaryText = ExclusionPreview.ExclusionSummary(_settings.Current); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ExclusionSummaryText))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasExclusionSummary))); } - private async Task LoadFromSelectedAsync() - { - var selected = SelectedSession; - if (selected is null) - { - StatusText = "Select a session to load into Composer"; - return; - } - await _store.EnsureBodiesLoadedAsync(selected).ConfigureAwait(false); - await MarshalToUiAsync(() => - { - ComposerMethod = selected.Method; - ComposerUrl = selected.Url; - ComposerHeaders = selected.RequestHeadersText ?? ""; - ComposerBody = selected.RequestBodyText ?? ""; - StatusText = "Composer loaded from selected session"; - }).ConfigureAwait(false); - } - private async Task LoadIntoComposerAsync() - { - var selected = SelectedSession; - if (selected is null) - { - StatusText = "Select a session to load into Composer"; - return; - } - await _store.EnsureBodiesLoadedAsync(selected).ConfigureAwait(false); - await MarshalToUiAsync(() => - { - ComposerMethod = selected.Method; - ComposerUrl = selected.Url; - ComposerHeaders = selected.RequestHeadersText ?? ""; - ComposerBody = selected.RequestBodyText ?? ""; - StatusText = "Composer loaded from selected session"; - }).ConfigureAwait(false); - await OpenToolsTabAsync(0).ConfigureAwait(false); - } - private async Task CopyUrlAsync() - { - var urls = ResolveCopyUrls(); - if (urls.Count == 0) - { - StatusText = "Select a session with a URL to copy"; - return; - } - var text = string.Join(Environment.NewLine, urls); - var window = TryGetMainWindow(); - if (window?.Clipboard is { } clipboard) - { - await clipboard.SetTextAsync(text); - } - StatusText = urls.Count == 1 ? "Copied URL" : $"Copied {urls.Count} URLs"; - } - private Task FilterByHostAsync() - { - var host = ResolveUnanimousFilterHost(); - if (string.IsNullOrEmpty(host)) - { - StatusText = "Filter by host needs one shared host in the selection"; - return Task.CompletedTask; - } - SearchQuery = SessionSearch.SetKeyedToken(SearchQuery, "host", host); - StatusText = $"Filtered by host:{host}"; - return Task.CompletedTask; - } - private Task FilterByProcessAsync() + + private SessionSnapshot? ResolveSingleCopySession() { - var process = ResolveUnanimousFilterProcess(); - if (string.IsNullOrEmpty(process)) - { - StatusText = "Filter by process needs one shared process in the selection"; - return Task.CompletedTask; - } + var selection = ResolveFilterSelection(); + return selection.Count == 1 ? selection[0] : null; + } - SearchQuery = SessionSearch.SetKeyedToken(SearchQuery, "process", process); - StatusText = $"Filtered by process:{process}"; - return Task.CompletedTask; + + + + + public bool CanExcludeHost => CanFilterByHost; + + public string ExclusionSummaryText + { + get => _exclusionSummaryText; + private set => SetField(ref _exclusionSummaryText, value); } - /// True when selection shares one non-empty host (single or multi-select). - public bool CanFilterByHost => ResolveUnanimousFilterHost() is not null; + public bool HasExclusionSummary => !string.IsNullOrEmpty(_exclusionSummaryText); + + public string SelectedOpaqueHint => + _selected is { IsTunnel: true } && _selected.OpaqueReason != OpaqueTunnelReason.None + ? _selected.OpaqueReasonDisplay + : ""; + + public bool ShowSelectedOpaqueHint => !string.IsNullOrEmpty(SelectedOpaqueHint); /// True when selection shares one non-empty process (single or multi-select). - public bool CanFilterByProcess => ResolveUnanimousFilterProcess() is not null; + public bool CanFilterByProcess => + ShowProcessColumn && ResolveUnanimousFilterProcess() is not null; - /// True when at least one session is selected. - public bool HasSelectedSessions => ResolveFilterSelection().Count > 0; - /// True when exactly one session is selected (Replay / Composer). - public bool HasSingleSelectedSession => ResolveFilterSelection().Count == 1; - /// True when at least one selected session has a URL to copy. - public bool CanCopyUrl => ResolveCopyUrls().Count > 0; - private string? ResolveUnanimousFilterHost() + /// True when exactly one non-tunnel session with a URL is selected (curl/fetch). + public bool CanCopyAsCurl { - var selection = ResolveFilterSelection(); - if (selection.Count == 0) + get { - return null; + var session = ResolveSingleCopySession(); + return SessionRequestCodegen.CanGenerate(session); } + } - string? host = null; - foreach (var session in selection) + + /// Last Session Diff text (Inspect Diff tab / probe). + public string SessionDiffText + { + get => _sessionDiffText; + private set { - var value = ResolveSessionHost(session); - if (string.IsNullOrEmpty(value)) + if (SetField(ref _sessionDiffText, value)) { - return null; - } - - if (host is null) - { - host = value; - } - else if (!host.Equals(value, StringComparison.OrdinalIgnoreCase)) - { - return null; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanShowSessionDiffTab))); } } - - return host; } - private string? ResolveUnanimousFilterProcess() - { - var selection = ResolveFilterSelection(); - if (selection.Count == 0) - { - return null; - } + /// Show Inspect Diff tab after a Session Diff has been computed. + public bool CanShowSessionDiffTab => !string.IsNullOrEmpty(SessionDiffText); - string? process = null; - foreach (var session in selection) - { - var value = ResolveSessionProcess(session); - if (string.IsNullOrEmpty(value)) - { - return null; - } - if (process is null) - { - process = value; - } - else if (!process.Equals(value, StringComparison.OrdinalIgnoreCase)) - { - return null; - } - } - return process; - } - private IReadOnlyList ResolveFilterSelection() - { - if (_selectedSessions.Count > 0) - { - return _selectedSessions; - } - return SelectedSession is null ? Array.Empty() : [SelectedSession]; - } - private static string? ResolveSessionHost(SessionSnapshot session) - { - if (!string.IsNullOrWhiteSpace(session.Host)) - { - return session.Host.Trim(); - } - return Uri.TryCreate(session.Url, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Host) - ? uri.Host - : null; - } - private static string? ResolveSessionProcess(SessionSnapshot session) - { - if (!string.IsNullOrWhiteSpace(session.ProcessName)) - { - return session.ProcessName.Trim(); - } - return session.ProcessId > 0 ? session.ProcessId.ToString() : null; - } - private void NotifyFilterSelectionProperties() - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanFilterByHost))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanFilterByProcess))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasSelectedSessions))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasSingleSelectedSession))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CanCopyUrl))); - } - private List ResolveCopyUrls() => - ResolveFilterSelection() - .Where(snap => !string.IsNullOrEmpty(snap.Url)) - .Select(snap => snap.Url) - .ToList(); - private Task AddAutoResponderRuleAsync() - { - AutoResponder.Rules.Add(new AutoResponderRule - { - MatchUrl = AutoResponderMatch, - StatusCode = AutoResponderStatus, - Body = AutoResponderBody, - ContentType = AutoResponderContentType, - Enabled = true, - }); - PersistAutoResponder(); - StatusText = $"AutoResponder rule added ({AutoResponder.Rules.Count} total)"; - return Task.CompletedTask; - } - private Task DeleteAutoResponderRuleAsync() - { - if (AutoResponder.SelectedRule is null) - { - StatusText = "Select an AutoResponder rule to delete"; - return Task.CompletedTask; - } - AutoResponder.Rules.Remove(AutoResponder.SelectedRule); - AutoResponder.SelectedRule = null; - PersistAutoResponder(); - StatusText = "AutoResponder rule deleted"; - return Task.CompletedTask; - } - private Task UpdateAutoResponderRuleAsync() - { - if (AutoResponder.SelectedRule is null) - { - StatusText = "Select an AutoResponder rule to update"; - return Task.CompletedTask; - } - - var rule = AutoResponder.SelectedRule; - rule.MatchUrl = AutoResponderMatch; - rule.StatusCode = AutoResponderStatus; - rule.Body = AutoResponderBody; - rule.ContentType = AutoResponderContentType; - PersistAutoResponder(); - StatusText = "AutoResponder rule updated"; - return Task.CompletedTask; - } private Task ApplyEditBodyAsync() { @@ -1076,9 +1100,13 @@ private Task ApplyEditBodyAsync() public ObservableCollection Sessions { get; } public BreakpointViewModel Breakpoints { get; } public AutoResponderViewModel AutoResponder { get; } + public MapRemoteViewModel MapRemote { get; } public ICommand CheckForUpdatesCommand { get; } public ICommand SetUpdateChannelStableCommand { get; } public ICommand SetUpdateChannelBetaCommand { get; } + public ICommand SetThemeLightCommand { get; } + public ICommand SetThemeDarkCommand { get; } + public ICommand SetThemeAutomaticCommand { get; } public ICommand ToggleCheckForUpdatesOnStartupCommand { get; } public ICommand ExportHarCommand { get; } public ICommand ExportSelectedHarCommand { get; } @@ -1086,6 +1114,7 @@ private Task ApplyEditBodyAsync() public ICommand ExportArchiveCommand { get; } public ICommand ExportSelectedArchiveCommand { get; } public ICommand ImportArchiveCommand { get; } + public ICommand ExitCommand { get; } public ICommand StartCaptureCommand { get; } public ICommand StopCaptureCommand { get; } public ICommand ToggleInterceptCommand { get; } @@ -1098,6 +1127,7 @@ private Task ApplyEditBodyAsync() public ICommand RemoveSelectedSessionsCommand { get; } public ICommand ToggleSystemProxyCommand { get; } public ICommand InstallCaCommand { get; } + public ICommand TrustFirefoxCaCommand { get; } public ICommand UntrustCaCommand { get; } public ICommand RotateCaCommand { get; } public ICommand ExportCaCommand { get; } @@ -1105,18 +1135,28 @@ private Task ApplyEditBodyAsync() public ICommand OpenLoopbackExemptCommand { get; } public ICommand OpenSessionRetentionCommand { get; } public ICommand OpenLoggingSettingsCommand { get; } + public ICommand OpenAboutCommand { get; } public ICommand OpenHttpsDecryptHostsCommand { get; } + public ICommand ExcludeHostCommand { get; } + public ICommand OpenExclusionSummaryCommand { get; } public ICommand ResetSettingsCommand { get; } public ICommand ReplayCommand { get; } public ICommand LoadFromSelectedCommand { get; } public ICommand LoadIntoComposerCommand { get; } public ICommand CopyUrlCommand { get; } + public ICommand CopyAsCurlCommand { get; } + public ICommand CopyAsFetchCommand { get; } + public ICommand DiffSessionsCommand { get; } public ICommand FilterByHostCommand { get; } public ICommand FilterByProcessCommand { get; } public ICommand SendComposerCommand { get; } public ICommand AddAutoResponderRuleCommand { get; } public ICommand DeleteAutoResponderRuleCommand { get; } public ICommand UpdateAutoResponderRuleCommand { get; } + public ICommand BrowseAutoResponderLocalFileCommand { get; } + public ICommand AddMapRemoteRuleCommand { get; } + public ICommand DeleteMapRemoteRuleCommand { get; } + public ICommand UpdateMapRemoteRuleCommand { get; } public ICommand ContinueBreakpointCommand { get; } public ICommand AbortBreakpointCommand { get; } public ICommand ApplyEditBodyCommand { get; } @@ -1126,6 +1166,7 @@ private Task ApplyEditBodyAsync() public ICommand OpenToolsBreakpointsCommand { get; } public ICommand OpenToolsAutoResponderCommand { get; } public ICommand OpenToolsScriptsCommand { get; } + public ICommand OpenToolsMapRemoteCommand { get; } public ICommand ClearFiltersCommand { get; } public string BindAddress @@ -1245,6 +1286,37 @@ public string AutoResponderContentType set => SetField(ref _autoResponderContentType, value); } + /// Optional Map Local file path; when set, response body is read from disk. + public string AutoResponderLocalFilePath + { + get => _autoResponderLocalFilePath; + set => SetField(ref _autoResponderLocalFilePath, value); + } + + public string MapRemoteMatch + { + get => _mapRemoteMatch; + set => SetField(ref _mapRemoteMatch, value); + } + + public string MapRemoteTarget + { + get => _mapRemoteTarget; + set => SetField(ref _mapRemoteTarget, value); + } + + public string MapRemoteGraphQlOperation + { + get => _mapRemoteGraphQlOperation; + set => SetField(ref _mapRemoteGraphQlOperation, value); + } + + public string AutoResponderGraphQlOperation + { + get => _autoResponderGraphQlOperation; + set => SetField(ref _autoResponderGraphQlOperation, value); + } + public int AutoResponderStatus { get => _autoResponderStatus; @@ -1286,35 +1358,46 @@ public bool SystemProxy { if (!_interception.IsRunning) { - StatusText = "Start the proxy before enabling system proxy"; + SetGuardStatus("Start the proxy before enabling system proxy"); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SystemProxy))); return; } - if (!_interception.SetSystemProxy(true)) + if (!_interception.SetSystemProxy(true, _settings.Current)) { - StatusText = - "Failed to enable system proxy (permissions, cancelled admin prompt, or unsupported desktop environment)"; + var detail = _interception.LastSystemProxyError; + var text = string.IsNullOrWhiteSpace(detail) + ? "Failed to enable system proxy (permissions, cancelled admin prompt, or unsupported desktop environment)" + : "Failed to enable system proxy: " + Truncate(detail, 180); + SetOutcomeStatus(text, StatusSeverity.Error, toastImportant: true); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SystemProxy))); return; } SetSystemProxyCore(true); - StatusText = - "System proxy enabled. For Chrome: disable QUIC (--disable-quic) or H3 may bypass the proxy."; + SetOutcomeStatus( + SystemProxyEnabledStatusMessage(), + StatusSeverity.Success, + toastImportant: OperatingSystem.IsWindows()); return; } if (_interception.IsRunning && _interception.SystemProxyEnabled && !_interception.SetSystemProxy(false)) { - StatusText = "Failed to restore system proxy settings"; + var detail = _interception.LastSystemProxyError; + var text = string.IsNullOrWhiteSpace(detail) + ? "Failed to restore system proxy settings" + : "Failed to restore system proxy: " + Truncate(detail, 180); + SetOutcomeStatus(text, StatusSeverity.Error, toastImportant: true); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SystemProxy))); return; } SetSystemProxyCore(false); - StatusText = "System proxy restored"; + SetOutcomeStatus( + SystemProxyRestoredStatus, + StatusSeverity.Success); } } @@ -1383,6 +1466,12 @@ public bool UpdateChannelIsStable } } + public bool ThemeModeIsLight => _settings.Current.ThemeMode == ThemeMode.Light; + + public bool ThemeModeIsDark => _settings.Current.ThemeMode == ThemeMode.Dark; + + public bool ThemeModeIsAutomatic => _settings.Current.ThemeMode == ThemeMode.Automatic; + public bool CheckForUpdatesOnStartup { get => _settings.Current.CheckForUpdatesOnStartup; @@ -1451,6 +1540,9 @@ public bool IgnoreServerCertificateErrors public bool ShowLoopbackExemptMenu { get; } + /// True when this OS can resolve local client process ids for the Process column. + public bool ShowProcessColumn { get; } + /// Right pane visibility (Inspect + Tools). Kept name for tests. public bool ShowSessionDetails { @@ -1477,6 +1569,52 @@ public bool ShowWsFramesTab private set => SetField(ref _showWsFramesTab, value); } + public bool ShowSseTab + { + get => _showSseTab; + private set => SetField(ref _showSseTab, value); + } + + public bool ShowProtobufTab + { + get => _showProtobufTab; + private set => SetField(ref _showProtobufTab, value); + } + + public string SelectedSseEvents + { + get => _selectedSseEvents; + private set => SetField(ref _selectedSseEvents, value); + } + + public string SelectedProtobufDecoded + { + get => _selectedProtobufDecoded; + private set => SetField(ref _selectedProtobufDecoded, value); + } + + /// Network throttle profile name applied to capture (None / Slow 3G / Fast 3G / LTE). + public string NetworkThrottleProfile + { + get => _networkThrottleProfile; + set + { + if (!SetField(ref _networkThrottleProfile, value ?? "None")) + { + return; + } + + _interception.ThrottleProfile = NetworkThrottle.Find(_networkThrottleProfile) is { IsEnabled: true } p + ? p + : null; + _settings.Current.NetworkThrottleProfile = _networkThrottleProfile; + _settings.Save(); + } + } + + public IReadOnlyList NetworkThrottleProfileNames { get; } = + NetworkThrottle.Profiles.Select(p => p.Name).ToArray(); + public string SearchQuery { get => _searchQuery; @@ -1550,9 +1688,11 @@ public SessionSnapshot? SelectedSession PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasSelectedSession))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowInspectEmpty))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedOpaqueHint))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowSelectedOpaqueHint))); NotifyFilterSelectionProperties(); - if (value is not null) + if (value is not null && !_suppressOpenSessionDetails) { ShowSessionDetails = true; SelectedOuterPaneIndex = 0; @@ -1588,7 +1728,7 @@ public int SelectedOuterPaneIndex } } - /// Inspect tabs: 0 Headers, 1 Body, 2 Hex, 3 WS Frames. + /// Inspect tabs: 0 Headers, 1 Body, 2 Hex, 3 Diff, 4 WS Frames. public int SelectedInspectTabIndex { get => _selectedInspectTabIndex; @@ -1601,7 +1741,7 @@ public int SelectedInspectTabIndex } } - /// Tools tabs: 0 Composer, 1 Breakpoints, 2 AutoResponder, 3 Scripts. + /// Tools tabs: 0 Composer, 1 Breakpoints, 2 AutoResponder, 3 Scripts, 4 Map Remote. public int SelectedToolsTabIndex { get => _selectedToolsTabIndex; @@ -1615,7 +1755,7 @@ public int SelectedToolsTabIndex } /// - /// Compatibility index for tests: 0–3 Inspect, 4–7 Tools (Composer…Scripts). + /// Compatibility index for tests: 0–3 Inspect, 4–8 Tools (Composer…Map Remote). /// public int SelectedDetailTabIndex { @@ -1627,12 +1767,12 @@ public int SelectedDetailTabIndex if (value < 4) { SelectedOuterPaneIndex = 0; - SelectedInspectTabIndex = Math.Clamp(value, 0, 3); + SelectedInspectTabIndex = Math.Clamp(value, 0, 6); } else { SelectedOuterPaneIndex = 1; - SelectedToolsTabIndex = Math.Clamp(value - 4, 0, 3); + SelectedToolsTabIndex = Math.Clamp(value - 4, 0, 4); } PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedDetailTabIndex))); @@ -1642,7 +1782,17 @@ public int SelectedDetailTabIndex public string StatusText { get => _statusText; - set => SetField(ref _statusText, value); + set + { + if (_settingStatus) + { + SetField(ref _statusText, value); + return; + } + + // Direct assignments (toggles / guards) stay Neutral and clear busy. + SetStatus(value, StatusSeverity.Neutral); + } } /// Live session total; kept separate so capture traffic does not wipe command feedback. @@ -1679,14 +1829,22 @@ private void LoadFromSettings() _interception.ScriptOnResponse = _scriptOnResponse; _interception.IgnoreServerCertificateErrors = s.IgnoreServerCertificateErrors; _interception.DecryptHttps = _decryptHttps; - ApplyDecryptHostListsFromSettings(); + _interception.ProtobufDescriptorSetPath = s.ProtobufDescriptorSetPath; + _networkThrottleProfile = string.IsNullOrWhiteSpace(s.NetworkThrottleProfile) ? "None" : s.NetworkThrottleProfile; + _interception.ThrottleProfile = NetworkThrottle.Find(_networkThrottleProfile) is { IsEnabled: true } tp + ? tp + : null; + ApplyExclusionSettingsFromSettings(); _debugFileLogging = IsDebugFileLoggingEnabled(s); _interception.ConfigureLogging(s); AutoResponder.Enabled = s.AutoResponderEnabled; AutoResponder.LoadFromDtos(s.AutoResponderRules); + MapRemote.Enabled = s.MapRemoteEnabled; + MapRemote.LoadFromDtos(s.MapRemoteRules); Breakpoints.Enabled = s.BreakpointEnabled; Breakpoints.UrlFilter = string.IsNullOrEmpty(s.BreakpointUrlFilter) ? "*" : s.BreakpointUrlFilter; + Breakpoints.GraphQlOperationName = s.BreakpointGraphQlOperationName ?? ""; } private void NotifySettingsUiChanged() @@ -1704,6 +1862,32 @@ private void NotifySettingsUiChanged() PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(UpdateChannelIsBeta))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(UpdateChannelIsStable))); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CheckForUpdatesOnStartup))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ThemeModeIsLight))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ThemeModeIsDark))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ThemeModeIsAutomatic))); + ThemeService.ApplyThemeMode(_settings.Current.ThemeMode); + } + + private void SetThemeMode(ThemeMode mode) + { + if (_settings.Current.ThemeMode == mode) + { + return; + } + + _settings.Current.ThemeMode = mode; + _settings.Save(); + ThemeService.ApplyThemeMode(mode); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ThemeModeIsLight))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ThemeModeIsDark))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ThemeModeIsAutomatic))); + } + + /// Rebind theme-aware brushes after changes. + public void NotifyThemeVariantChanged() + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(StatusSeverity))); + ThemeRefreshTick++; } private static bool IsDebugFileLoggingEnabled(InspectorSettings s) => @@ -1718,6 +1902,14 @@ private void PersistAutoResponder() AutoResponder.NotifyRulesChanged(); } + private void PersistMapRemote() + { + _settings.Current.MapRemoteEnabled = MapRemote.Enabled; + _settings.Current.MapRemoteRules = MapRemote.ToDtos(); + _settings.Save(); + MapRemote.NotifyRulesChanged(); + } + private void PersistSettings() { var s = _settings.Current; @@ -1729,8 +1921,13 @@ private void PersistSettings() s.IgnoreServerCertificateErrors = _interception.IgnoreServerCertificateErrors; s.AutoResponderEnabled = AutoResponder.Enabled; s.AutoResponderRules = AutoResponder.ToDtos(); + s.MapRemoteEnabled = MapRemote.Enabled; + s.MapRemoteRules = MapRemote.ToDtos(); s.BreakpointEnabled = Breakpoints.Enabled; s.BreakpointUrlFilter = Breakpoints.UrlFilter; + s.BreakpointGraphQlOperationName = string.IsNullOrWhiteSpace(Breakpoints.GraphQlOperationName) + ? null + : Breakpoints.GraphQlOperationName; s.BreakpointOnResponse = BreakpointOnResponse; s.ScriptOnRequest = ScriptOnRequest; s.ScriptOnResponse = ScriptOnResponse; @@ -1751,74 +1948,35 @@ private Task OpenToolsTabAsync(int toolsTabIndex) { ShowSessionDetails = true; SelectedOuterPaneIndex = 1; - SelectedToolsTabIndex = Math.Clamp(toolsTabIndex, 0, 3); + SelectedToolsTabIndex = Math.Clamp(toolsTabIndex, 0, 4); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedDetailTabIndex))); return Task.CompletedTask; } private void UpdateWsFramesVisibility() { - var show = _selected?.IsWebSocket == true; - ShowWsFramesTab = show; - if (!show && SelectedInspectTabIndex == 3) + ShowWsFramesTab = _selected?.IsWebSocket == true; + ShowSseTab = _selected?.IsServerSentEvents == true || + (_selected?.SseEvents?.Count > 0); + ShowProtobufTab = _selected?.IsGrpc == true || + _selected?.IsTranscoded == true || + !string.IsNullOrEmpty(_selected?.ProtobufDecodedText); + // Inspect tabs: 0 Headers, 1 Body, 2 Hex, 3 Diff, 4 WS, 5 SSE, 6 Protobuf + if ((!ShowWsFramesTab && SelectedInspectTabIndex == 4) || + (!ShowSseTab && SelectedInspectTabIndex == 5) || + (!ShowProtobufTab && SelectedInspectTabIndex == 6)) { SelectedInspectTabIndex = 0; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedDetailTabIndex))); } } - private async Task EnableDecryptHttpsAsync() - { - _decryptHttpsBusy = true; - try - { - if (!_interception.IsRunning) - { - StatusText = "Start the proxy before enabling Decrypt HTTPS"; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DecryptHttps))); - return; - } - _interception.RefreshTrustState(); - if (!_interception.IsRootTrusted) - { - var owner = TryGetMainWindow(); - if (!await _dialogs.ConfirmInstallRootCaAsync(owner)) - { - StatusText = "Decrypt HTTPS cancelled — root CA not installed"; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DecryptHttps))); - return; - } - if (!_interception.InstallRootCertificate(machineStore: false) && - !await TryElevateRootCaInstallAsync(owner)) - { - StatusText = "Root CA install failed - Decrypt HTTPS stays off (try Export CA or allow admin prompt)"; - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DecryptHttps))); - return; - } - } - SetDecryptHttpsCore(true); - StatusText = "Decrypting HTTPS"; - } - finally - { - _decryptHttpsBusy = false; - } - } - private async Task TryElevateRootCaInstallAsync(Window? owner) => - await _dialogs.ConfirmElevateRootCaAsync(owner) && - _interception.InstallRootCertificateAsAdmin(machineStore: false); - private void SetDecryptHttpsCore(bool enabled) - { - _decryptHttps = enabled; - _interception.DecryptHttps = enabled; - PersistSettings(); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DecryptHttps))); - } + private string FormatBindDisplay() { @@ -1859,219 +2017,179 @@ private void RefreshSelectedInspectors() if (_selected is null) { SelectedHeaders = SelectedBody = SelectedHex = SelectedFrames = ""; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedOpaqueHint))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowSelectedOpaqueHint))); return; } - var sb = new StringBuilder(); - sb.AppendLine("=== Request ==="); - sb.AppendLine(_selected.RequestHeadersText); - if (!string.IsNullOrEmpty(_selected.ResponseHeadersText)) - { - sb.AppendLine("=== Response ==="); - sb.AppendLine(_selected.ResponseHeadersText); - } - - var cookies = SessionInspectors.ParseCookies(SessionInspectors.ParseHeaderBlock(_selected.RequestHeadersText)); - var query = SessionInspectors.ParseQuery(_selected.Url); - if (cookies.Count > 0) - { - sb.AppendLine("=== Cookies ==="); - foreach (var c in cookies) - { - sb.Append(c.Key).Append('=').AppendLine(c.Value); - } - } - - if (query.Count > 0) - { - sb.AppendLine("=== Query ==="); - foreach (var q in query) - { - sb.Append(q.Key).Append('=').AppendLine(q.Value); - } - } - - SelectedHeaders = sb.ToString(); - - SelectedBody = SessionInspectors.FormatLabeledBody( - _selected.RequestHeadersText, - _selected.ResponseHeadersText, - _selected.RequestBodyText, - _selected.ResponseBodyText, - _selected.RequestBodyBytes, - _selected.ResponseBodyBytes); + SelectedHeaders = BuildSelectedHeadersText(_selected); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedOpaqueHint))); + SelectedBody = BuildSelectedBodyText(_selected); SelectedHex = SessionInspectors.FormatLabeledHex( _selected.RequestHeadersText, _selected.ResponseHeadersText, _selected.RequestBodyBytes, _selected.ResponseBodyBytes); + SelectedFrames = BuildSelectedFramesText(_selected); + SelectedSseEvents = BuildSelectedSseText(_selected); + SelectedProtobufDecoded = BuildSelectedProtobufText(_selected); + } - if (_selected.WebSocketFrames is { Count: > 0 } frames) + private static string BuildSelectedHeadersText(SessionSnapshot selected) + { + var sb = new StringBuilder(); + if (selected.IsTunnel && selected.OpaqueReason != OpaqueTunnelReason.None) { - var fb = new StringBuilder(); - foreach (var f in frames) - { - fb.Append('[').Append(f.Direction).Append(' ').Append(f.Opcode).Append("] ") - .AppendLine(f.PayloadPreview); - } - - SelectedFrames = fb.ToString(); + sb.AppendLine(selected.OpaqueReasonDisplay); + sb.AppendLine(); } - else + + if (selected.IsTranscoded) { - SelectedFrames = _selected.IsWebSocket ? "(no frames parsed)" : ""; + sb.AppendLine("=== gRPC-JSON transcoded ==="); + sb.Append("Client: ").Append(selected.ClientMethod ?? selected.Method) + .Append(' ').AppendLine(selected.ClientPathAndQuery ?? selected.Url); + if (!string.IsNullOrEmpty(selected.ClientContentType)) + sb.Append("Client Content-Type: ").AppendLine(selected.ClientContentType); + sb.Append("Upstream: ").Append(selected.UpstreamMethod ?? "POST") + .Append(' ').AppendLine(selected.UpstreamPath ?? ""); + if (!string.IsNullOrEmpty(selected.UpstreamContentType)) + sb.Append("Upstream Content-Type: ").AppendLine(selected.UpstreamContentType); + sb.AppendLine(); } - } - private void OnSessionAddedToFilter(SessionSnapshot snapshot) - { - // Store already holds the row — append to the filtered grid in place. - if (SessionSearch.Matches(snapshot, SearchQuery)) + sb.AppendLine("=== Request ==="); + sb.AppendLine(selected.RequestHeadersText); + if (!string.IsNullOrEmpty(selected.ResponseHeadersText)) { - Sessions.Add(snapshot); + sb.AppendLine("=== Response ==="); + sb.AppendLine(selected.ResponseHeadersText); } - RefreshSessionCountText(); + AppendNameValues(sb, "=== Cookies ===", + SessionInspectors.ParseCookies(SessionInspectors.ParseHeaderBlock(selected.RequestHeadersText))); + AppendNameValues(sb, "=== Query ===", SessionInspectors.ParseQuery(selected.Url)); + return sb.ToString(); } - private void OnSessionsRemoved(IReadOnlyList removed) + private static void AppendNameValues( + StringBuilder sb, string heading, IReadOnlyDictionary values) { - if (removed.Count == 0) - { + if (values.Count == 0) return; - } - - var ids = removed.Select(s => s.Id).ToHashSet(); - for (var i = Sessions.Count - 1; i >= 0; i--) - { - if (ids.Contains(Sessions[i].Id)) + sb.AppendLine(heading); + foreach (var pair in values) + sb.Append(pair.Key).Append('=').AppendLine(pair.Value); + } + + private static string BuildSelectedBodyText(SessionSnapshot selected) + { + var body = SessionInspectors.FormatLabeledBody( + selected.RequestHeadersText, + selected.ResponseHeadersText, + selected.RequestBodyText, + selected.ResponseBodyText, + selected.RequestBodyBytes, + selected.ResponseBodyBytes); + if (!selected.IsTranscoded) + return body; + + var prefix = new StringBuilder(); + prefix.AppendLine("=== Client (JSON/REST) ==="); + prefix.AppendLine(selected.RequestBodyText ?? "(empty)"); + prefix.AppendLine(); + prefix.AppendLine("=== Client response (JSON) ==="); + prefix.AppendLine(selected.ResponseBodyText ?? "(empty)"); + if (selected.UpstreamRequestBodyBytes is { Length: > 0 } || + selected.UpstreamResponseBodyBytes is { Length: > 0 }) + { + prefix.AppendLine(); + prefix.AppendLine("=== Upstream gRPC frames (see Hex / frame preview) ==="); + if (selected.GrpcFrames is { Count: > 0 } gf) { - Sessions.RemoveAt(i); + foreach (var f in gf) + prefix.Append("frame compressed=").Append(f.Compressed) + .Append(" len=").Append(f.Length) + .Append(" preview=").AppendLine(f.HexPreview); } } - _selectedSessions.RemoveAll(s => ids.Contains(s.Id)); - if (SelectedSession is not null && ids.Contains(SelectedSession.Id)) - { - SelectedSession = null; - } - - RefreshSessionCountText(); - if (removed.Count == 1) - { - StatusText = "Removed 1 oldest session to stay under limits"; - } - else - { - StatusText = $"Removed {removed.Count} oldest sessions to stay under limits"; - } + prefix.AppendLine(); + prefix.Append(body); + return prefix.ToString(); } - private async Task LoadSelectedBodiesAsync(SessionSnapshot snap) + private static string BuildSelectedFramesText(SessionSnapshot selected) { - try - { - await _store.EnsureBodiesLoadedAsync(snap).ConfigureAwait(false); - await MarshalToUiAsync(() => - { - if (ReferenceEquals(_selected, snap)) - { - RefreshSelectedInspectors(); - } - }).ConfigureAwait(false); - } - catch + if (selected.WebSocketFrames is not { Count: > 0 } frames) + return selected.IsWebSocket ? "(no frames parsed)" : ""; + + var fb = new StringBuilder(); + foreach (var f in frames) { - await MarshalToUiAsync(() => - { - if (ReferenceEquals(_selected, snap)) - { - RefreshSelectedInspectors(); - } - }).ConfigureAwait(false); + fb.Append('[').Append(f.Direction).Append(' ').Append(f.Opcode).Append("] ") + .AppendLine(f.PayloadPreview); } - } - private void RefreshSessionCountText() - { - var spilled = _store.SpilledCount; - var spilledSuffix = spilled > 0 ? $" ({spilled} bodies on disk)" : ""; - SessionCountText = string.IsNullOrWhiteSpace(SearchQuery) - ? $"Sessions: {_all.Count}{spilledSuffix}" - : $"Sessions: {Sessions.Count} / {_all.Count}{spilledSuffix}"; + return fb.ToString(); } - private void NotifyQuickFilterProperties() + private static string BuildSelectedSseText(SessionSnapshot selected) { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HideTunnelsFilter))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HideImagesFilter))); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ErrorsOnlyFilter))); - } + if (selected.SseEvents is not { Count: > 0 } sse) + return selected.IsServerSentEvents ? "(no events parsed)" : ""; - private void ApplyFilter() - { - var previouslySelected = SelectedSession; - Sessions.Clear(); - foreach (var s in SessionSearch.Filter(_all, SearchQuery)) + var sseSb = new StringBuilder(); + foreach (var ev in sse) { - Sessions.Add(s); + sseSb.Append("event=").Append(ev.Event); + if (!string.IsNullOrEmpty(ev.Id)) + sseSb.Append(" id=").Append(ev.Id); + sseSb.AppendLine(); + sseSb.AppendLine(ev.Data); + sseSb.AppendLine("---"); } - // Restore single selection used by the detail pane when the row still matches the filter. - if (previouslySelected is not null && Sessions.Contains(previouslySelected)) - { - SelectedSession = previouslySelected; - } - else if (previouslySelected is not null) - { - SelectedSession = null; - } + return sseSb.ToString(); } - /// Startup or Help → Check for updates. When , offer install dialog. - public async Task CheckUpdatesAsync(bool promptIfAvailable = true) + private static string BuildSelectedProtobufText(SessionSnapshot selected) { - var channel = _updates.ChannelDisplayName; - StatusText = $"Checking for updates ({channel})…"; - var result = await _updates.CheckAsync(); - if (!result.UpdateAvailable || string.IsNullOrEmpty(result.AssetUrl)) + if (!string.IsNullOrEmpty(selected.ProtobufDecodedText)) + return selected.ProtobufDecodedText; + if (selected.IsGrpc || selected.IsTranscoded) { - StatusText = result.Message; - return; + return ProtobufMessageDecoder.DecodeWireFormat( + selected.UpstreamResponseBodyBytes ?? selected.UpstreamRequestBodyBytes ?? selected.ResponseBodyBytes); } - StatusText = result.Message; - if (!promptIfAvailable) - { - return; - } + return ""; + } + + - var owner = TryGetMainWindow(); - var version = result.RemoteVersion ?? ""; - if (!await _dialogs.ConfirmInstallUpdateAsync(owner, version, result.ChannelDisplay)) - { - StatusText = $"Update available: {version} ({result.ChannelDisplay})"; - return; - } - StatusText = "Downloading update…"; - var (ok, message) = await _updates.DownloadAndStartApplyAsync(result); - StatusText = message; - if (!ok) + + + + + private Task ExitAsync() + { + // Close the main window so OnClosing runs BeginBackgroundShutdown (system proxy restore). + var window = TryGetMainWindow(); + if (window is not null) { - return; + window.Close(); + return Task.CompletedTask; } - StatusText = $"Installing {version} ({result.ChannelDisplay})… restarting."; BeginBackgroundShutdown(); if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.Shutdown(); } - else - { - TryGetMainWindow()?.Close(); - } + + return Task.CompletedTask; } private async Task StartCaptureAsync() @@ -2084,7 +2202,8 @@ private async Task StartCaptureAsync() _interception.IgnoreServerCertificateErrors = _settings.Current.IgnoreServerCertificateErrors; _interception.DecryptHttps = _decryptHttps; _interception.ConfigureLogging(_settings.Current); - await _interception.StartAsync(address, BindPort); + SetStatus("Starting proxy…", StatusSeverity.Busy); + await _interception.StartAsync(address, BindPort, _statusRevertCts?.Token ?? CancellationToken.None); if (_interception.BoundPort > 0) { BindPort = _interception.BoundPort; @@ -2096,33 +2215,30 @@ private async Task StartCaptureAsync() var wantSystemProxy = _reenableSystemProxyOnStart || AutoSystemProxyOnStart; _reenableSystemProxyOnStart = false; + var showedSystemProxyGuidance = false; if (wantSystemProxy && !SystemProxy) { SystemProxy = true; + showedSystemProxyGuidance = SystemProxy; } // If settings asked for decrypt but CA is gone, fall back to CONNECT (no silent re-trust). if (_decryptHttps && !_interception.RefreshTrustState()) { SetDecryptHttpsCore(false); - StatusText = SystemProxy - ? $"Proxy running on {FormatBindDisplay()}:{BindPort}; system proxy on — Decrypt HTTPS off (root CA not trusted). Install CA or enable Decrypt HTTPS." - : $"Proxy running on {FormatBindDisplay()}:{BindPort} — Decrypt HTTPS off (root CA not trusted). Install CA or enable Decrypt HTTPS."; + SetStatus( + SystemProxy + ? $"Proxy running on {FormatBindDisplay()}:{BindPort}; system proxy on — Decrypt HTTPS off (root CA not trusted). Install CA or enable Decrypt HTTPS." + : $"Proxy running on {FormatBindDisplay()}:{BindPort} — Decrypt HTTPS off (root CA not trusted). Install CA or enable Decrypt HTTPS.", + StatusSeverity.Warning); return; } - if (SystemProxy) + // Keep the system-proxy restart guidance visible; do not replace it with Ready. + if (!showedSystemProxyGuidance) { - StatusText = _decryptHttps - ? $"Proxy running on {FormatBindDisplay()}:{BindPort}; system proxy on. Decrypt HTTPS on. Chrome: --disable-quic or H3 may bypass." - : $"Proxy running on {FormatBindDisplay()}:{BindPort}; system proxy on. HTTPS shown as encrypted tunnels until Decrypt HTTPS is enabled." + - " Chrome/Edge: --disable-quic or HTTP/3 may bypass the proxy."; - return; + SetSteadyStatus(StatusReady); } - - StatusText = _decryptHttps - ? $"Proxy running on {FormatBindDisplay()}:{BindPort} — Decrypt HTTPS on. Enable System proxy if needed. Chrome: --disable-quic or H3 may bypass." - : $"Proxy running on {FormatBindDisplay()}:{BindPort} — HTTPS shown as encrypted tunnels until Decrypt HTTPS is enabled. Enable System proxy if needed."; } private void RefreshEndpointAndBindUi() @@ -2150,88 +2266,8 @@ private static IPAddress ParseBindAddress(string bindAddress) return IPAddress.Parse(bindAddress); } - private async Task ReplaySelectedAsync() - { - if (SelectedSession is null) - { - StatusText = "Select a session to replay"; - return; - } - - StatusText = "Replaying…"; - await _store.EnsureBodiesLoadedAsync(SelectedSession).ConfigureAwait(false); - var result = await ReplayService.ReplayAsync( - SelectedSession, - ignoreServerCertificateErrors: _interception.IgnoreServerCertificateErrors).ConfigureAwait(false); - await MarshalToUiAsync(() => - { - StatusText = result.Ok - ? $"Replay → HTTP {result.StatusCode}: {Truncate(result.Message, 120)}" - : "Replay failed: " + result.Message; - }).ConfigureAwait(false); - } - - private async Task SendComposerAsync() - { - if (string.IsNullOrWhiteSpace(ComposerUrl)) - { - StatusText = "Composer URL is required"; - return; - } - - StatusText = "Composer sending…"; - var template = new SessionSnapshot - { - Method = string.IsNullOrWhiteSpace(ComposerMethod) ? "GET" : ComposerMethod, - Url = ComposerUrl, - RequestHeadersText = ComposerHeaders, - RequestBodyText = ComposerBody, - ContentType = GuessContentType(ComposerHeaders), - }; - - var result = await ReplayService.ReplayAsync( - template, - editedUrl: ComposerUrl, - editedMethod: ComposerMethod, - editedBody: ComposerBody, - editedHeaders: ComposerHeaders, - ignoreServerCertificateErrors: _interception.IgnoreServerCertificateErrors); - - if (!result.Ok) - { - StatusText = "Composer failed: " + result.Message; - return; - } - - var snap = new SessionSnapshot - { - Id = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - Method = template.Method, - Url = ComposerUrl, - Host = TryHost(ComposerUrl), - StartedUtc = DateTimeOffset.UtcNow, - RequestHeadersText = ComposerHeaders, - RequestBodyText = ComposerBody, - StatusCode = result.StatusCode, - ResponseHeadersText = result.ResponseHeaders, - ResponseBodyText = result.ResponseBody, - ContentType = template.ContentType, - BodySize = result.ResponseBody?.Length, - Protocol = "Composer", - }; - _store.Add(snap); - ApplyFilter(); - RefreshSessionCountText(); - SelectedSession = snap; - StatusText = $"Composer → HTTP {result.StatusCode} (session #{snap.Id})"; - } - private static string? GuessContentType(string headers) - { - var map = SessionInspectors.ParseHeaderBlock(headers); - return map.TryGetValue("Content-Type", out var ct) ? ct : null; - } private static string? TryHost(string url) { @@ -2245,207 +2281,51 @@ private async Task SendComposerAsync() } } - private async Task ExportHarAsync() - { - if (_all.Count == 0) - { - StatusText = "No sessions to export"; - return; - } - var path = await _pathPicker.PickSavePathAsync("Export all HAR", "titanium-inspector.har", "HAR", "*.har"); - if (path is null) - { - StatusText = "Export HAR cancelled"; - return; - } - try - { - var sessions = _all.ToList(); - // Stay on the UI sync context (RelayCommand). ConfigureAwait(false) + StatusText update - // raced with headless WaitUntil pumps on macOS (file written, StatusText stayed Ready). - StatusText = "Exporting HAR…"; - await _store.EnsureBodiesLoadedAsync(sessions); - await SessionArchive.ExportHarAsync(sessions, path); - StatusText = $"Exported {sessions.Count} sessions to {path}"; - } - catch (Exception ex) - { - StatusText = "Export HAR failed: " + Truncate(ex.Message, 160); - } - } - private async Task ExportSelectedHarAsync() - { - var sessions = ResolveExportSelection(); - if (sessions.Count == 0) - { - StatusText = "Select a session to export"; - return; - } - - var path = await _pathPicker.PickSavePathAsync("Export selected HAR", "titanium-inspector.har", "HAR", "*.har"); - if (path is null) - { - StatusText = "Export HAR cancelled"; - return; - } - try - { - StatusText = "Exporting HAR…"; - await _store.EnsureBodiesLoadedAsync(sessions); - await SessionArchive.ExportHarAsync(sessions, path); - StatusText = $"Exported {sessions.Count} sessions to {path}"; - } - catch (Exception ex) - { - StatusText = "Export HAR failed: " + Truncate(ex.Message, 160); - } - } - - private async Task ImportHarAsync() - { - var path = await _pathPicker.PickOpenPathAsync("Import HAR", "HAR", "*.har", ZipFileFilter); - if (path is null) - { - StatusText = "No .har or archive to import"; - return; - } - - List imported; - if (path.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) - { - imported = await SessionArchive.ImportNativeArchiveAsync(path); - } - else - { - imported = await SessionArchive.ImportHarAsync(path); - } - foreach (var snap in imported) - { - _store.Add(snap); - } - ApplyFilter(); - RefreshSessionCountText(); - StatusText = $"Appended {imported.Count} sessions from {Path.GetFileName(path)}"; - } - private async Task ExportArchiveAsync() + private static string DescribePanel(object panel) { - if (_all.Count == 0) - { - StatusText = "No sessions to export"; - return; - } - - var path = await _pathPicker.PickSavePathAsync("Export all archive", "titanium-inspector.zip", "ZIP", ZipFileFilter); - if (path is null) - { - StatusText = "Export archive cancelled"; - return; - } - - try - { - var sessions = _all.ToList(); - // Stay on the UI sync context (RelayCommand). ConfigureAwait(false) + StatusText update - // raced with headless WaitUntil pumps on macOS (file written, StatusText stayed Ready). - StatusText = "Exporting archive…"; - await _store.EnsureBodiesLoadedAsync(sessions); - await SessionArchive.ExportNativeArchiveAsync(sessions, path); - StatusText = $"Exported {sessions.Count} sessions to {path}"; - } - catch (Exception ex) - { - StatusText = "Export archive failed: " + Truncate(ex.Message, 160); - } + var type = panel.GetType(); + var title = type.GetProperty("Title")?.GetValue(panel)?.ToString(); + var desc = type.GetProperty("Description")?.GetValue(panel)?.ToString(); + return string.IsNullOrEmpty(title) ? type.Name : $"{title}: {desc}"; } - private async Task ExportSelectedArchiveAsync() - { - var sessions = ResolveExportSelection(); - if (sessions.Count == 0) - { - StatusText = "Select a session to export"; - return; - } + private static string Truncate(string text, int max) + => text.Length <= max ? text : text[..max] + "…"; - var path = await _pathPicker.PickSavePathAsync("Export selected archive", "titanium-inspector.zip", "ZIP", ZipFileFilter); - if (path is null) - { - StatusText = "Export archive cancelled"; - return; - } + private RelayCommand Cmd(Func execute, Func? canExecute = null) => + new(execute, canExecute, ReportActionFailure); - try - { - StatusText = "Exporting archive…"; - await _store.EnsureBodiesLoadedAsync(sessions); - await SessionArchive.ExportNativeArchiveAsync(sessions, path); - StatusText = $"Exported {sessions.Count} sessions to {path}"; - } - catch (Exception ex) - { - StatusText = "Export archive failed: " + Truncate(ex.Message, 160); - } - } - - private async Task ImportArchiveAsync() + internal void ReportActionFailure(Exception ex) { - var path = await _pathPicker.PickOpenPathAsync("Import archive", "ZIP", ZipFileFilter); - if (path is null) + if (ex is OperationCanceledException) { - StatusText = "No titanium-inspector archive to import"; return; } - StatusText = "Importing archive…"; - try - { - // Stay on the UI sync context (RelayCommand). ConfigureAwait(false) + off-thread - // StatusText throws Avalonia "Call from invalid thread" on Windows CI, and - // nested MarshalToUiAsync StatusText updates flaked on macOS headless. - var imported = await SessionArchive.ImportNativeArchiveAsync(path); - foreach (var snap in imported) - { - _store.Add(snap); - } - - ApplyFilter(); - RefreshSessionCountText(); - StatusText = $"Appended {imported.Count} sessions from {Path.GetFileName(path)}"; - } - catch (Exception ex) - { - StatusText = "Import archive failed: " + Truncate(ex.Message, 160); - } + SetOutcomeStatus( + "Action failed: " + Truncate(ex.Message, 160), + StatusSeverity.Error, + toastImportant: true); } - private IReadOnlyList ResolveExportSelection() + private static string SystemProxyEnabledStatusMessage() { - if (_selectedSessions.Count > 0) - { - return _selectedSessions.ToList(); - } + if (OperatingSystem.IsWindows()) + return "System proxy enabled. For Chrome: disable QUIC (--disable-quic) or H3 may bypass the proxy."; - return SelectedSession is null ? Array.Empty() : [SelectedSession]; - } + if (OperatingSystem.IsMacOS()) + return "System proxy enabled. Restart Firefox if it was already open so it picks up the proxy."; - private static string DescribePanel(object panel) - { - var type = panel.GetType(); - var title = type.GetProperty("Title")?.GetValue(panel)?.ToString(); - var desc = type.GetProperty("Description")?.GetValue(panel)?.ToString(); - return string.IsNullOrEmpty(title) ? type.Name : $"{title}: {desc}"; + return "System proxy enabled"; } - private static string Truncate(string text, int max) - => text.Length <= max ? text : text[..max] + "…"; - private bool SetField(ref T field, T value, [CallerMemberName] string? name = null) { if (Equals(field, value)) @@ -2469,9 +2349,12 @@ private bool SetField(ref T field, T value, [CallerMemberName] string? name = } } -internal sealed class RelayCommand(Func execute) : ICommand + +internal sealed class RelayCommand(Func execute, Func? canExecute = null, Action? onError = null) : ICommand { - public bool CanExecute(object? parameter) => true; + public bool CanExecute(object? parameter) => canExecute?.Invoke() ?? true; + + public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); public async void Execute(object? parameter) { @@ -2482,13 +2365,17 @@ public async void Execute(object? parameter) // headless flakes where export wrote the file but StatusText stayed "Ready"). await execute(); } - catch + catch (Exception ex) { + if (ex is OperationCanceledException) + { + return; + } + // UI commands must not tear down the process (async void). + onError?.Invoke(ex); } } -#pragma warning disable CS0067 public event EventHandler? CanExecuteChanged; -#pragma warning restore CS0067 } diff --git a/src/Titanium.Inspector/ViewModels/MapRemoteViewModel.cs b/src/Titanium.Inspector/ViewModels/MapRemoteViewModel.cs new file mode 100644 index 000000000..c214bbd9b --- /dev/null +++ b/src/Titanium.Inspector/ViewModels/MapRemoteViewModel.cs @@ -0,0 +1,255 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using Titanium.Inspector.Services; + +namespace Titanium.Inspector.ViewModels; + +/// Map Remote rules — rewrite request URL before origin (after AutoResponder). +public sealed class MapRemoteViewModel : INotifyPropertyChanged +{ + private bool _enabled; + private MapRemoteRule? _selectedRule; + + public bool Enabled + { + get => _enabled; + set + { + if (_enabled == value) + { + return; + } + + _enabled = value; + PropertyChanged?.Invoke(this, new(nameof(Enabled))); + EnabledChanged?.Invoke(this, EventArgs.Empty); + } + } + + public ObservableCollection Rules { get; } = new(); + + public MapRemoteRule? SelectedRule + { + get => _selectedRule; + set + { + if (ReferenceEquals(_selectedRule, value)) + { + return; + } + + _selectedRule = value; + PropertyChanged?.Invoke(this, new(nameof(SelectedRule))); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + public event EventHandler? EnabledChanged; + public event EventHandler? RulesChanged; + + public void NotifyRulesChanged() => RulesChanged?.Invoke(this, EventArgs.Empty); + + public void LoadFromDtos(IEnumerable dtos) + { + Rules.Clear(); + foreach (var dto in dtos) + { + Rules.Add(new MapRemoteRule + { + MatchUrl = dto.MatchUrl, + TargetUrl = dto.TargetUrl, + Enabled = dto.Enabled, + GraphQlOperationName = dto.GraphQlOperationName ?? string.Empty, + }); + } + } + + public List ToDtos() => + Rules.Select(r => new MapRemoteRuleDto + { + MatchUrl = r.MatchUrl, + TargetUrl = r.TargetUrl, + Enabled = r.Enabled, + GraphQlOperationName = string.IsNullOrWhiteSpace(r.GraphQlOperationName) ? null : r.GraphQlOperationName, + }).ToList(); + + public bool TryRewrite(string url, string? requestBody, out string? rewritten, out MapRemoteRule? matched) + { + rewritten = null; + matched = null; + if (!Enabled) + { + return false; + } + + foreach (var rule in Rules) + { + if (!rule.Enabled || string.IsNullOrWhiteSpace(rule.TargetUrl)) + { + continue; + } + + if (!Matches(rule.MatchUrl, url)) + { + continue; + } + + if (!GraphQlOperationMatcher.MatchesOperation(requestBody, rule.GraphQlOperationName)) + { + continue; + } + + if (!TryApplyRewrite(url, rule.MatchUrl, rule.TargetUrl, out rewritten)) + { + continue; + } + + matched = rule; + return true; + } + + return false; + } + + public bool TryRewrite(string url, out string? rewritten, out MapRemoteRule? matched) + => TryRewrite(url, requestBody: null, out rewritten, out matched); + + /// + /// Rewrites using wildcard capture from + /// into . A single trailing * in both match and target + /// preserves the matched suffix (path/query). Otherwise the target absolute URL replaces the request. + /// + public static bool TryApplyRewrite(string url, string matchPattern, string targetTemplate, out string? rewritten) // NOSONAR S3776 -- Wildcard rewrite keeps match/target suffix handling together. + { + rewritten = null; + if (string.IsNullOrWhiteSpace(targetTemplate)) + { + return false; + } + + var target = targetTemplate.Trim(); + if (!Uri.TryCreate(target.Contains('*') ? target.Replace("*", "x", StringComparison.Ordinal) : target, + UriKind.Absolute, out _) && !target.Contains('*')) + { + // Allow templates with *; validate non-wildcard targets are absolute. + return false; + } + + if (string.IsNullOrEmpty(matchPattern) || matchPattern == "*") + { + if (target.Contains('*')) + { + rewritten = target.Replace("*", url, StringComparison.Ordinal); + } + else + { + rewritten = target; + } + + return Uri.TryCreate(rewritten, UriKind.Absolute, out _); + } + + var star = matchPattern.IndexOf('*'); + if (star >= 0 && matchPattern.IndexOf('*', star + 1) < 0) + { + var prefix = matchPattern[..star]; + var suffix = matchPattern[(star + 1)..]; + if (url.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && + url.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) && + url.Length >= prefix.Length + suffix.Length) + { + var captured = url.Substring(prefix.Length, url.Length - prefix.Length - suffix.Length); + if (target.Contains('*')) + { + rewritten = target.Replace("*", captured, StringComparison.Ordinal); + } + else + { + rewritten = target; + } + + return Uri.TryCreate(rewritten, UriKind.Absolute, out _); + } + } + + // Full-string wildcard regex match without capture → replace with literal target (no *). + if (!target.Contains('*') && Matches(matchPattern, url)) + { + rewritten = target; + return Uri.TryCreate(rewritten, UriKind.Absolute, out _); + } + + return false; + } + + private static bool Matches(string filter, string url) + { + if (string.IsNullOrEmpty(filter) || filter == "*") + { + return true; + } + + var pattern = "^" + Regex.Escape(filter).Replace("\\*", ".*") + "$"; + return Regex.IsMatch(url, pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1)); + } +} + +public sealed class MapRemoteRule : INotifyPropertyChanged +{ + private string _matchUrl = "*"; + private string _targetUrl = "http://127.0.0.1/"; // NOSONAR S1075 -- Default Map Remote target is loopback. + private string _graphQlOperationName = string.Empty; + private bool _enabled = true; + + public string MatchUrl + { + get => _matchUrl; + set => SetField(ref _matchUrl, value); + } + + public string TargetUrl + { + get => _targetUrl; + set => SetField(ref _targetUrl, value); + } + + public string GraphQlOperationName + { + get => _graphQlOperationName; + set => SetField(ref _graphQlOperationName, value ?? string.Empty); + } + + public bool Enabled + { + get => _enabled; + set => SetField(ref _enabled, value); + } + + public string Display + { + get + { + var gql = string.IsNullOrWhiteSpace(GraphQlOperationName) ? string.Empty : $" gql:{GraphQlOperationName}"; + return $"{(Enabled ? "✓" : "✗")} {MatchUrl}{gql} → {TargetUrl}"; + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + private void SetField(ref T field, T value, [CallerMemberName] string? name = null) + { + if (Equals(field, value)) + { + return; + } + + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); + if (name is not nameof(Display)) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Display))); + } + } +} diff --git a/src/Titanium.Inspector/Views/AboutWindow.axaml b/src/Titanium.Inspector/Views/AboutWindow.axaml new file mode 100644 index 000000000..c1b42ce75 --- /dev/null +++ b/src/Titanium.Inspector/Views/AboutWindow.axaml @@ -0,0 +1,34 @@ + + + + public static bool SuppressInteractiveRootStoreMutations { get; set; } + /// + /// True when Root-store Add/Remove should be skipped to avoid modal CryptUI prompts + /// (static flag, CI env, or TITANIUM_SKIP_ROOT_STORE_UI=1). + /// + public static bool AreInteractiveRootStoreMutationsSuppressed => + ShouldSuppressInteractiveRootStoreMutations; + /// /// True when Root-store Add/Remove should be skipped to avoid modal CryptUI prompts. /// @@ -633,6 +640,12 @@ private static X509Certificate2Collection FindCertificates(StoreName storeName, x509Store.Open(OpenFlags.OpenExistingOnly); return x509Store.Certificates.Find(X509FindType.FindByThumbprint, thumbprint, false); } + catch (CryptographicException) + { + // Fresh Linux images often lack ~/.dotnet/corefx/cryptography/x509stores/{root,my}. + // Treat a missing store as empty so InstallCertificate can create it via ReadWrite. + return []; + } finally { x509Store.Close(); @@ -715,8 +728,10 @@ private void RemoveMatchingCertificates( } catch (Exception e) { + // macOS often denies opening CurrentUser\Root for write; Keychain untrust still runs. OnException(new Exception( - $"Failed to open {storeName}\\{storeLocation} for same-CN root cleanup.", e)); + $"Failed to open {storeName}\\{storeLocation} for same-CN root cleanup " + + "(continuing with OS trust helpers).", e)); } } @@ -1362,15 +1377,18 @@ public bool LoadRootCertificate(string pfxFilePath, string password, bool overwr return RootCertificate != null; } + /// Last OS/browser trust outcome from / related helpers. + public CertificateOsTrustResult? LastOsTrustResult { get; private set; } + /// /// Trusts the root certificate in the current-user Personal and Trusted Root stores, /// and optionally also in the local-machine Personal and Trusted Root stores. /// /// - /// When , also install into the local-machine stores. Defaults to - /// — user-only trust is the recommended default for interactive - /// apps; machine trust needs elevation (or a privileged service account) and otherwise - /// fails silently. + /// When , also install machine-wide trust (LocalMachine on Windows; + /// System.keychain / system CA store on macOS/Linux, with an admin prompt). Defaults to + /// — user-only trust is the recommended default. Check + /// and after calling. /// public void TrustRootCertificate(bool machineTrusted = false) { @@ -1395,7 +1413,104 @@ public void TrustRootCertificate(bool machineTrusted = false) // On macOS/Linux, also trust for SSL in Keychain / NSS so browsers accept MITM. if (!RunTime.IsWindows && RootCertificate != null) - Helpers.UnixCertificateTrust.TrustUserSsl(RootCertificate, RootCertificateName); + { + // Unit/CI: never open Keychain auth, polkit, or NSS package install dialogs. + if (ShouldSuppressInteractiveRootStoreMutations) + { + LastOsTrustResult = CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Cancelled, + "OS SSL trust skipped (interactive root-store UI suppressed)"); + return; + } + + LastOsTrustResult = Helpers.UnixCertificateTrust.TrustUserSsl(RootCertificate, RootCertificateName); + if (!machineTrusted) + return; + + // machineTrusted: elevate into System.keychain / system CA store (admin prompt). + var machineOk = Helpers.UnixCertificateTrust.TrustMachineSsl(RootCertificate, RootCertificateName); + if (!machineOk) + { + LastOsTrustResult = CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, + "Machine-wide CA trust failed (user trust may already be applied)"); + } + else if (LastOsTrustResult.Succeeded || + LastOsTrustResult.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm) + { + LastOsTrustResult = CertificateOsTrustResult.Ok("Root CA trusted machine-wide"); + } + + return; + } + + LastOsTrustResult = CertificateOsTrustResult.Ok("Root CA trusted in current-user store"); + } + + /// + /// Installs NSS certutil (Linux package or macOS Homebrew) after user consent, then retries user SSL trust. + /// + public CertificateOsTrustResult InstallNssCertutilAndRetryUserTrust() + { + if (ShouldSuppressInteractiveRootStoreMutations) + { + LastOsTrustResult = CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Cancelled, + "NSS certutil install skipped (interactive root-store UI suppressed)"); + return LastOsTrustResult; + } + + var install = Helpers.UnixCertificateTrust.TryInstallNssCertutil(); + if (!install.Succeeded) + { + LastOsTrustResult = install; + return install; + } + + if (RootCertificate == null) + { + LastOsTrustResult = CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, "Root certificate is not loaded"); + return LastOsTrustResult; + } + + LastOsTrustResult = Helpers.UnixCertificateTrust.TrustUserSsl(RootCertificate, RootCertificateName); + return LastOsTrustResult; + } + + /// Re-checks macOS/Linux user SSL trust for the current root. + public bool VerifyOsUserSslTrust() + { + if (RootCertificate == null || RunTime.IsWindows) return IsRootCertificateUserTrusted(); + return Helpers.UnixCertificateTrust.VerifyUserSslTrust(RootCertificate); + } + + /// + /// Best-effort: true when the current root appears in the macOS login keychain. + /// Does not imply SSL Always Trust — use . + /// + public bool IsRootInLoginKeychain() + { + if (RootCertificate == null || !RunTime.IsMac) return false; + return Helpers.UnixCertificateTrust.IsCertificateInLoginKeychain(RootCertificate); + } + + /// + /// True when a Titanium root (current hash or known CN) remains in login or System keychain. + /// + public bool IsOsRootStillPresent() + { + if (RootCertificate == null || !RunTime.IsMac) return false; + return Helpers.UnixCertificateTrust.IsMacRootStillPresent( + new Helpers.ProcessRunner(), RootCertificate, RootCertificateName); + } + + /// Opens Keychain Access (and a temp .cer) for manual Always Trust on macOS. + public string? OpenMacKeychainGuidance() + { + if (RootCertificate == null || !RunTime.IsMac) return null; + if (ShouldSuppressInteractiveRootStoreMutations) return null; + return Helpers.UnixCertificateTrust.OpenMacKeychainGuidanceForCertificate(RootCertificate); } /// @@ -1407,7 +1522,7 @@ public void TrustRootCertificate(bool machineTrusted = false) /// (user store only). /// /// True if success. - public bool TrustRootCertificateAsAdmin(bool machineTrusted = false) + public bool TrustRootCertificateAsAdmin(bool machineTrusted = false) // NOSONAR S3776 -- User/machine store install shares Root-store mutation order. { var certificate = RootCertificate; if (certificate == null) return false; @@ -1418,18 +1533,41 @@ public bool TrustRootCertificateAsAdmin(bool machineTrusted = false) if (rootAdded) RemoveOrphanedSameCommonNameCertificates(StoreLocation.CurrentUser, keepCurrentThumbprint: true); + // UAC / Keychain auth / polkit — never in unit/CI (hangs unattended runs). + if (ShouldSuppressInteractiveRootStoreMutations) + { + LastOsTrustResult = CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Cancelled, + "Elevated root trust skipped (interactive root-store UI suppressed)"); + return false; + } + if (!RunTime.IsWindows) { - Helpers.UnixCertificateTrust.TrustUserSsl(certificate, RootCertificateName); + LastOsTrustResult = Helpers.UnixCertificateTrust.TrustUserSsl(certificate, RootCertificateName); // Explicit true when only user-store trust was requested (no machine step). - return machineTrusted - ? Helpers.UnixCertificateTrust.TrustMachineSsl(certificate, RootCertificateName) - : true; // NOSONAR S1125 - } + if (!machineTrusted) + return LastOsTrustResult.Succeeded || + LastOsTrustResult.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm; - // Elevated certutil shows UAC; skip in CI / test processes that suppress Root UI. - if (ShouldSuppressInteractiveRootStoreMutations) - return false; + var machineOk = Helpers.UnixCertificateTrust.TrustMachineSsl(certificate, RootCertificateName); + if (!machineOk) + { + // User trust may already be applied; surface machine failure clearly. + LastOsTrustResult = CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, + "Machine-wide CA trust failed (user trust may already be applied)"); + return false; + } + + if (LastOsTrustResult.Succeeded || + LastOsTrustResult.Kind == CertificateOsTrustKind.MacNeedsManualTrustConfirm) + { + LastOsTrustResult = CertificateOsTrustResult.Ok("Root CA trusted machine-wide"); + } + + return true; + } // certutil.exe only accepts the PFX password via a plain "-p password" command-line argument - // it has no file/stdin-based alternative (confirmed: no documented option to read it from a @@ -1553,8 +1691,14 @@ public void RemoveTrustedRootCertificate(bool machineTrusted = false) if (machineTrusted) RemoveOrphanedSameCommonNameCertificates(StoreLocation.LocalMachine, keepCurrentThumbprint: false); - if (!RunTime.IsWindows && RootCertificate != null) + if (!RunTime.IsWindows && RootCertificate != null && + !ShouldSuppressInteractiveRootStoreMutations) Helpers.UnixCertificateTrust.UntrustUserSsl(RootCertificate, RootCertificateName); + + // Best-effort Firefox cleanup (policy + default profile nickname). + FirefoxCertificateTrust.TryClearWindowsEnterpriseRoots(); + if (RootCertificate != null) + FirefoxCertificateTrust.UntrustDefaultProfile(RootCertificateName); } /// @@ -1569,6 +1713,9 @@ public bool RemoveTrustedRootCertificateAsAdmin(bool machineTrusted = false) if (!RunTime.IsWindows) { if (RootCertificate == null) return false; + FirefoxCertificateTrust.UntrustDefaultProfile(RootCertificateName); + if (ShouldSuppressInteractiveRootStoreMutations) + return true; Helpers.UnixCertificateTrust.UntrustUserSsl(RootCertificate, RootCertificateName); // Explicit true when only user-store untrust was requested (no machine step). return machineTrusted @@ -1576,6 +1723,9 @@ public bool RemoveTrustedRootCertificateAsAdmin(bool machineTrusted = false) : true; // NOSONAR S1125 } + FirefoxCertificateTrust.TryClearWindowsEnterpriseRoots(); + FirefoxCertificateTrust.UntrustDefaultProfile(RootCertificateName); + // Elevated certutil -delstore shows UAC; skip when Root UI is suppressed. if (ShouldSuppressInteractiveRootStoreMutations) return true; diff --git a/src/Titanium.Web.Proxy/Certificates/CertificateOsTrustResult.cs b/src/Titanium.Web.Proxy/Certificates/CertificateOsTrustResult.cs new file mode 100644 index 000000000..1f5edad46 --- /dev/null +++ b/src/Titanium.Web.Proxy/Certificates/CertificateOsTrustResult.cs @@ -0,0 +1,47 @@ +namespace Titanium.Web.Proxy.Network; + +/// Outcome kind for OS / browser SSL trust helpers (Keychain, NSS, package install). +public enum CertificateOsTrustKind +{ + Succeeded = 0, + CertutilMissing = 1, + NssFailed = 2, + MacKeychainFailed = 3, + MacNeedsManualTrustConfirm = 4, + HomebrewMissing = 5, + Unsupported = 6, + Cancelled = 7, + Failed = 8, +} + +/// Structured result from Unix OS trust or related helper operations. +public sealed class CertificateOsTrustResult +{ + public CertificateOsTrustResult( + CertificateOsTrustKind kind, + string message, + string? packageHint = null, + bool brewAvailable = false) + { + Kind = kind; + Message = message ?? string.Empty; + PackageHint = packageHint; + BrewAvailable = brewAvailable; + } + + public CertificateOsTrustKind Kind { get; } + public string Message { get; } + public string? PackageHint { get; } + public bool BrewAvailable { get; } + public bool Succeeded => Kind == CertificateOsTrustKind.Succeeded; + + public static CertificateOsTrustResult Ok(string message = "Trusted") => + new(CertificateOsTrustKind.Succeeded, message); + + public static CertificateOsTrustResult Fail( + CertificateOsTrustKind kind, + string message, + string? packageHint = null, + bool brewAvailable = false) => + new(kind, message, packageHint, brewAvailable); +} diff --git a/src/Titanium.Web.Proxy/Certificates/FirefoxCertificateTrust.cs b/src/Titanium.Web.Proxy/Certificates/FirefoxCertificateTrust.cs new file mode 100644 index 000000000..f78e49ba7 --- /dev/null +++ b/src/Titanium.Web.Proxy/Certificates/FirefoxCertificateTrust.cs @@ -0,0 +1,858 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Threading; +using Titanium.Web.Proxy.Helpers; + +namespace Titanium.Web.Proxy.Network; + +/// +/// Firefox-specific CA trust: Windows ImportEnterpriseRoots policy / policies.json, +/// profile user.js (and prefs.js when Firefox is not running) so Firefox +/// uses OS roots, plus optional NSS import into the default profile (cert9.db). +/// Does not write into the Firefox.app bundle (that would invalidate the code signature). +/// +public static class FirefoxCertificateTrust +{ + private const string WindowsPolicySubKey = @"Software\Policies\Mozilla\Firefox\Certificates"; + private const string ImportEnterpriseRootsValue = "ImportEnterpriseRoots"; + private const string EnterpriseRootsPrefName = "security.enterprise_roots.enabled"; + private const string FirefoxProcessName = "firefox"; + private const string MozillaDirName = ".mozilla"; + private const string DistributionDirName = "distribution"; + private const string PoliciesJsonFileName = "policies.json"; + private const string LibraryDirName = "Library"; + private const string ApplicationSupportDirName = "Application Support"; + private const string FirefoxDirName = "Firefox"; + private static readonly Regex EnterpriseRootsUserPrefLine = new( + @"^\s*user_pref\s*\(\s*""" + Regex.Escape(EnterpriseRootsPrefName) + @"""\s*,\s*(true|false)\s*\)\s*;\s*$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, + TimeSpan.FromMilliseconds(250)); + + /// + /// Mozilla enterprise policies.json root shape. Extra keys are preserved on merge so we + /// do not wipe IT-managed policies when only ImportEnterpriseRoots is needed. + /// + internal const int FirefoxPoliciesSchemaCompat = 1; + + /// True when a Firefox profiles.ini (or common profile root) is present. + public static bool IsFirefoxProfilePresent() => TryGetProfilesIniPath(out _); + + /// + /// Windows: enable OS-root trust for Firefox via HKCU policy when allowed, + /// otherwise set security.enterprise_roots.enabled in the default profile user.js. + /// Also best-effort writes/merges Mozilla policies.json on all OSes. + /// + public static CertificateOsTrustResult TryEnableWindowsEnterpriseRoots() + { + // Cross-platform policies.json is best-effort; HKCU / user.js remain authoritative on Windows. + var policiesWritten = TryWriteOrMergeFirefoxPoliciesJson(importEnterpriseRoots: true); + + if (!OperatingSystem.IsWindows()) + { + if (policiesWritten) + { + return CertificateOsTrustResult.Ok( + "Firefox policies.json updated (" + ImportEnterpriseRootsValue + "); restart Firefox to apply"); + } + + // macOS/Linux: user.js is the supported way to enable OS-root trust without + // modifying the application bundle (which would break code signing). + return TryEnableEnterpriseRootsUserPref(); + } + + if (TryWriteWindowsImportEnterpriseRootsPolicy()) + { + return CertificateOsTrustResult.Ok( + "Firefox will trust the Windows root CA after you restart Firefox"); + } + + if (!TryResolveDefaultProfileDirectory(out var profileDir, out var resolveError)) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, + "Could not set Firefox " + ImportEnterpriseRootsValue + " policy and " + + (resolveError ?? "no Firefox profile was found")); + } + + return TryWriteEnterpriseRootsUserPref( + profileDir, + "Firefox will trust the Windows root CA after you restart Firefox (profile preference)"); + } + + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private static bool TryWriteWindowsImportEnterpriseRootsPolicy() + { + try + { + using var key = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(WindowsPolicySubKey, true); + if (key is null) + return false; + + key.SetValue(ImportEnterpriseRootsValue, 1, Microsoft.Win32.RegistryValueKind.DWord); + var verify = key.GetValue(ImportEnterpriseRootsValue); + return verify switch + { + int i => i == 1, + long l => l == 1, + null => false, + _ => Convert.ToInt32(verify) == 1, + }; + } + catch + { + return false; + } + } + + private static CertificateOsTrustResult TryWriteEnterpriseRootsUserPref(string profileDir, string successMessage) + { + try + { + EnsureEnterpriseRootsUserPref(profileDir); + if (!VerifyEnterpriseRootsUserPref(profileDir)) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, + "Wrote Firefox user.js but security.enterprise_roots.enabled did not validate"); + } + + return CertificateOsTrustResult.Ok(successMessage); + } + catch (Exception ex) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, + "Failed to enable Firefox OS-root trust: " + ex.Message); + } + } + + /// Clears the HKCU ImportEnterpriseRoots value and profile user.js pref we may have set. + public static bool TryClearWindowsEnterpriseRoots() + { + var cleared = TryClearFirefoxPoliciesJsonImportEnterpriseRoots(); + + if (OperatingSystem.IsWindows()) + { + try + { + using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(WindowsPolicySubKey, writable: true); + if (key is not null) + { + key.DeleteValue(ImportEnterpriseRootsValue, throwOnMissingValue: false); + cleared = true; + } + } + catch + { + // ignore + } + } + + if (TryResolveDefaultProfileDirectory(out var profileDir, out _)) + { + try + { + cleared = ClearEnterpriseRootsUserPref(profileDir) || cleared; + } + catch + { + // ignore + } + } + + return cleared; + } + + /// + /// Enables security.enterprise_roots.enabled in the default Firefox profile + /// (user.js; also prefs.js when Firefox is not running) so Firefox trusts + /// OS roots (Windows store / macOS Keychain / Linux system CAs). + /// + public static CertificateOsTrustResult TryEnableEnterpriseRootsUserPref() + { + if (!TryResolveDefaultProfileDirectory(out var profileDir, out var resolveError)) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, + resolveError ?? "Firefox profile not found"); + } + + try + { + EnsureEnterpriseRootsUserPref(profileDir); + if (!IsFirefoxProcessRunning()) + EnsureEnterpriseRootsPrefFile(Path.Combine(profileDir, "prefs.js")); + + if (!VerifyEnterpriseRootsUserPref(profileDir)) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, + "Wrote Firefox user.js but security.enterprise_roots.enabled did not validate"); + } + + return CertificateOsTrustResult.Ok( + "Firefox will trust the OS root CA after you restart Firefox (profile preference)"); + } + catch (Exception ex) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, + "Failed to enable Firefox OS-root trust: " + ex.Message); + } + } + + /// + /// Builds Mozilla enterprise policies.json content enabling ImportEnterpriseRoots, + /// merging into when present so IT policies are preserved. + /// + internal static string BuildOrMergeFirefoxPoliciesJson(string? existingJson, bool importEnterpriseRoots) + { + JsonObject root; + try + { + root = ParsePoliciesRoot(existingJson); + } + catch (JsonException) + { + // Corrupt / non-object existing file: start fresh rather than abort CA trust. + root = new JsonObject(); + } + + if (root["policies"] is not JsonObject policies) + { + policies = new JsonObject(); + root["policies"] = policies; + } + + if (policies["Certificates"] is not JsonObject certificates) + { + certificates = new JsonObject(); + policies["Certificates"] = certificates; + } + + if (importEnterpriseRoots) + certificates[ImportEnterpriseRootsValue] = true; + else + certificates.Remove(ImportEnterpriseRootsValue); + + return root.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) + "\n"; + } + + /// + /// True when JSON is a Mozilla policies document with Certificates.ImportEnterpriseRoots=true. + /// Extra top-level/policy keys are allowed (forward compatible). + /// + internal static bool TryValidateFirefoxPoliciesJson(string json, out string? error) + { + error = null; + try + { + using var doc = JsonDocument.Parse(json); + return TryValidateFirefoxPoliciesDocument(doc.RootElement, out error); + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + /// Best-effort write/merge of Firefox policies.json into known OS locations. + internal static bool TryWriteOrMergeFirefoxPoliciesJson(bool importEnterpriseRoots) + { + var any = false; + foreach (var path in GetFirefoxPoliciesJsonPaths()) + { + try + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + var existing = File.Exists(path) ? File.ReadAllText(path) : null; + var json = BuildOrMergeFirefoxPoliciesJson(existing, importEnterpriseRoots); + if (importEnterpriseRoots && !TryValidateFirefoxPoliciesJson(json, out _)) + continue; + + var temp = path + ".tmp"; + File.WriteAllText(temp, json); + File.Move(temp, path, overwrite: true); + + if (!importEnterpriseRoots) + { + any = true; + continue; + } + + var onDisk = File.ReadAllText(path); + if (TryValidateFirefoxPoliciesJson(onDisk, out _)) + any = true; + } + catch + { + // /etc and Program Files often need elevation — ignore. + } + } + + return any; + } + + private static bool TryClearFirefoxPoliciesJsonImportEnterpriseRoots() + { + var cleared = false; + foreach (var path in GetFirefoxPoliciesJsonPaths()) + { + try + { + if (!File.Exists(path)) + continue; + var existing = File.ReadAllText(path); + if (!existing.Contains(ImportEnterpriseRootsValue, StringComparison.Ordinal)) + continue; + var json = BuildOrMergeFirefoxPoliciesJson(existing, importEnterpriseRoots: false); + File.WriteAllText(path, json); + cleared = true; + } + catch + { + // ignore + } + } + + return cleared; + } + + /// Known Mozilla policies.json locations (system + user-writable fallbacks). + internal static IEnumerable GetFirefoxPoliciesJsonPaths() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + if (OperatingSystem.IsWindows()) + { + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + yield return Path.Combine(programFiles, "Mozilla Firefox", DistributionDirName, PoliciesJsonFileName); + if (!string.IsNullOrEmpty(programFilesX86)) + yield return Path.Combine(programFilesX86, "Mozilla Firefox", DistributionDirName, PoliciesJsonFileName); + // User-level distribution next to the profile root (portable / some enterprise layouts). + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + yield return Path.Combine(appData, "Mozilla", FirefoxDirName, DistributionDirName, PoliciesJsonFileName); + yield break; + } + + if (OperatingSystem.IsMacOS()) + { + // Never write Firefox.app/.../distribution — that invalidates the code signature. + yield return Path.Combine(home, LibraryDirName, ApplicationSupportDirName, FirefoxDirName, DistributionDirName, + PoliciesJsonFileName); + yield return Path.Combine(home, LibraryDirName, ApplicationSupportDirName, "FirefoxDeveloperEdition", + DistributionDirName, PoliciesJsonFileName); + yield return Path.Combine(home, LibraryDirName, ApplicationSupportDirName, "Firefox Nightly", DistributionDirName, + PoliciesJsonFileName); + yield break; + } + + if (OperatingSystem.IsLinux()) + { + foreach (var path in GetLinuxFirefoxPoliciesJsonPaths(home)) + yield return path; + } + } + + private static JsonObject ParsePoliciesRoot(string? existingJson) + { + if (string.IsNullOrWhiteSpace(existingJson)) + return new JsonObject(); + + return JsonNode.Parse(existingJson) as JsonObject ?? new JsonObject(); + } + + private static bool TryValidateFirefoxPoliciesDocument(JsonElement root, out string? error) + { + error = null; + if (root.ValueKind != JsonValueKind.Object) + { + error = "policies.json root must be an object"; + return false; + } + + if (!root.TryGetProperty("policies", out var policies) || + policies.ValueKind != JsonValueKind.Object) + { + error = "missing policies object"; + return false; + } + + if (!policies.TryGetProperty("Certificates", out var certs) || + certs.ValueKind != JsonValueKind.Object) + { + error = "missing policies.Certificates"; + return false; + } + + if (!certs.TryGetProperty(ImportEnterpriseRootsValue, out var flag) || + flag.ValueKind != JsonValueKind.True) + { + error = ImportEnterpriseRootsValue + " is not true"; + return false; + } + + return true; + } + + private static IEnumerable GetLinuxFirefoxPoliciesJsonPaths(string home) + { + yield return "/etc/firefox/policies/" + PoliciesJsonFileName; + yield return Path.Combine("/usr/lib/firefox", DistributionDirName, PoliciesJsonFileName); + yield return Path.Combine("/usr/lib64/firefox", DistributionDirName, PoliciesJsonFileName); + yield return Path.Combine(home, MozillaDirName, FirefoxProcessName, DistributionDirName, PoliciesJsonFileName); + yield return Path.Combine(home, "snap", FirefoxProcessName, "common", MozillaDirName, FirefoxProcessName, + DistributionDirName, PoliciesJsonFileName); + yield return Path.Combine(home, ".var", "app", "org.mozilla.firefox", MozillaDirName, FirefoxProcessName, + DistributionDirName, PoliciesJsonFileName); + } + + internal static void EnsureEnterpriseRootsUserPref(string profileDirectory) => + EnsureEnterpriseRootsPrefFile(Path.Combine(profileDirectory, "user.js")); + + internal static void EnsureEnterpriseRootsPrefFile(string prefFile) + { + const string prefLine = "user_pref(\"" + EnterpriseRootsPrefName + "\", true);"; + if (File.Exists(prefFile)) + { + var text = File.ReadAllText(prefFile); + var lines = text.Split(['\r', '\n'], StringSplitOptions.None); + var found = false; + for (var i = 0; i < lines.Length; i++) + { + // Only rewrite real user_pref lines — never comments or lockPref. + if (!EnterpriseRootsUserPrefLine.IsMatch(lines[i])) + continue; + lines[i] = prefLine; + found = true; + } + + if (found) + { + File.WriteAllText(prefFile, string.Join(Environment.NewLine, lines)); + return; + } + + File.AppendAllText(prefFile, Environment.NewLine + prefLine + Environment.NewLine); + return; + } + + File.WriteAllText(prefFile, prefLine + Environment.NewLine); + } + + internal static bool VerifyEnterpriseRootsUserPref(string profileDirectory) + { + var userJs = Path.Combine(profileDirectory, "user.js"); + if (!File.Exists(userJs)) return false; + foreach (var line in File.ReadLines(userJs)) + { + var m = EnterpriseRootsUserPrefLine.Match(line); + if (m.Success && m.Groups[1].Value.Equals("true", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + private static bool ClearEnterpriseRootsUserPref(string profileDirectory) + { + var cleared = ClearEnterpriseRootsPrefFile(Path.Combine(profileDirectory, "user.js")); + cleared = ClearEnterpriseRootsPrefFile(Path.Combine(profileDirectory, "prefs.js")) || cleared; + return cleared; + } + + private static bool ClearEnterpriseRootsPrefFile(string prefFile) + { + if (!File.Exists(prefFile)) return false; + var lines = File.ReadAllLines(prefFile) + .Where(l => !EnterpriseRootsUserPrefLine.IsMatch(l)) + .ToArray(); + File.WriteAllLines(prefFile, lines); + return true; + } + + /// + /// Imports the CA into the default Firefox profile NSS DB via certutil. + /// Caller should ensure Firefox is not locking the DB. + /// + public static CertificateOsTrustResult TrustDefaultProfile( + X509Certificate2 certificate, + string friendlyName) => + TrustDefaultProfile(certificate, friendlyName, new ProcessRunner()); + + /// Best-effort removal of the CA nickname from the default Firefox profile. + public static bool UntrustDefaultProfile(string friendlyName) => + UntrustDefaultProfile(friendlyName, new ProcessRunner()); + + internal static CertificateOsTrustResult TrustDefaultProfile( + X509Certificate2 certificate, + string friendlyName, + IProcessRunner runner) + { + if (!TryResolveDefaultProfileDirectory(out var profileDir, out var resolveError)) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, + resolveError ?? "Firefox profile not found"); + } + + var certutil = UnixCertificateTrust.FindCertutil(runner); + if (certutil is null) + return UnixCertificateTrust.ProbeCertutilInstall(runner); + + if (IsFirefoxProcessRunning()) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, + "Firefox appears to be running. Quit Firefox, then retry Trust CA in Firefox."); + } + + var cerPath = UnixCertificateTrust.WriteTempCer(certificate); + try + { + // Delete existing nickname first (ignore failure), then add as trusted CA. + runner.Run(certutil, $"-d sql:\"{profileDir}\" -D -n \"{Escape(friendlyName)}\""); + var add = runner.Run(certutil, + $"-d sql:\"{profileDir}\" -A -t \"C,,\" -n \"{Escape(friendlyName)}\" -i \"{cerPath}\""); + if (add is not { Succeeded: true }) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.NssFailed, + string.IsNullOrWhiteSpace(add?.StandardError) + ? "certutil failed to import the CA into the Firefox profile" + : add.StandardError.Trim()); + } + + // certutil -A can exit 0 as a silent no-op when the DER exists under another nickname. + var list = runner.Run(certutil, $"-d sql:\"{profileDir}\" -L"); + if (list is { Succeeded: true } && + list.StandardOutput.Contains(friendlyName, StringComparison.OrdinalIgnoreCase)) + { + return CertificateOsTrustResult.Ok( + "Firefox will use the Titanium root CA after you restart Firefox"); + } + + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.NssFailed, + "certutil reported success but the CA nickname is missing from the Firefox NSS database"); + } + catch (Exception ex) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.NssFailed, + "Firefox NSS import failed: " + ex.Message); + } + finally + { + try { File.Delete(cerPath); } catch { /* best effort */ } + } + } + + internal static bool UntrustDefaultProfile(string friendlyName, IProcessRunner runner) + { + if (!TryResolveDefaultProfileDirectory(out var profileDir, out _)) + return false; + + var certutil = UnixCertificateTrust.FindCertutil(runner); + if (certutil is null) return false; + + try + { + var result = runner.Run(certutil, $"-d sql:\"{profileDir}\" -D -n \"{Escape(friendlyName)}\""); + return result is { Succeeded: true }; + } + catch + { + return false; + } + } + + /// True when a firefox process is running (best-effort). + public static bool IsFirefoxProcessRunning() + { + using var process = EnumerateFirefoxProcesses().FirstOrDefault(); + return process is not null; + } + + /// + /// Asks Firefox to quit gracefully (user already consented). Waits briefly for exit. + /// Does not force-kill; returns false if Firefox is still running after the wait. + /// + public static bool TryRequestFirefoxQuit(TimeSpan? waitForExit = null) => + TryRequestFirefoxQuit(waitForExit, new ProcessRunner()); + + /// + /// Asks Firefox to quit gracefully (user already consented). Waits briefly for exit. + /// Does not force-kill; returns false if Firefox is still running after the wait. + /// + internal static bool TryRequestFirefoxQuit(TimeSpan? waitForExit, IProcessRunner runner) + { + var wait = waitForExit ?? TimeSpan.FromSeconds(8); + + if (!IsFirefoxProcessRunning()) + return true; + + if (OperatingSystem.IsWindows()) + { + foreach (var process in EnumerateFirefoxProcesses()) + { + try + { + process.CloseMainWindow(); + } + catch + { + // ignore per-process failures; wait loop decides success + } + finally + { + process.Dispose(); + } + } + } + else if (OperatingSystem.IsMacOS()) + { + // Consent already given in UI — quit the app, not Keychain UI automation. + // osascript may be blocked by TCC; fall back to SIGTERM (never SIGKILL). + runner.Run("osascript", "-e 'tell application \"Firefox\" to quit'"); + if (IsFirefoxProcessRunning()) + TermFirefoxProcesses(runner); + } + else if (OperatingSystem.IsLinux()) + { + TermFirefoxProcesses(runner); + } + + var deadline = DateTime.UtcNow + wait; + while (DateTime.UtcNow < deadline) + { + if (!IsFirefoxProcessRunning()) + return true; + Thread.Sleep(200); + } + + return !IsFirefoxProcessRunning(); + } + + private static void TermFirefoxProcesses(IProcessRunner runner) + { + foreach (var process in EnumerateFirefoxProcesses()) + { + try + { + runner.Run("kill", $"-TERM {process.Id}"); + } + catch + { + // ignore per-process failures; wait loop decides success + } + finally + { + process.Dispose(); + } + } + } + + private static IEnumerable EnumerateFirefoxProcesses() + { + Process[] processes; + try + { + processes = Process.GetProcesses(); + } + catch + { + yield break; + } + + foreach (var process in processes) + { + var match = false; + try + { + var name = process.ProcessName; + match = name.Equals(FirefoxProcessName, StringComparison.OrdinalIgnoreCase) + || name.Equals(FirefoxProcessName + "-bin", StringComparison.OrdinalIgnoreCase); + } + catch + { + match = false; + } + + if (match) + yield return process; + else + { + try { process.Dispose(); } catch { /* ignore */ } + } + } + } + + /// Resolves the default Firefox profile directory from profiles.ini. + public static bool TryResolveDefaultProfileDirectory(out string profileDirectory, out string? error) + { + profileDirectory = string.Empty; + error = null; + + if (!TryGetProfilesIniPath(out var iniPath)) + { + error = "Firefox profile not found (no profiles.ini)"; + return false; + } + + try + { + var root = Path.GetDirectoryName(iniPath); + if (string.IsNullOrEmpty(root)) + { + error = "Firefox profiles.ini path is invalid"; + return false; + } + var text = File.ReadAllText(iniPath); + var entry = ParseDefaultProfileEntry(text); + if (entry is null || string.IsNullOrWhiteSpace(entry.Value.Path)) + { + error = "Firefox profiles.ini has no default profile"; + return false; + } + + var defaultPath = entry.Value.Path; + var full = !entry.Value.IsRelative || Path.IsPathRooted(defaultPath) + ? defaultPath + : Path.GetFullPath(Path.Combine(root, defaultPath)); + + if (!Directory.Exists(full)) + { + error = "Firefox default profile directory does not exist: " + full; + return false; + } + + profileDirectory = full; + return true; + } + catch (Exception ex) + { + error = "Failed to read Firefox profiles.ini: " + ex.Message; + return false; + } + } + + /// Prefer Default=1 profile; else first Path= under a Profile section. + internal static string? ParseDefaultProfilePath(string profilesIni) => + ParseDefaultProfileEntry(profilesIni)?.Path; + + internal static (string Path, bool IsRelative)? ParseDefaultProfileEntry(string profilesIni) // NOSONAR S3776 -- profiles.ini parse is a single section walk. + { + string? fallbackPath = null; + var fallbackRelative = true; + string? currentPath = null; + var isRelative = true; + var isDefault = false; + + using var reader = new StringReader(profilesIni); + string? line; + while ((line = reader.ReadLine()) != null) + { + line = line.Trim(); + if (line.StartsWith('[') && line.EndsWith(']')) + { + if (isDefault && !string.IsNullOrWhiteSpace(currentPath)) + return (currentPath, isRelative); + if (fallbackPath is null && !string.IsNullOrWhiteSpace(currentPath)) + { + fallbackPath = currentPath; + fallbackRelative = isRelative; + } + + currentPath = null; + isRelative = true; + isDefault = false; + continue; + } + + if (line.StartsWith("Path=", StringComparison.OrdinalIgnoreCase)) + currentPath = line["Path=".Length..].Trim(); + else if (line.StartsWith("IsRelative=", StringComparison.OrdinalIgnoreCase)) + { + var value = line["IsRelative=".Length..].Trim(); + isRelative = value is not ("0" or "false"); + } + else if (line.StartsWith("Default=1", StringComparison.OrdinalIgnoreCase)) + isDefault = true; + } + + if (isDefault && !string.IsNullOrWhiteSpace(currentPath)) + return (currentPath, isRelative); + if (fallbackPath is not null) + return (fallbackPath, fallbackRelative); + if (!string.IsNullOrWhiteSpace(currentPath)) + return (currentPath, isRelative); + return null; + } + + private static bool TryGetProfilesIniPath(out string iniPath) + { + foreach (var root in GetFirefoxRoots()) + { + var candidate = Path.Combine(root, "profiles.ini"); + if (File.Exists(candidate)) + { + iniPath = candidate; + return true; + } + } + + iniPath = string.Empty; + return false; + } + + /// Known Firefox profile roots (classic, Snap, Flatpak). First hit with profiles.ini wins. + internal static string[] GetFirefoxRoots() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (OperatingSystem.IsWindows()) + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + return [Path.Combine(appData, "Mozilla", FirefoxDirName)]; + } + + if (OperatingSystem.IsMacOS()) + { + return + [ + Path.Combine(home, LibraryDirName, ApplicationSupportDirName, FirefoxDirName), + Path.Combine(home, LibraryDirName, ApplicationSupportDirName, "FirefoxDeveloperEdition"), + Path.Combine(home, LibraryDirName, ApplicationSupportDirName, "Firefox Nightly"), + ]; + } + + if (OperatingSystem.IsLinux()) + { + return + [ + Path.Combine(home, MozillaDirName, FirefoxProcessName), + // Ubuntu Snap default Firefox + Path.Combine(home, "snap", FirefoxProcessName, "common", MozillaDirName, FirefoxProcessName), + // Flatpak (org.mozilla.Firefox / org.mozilla.firefox) + Path.Combine(home, ".var", "app", "org.mozilla.firefox", MozillaDirName, FirefoxProcessName), + Path.Combine(home, ".var", "app", "org.mozilla.Firefox", MozillaDirName, FirefoxProcessName), + ]; + } + + return []; + } + + private static string Escape(string value) => value.Replace("\"", "\\\""); +} diff --git a/src/Titanium.Web.Proxy/Certificates/Makers/BCCertificateMakerFast.cs b/src/Titanium.Web.Proxy/Certificates/Makers/BCCertificateMakerFast.cs index 7df795fd3..b147793cd 100644 --- a/src/Titanium.Web.Proxy/Certificates/Makers/BCCertificateMakerFast.cs +++ b/src/Titanium.Web.Proxy/Certificates/Makers/BCCertificateMakerFast.cs @@ -37,6 +37,9 @@ internal class BcCertificateMakerFast : ICertificateMaker /// /// Shared key pair used for every leaf. Roots always get a fresh RSA-2048 pair so the /// documented "root stays RSA" contract holds when leaves are ECDSA. + /// Intentional MITM first-visit RPS tradeoff: one pair per + /// instance. Compromise of this material compromises all forged leaves from this maker; + /// do not generate per-host keys on the hot path. /// private readonly AsymmetricCipherKeyPair leafKeyPair; diff --git a/src/Titanium.Web.Proxy/ClientProcessId.cs b/src/Titanium.Web.Proxy/ClientProcessId.cs new file mode 100644 index 000000000..2ea915962 --- /dev/null +++ b/src/Titanium.Web.Proxy/ClientProcessId.cs @@ -0,0 +1,16 @@ +using System; + +namespace Titanium.Web.Proxy; + +/// +/// Capability for resolving the local client process that owns a TCP connection to the proxy. +/// +public static class ClientProcessId +{ + /// + /// True when this OS can map a localhost client TCP port to a process id + /// (Windows, Linux, and macOS). + /// + public static bool IsSupported => + OperatingSystem.IsWindows() || OperatingSystem.IsLinux() || OperatingSystem.IsMacOS(); +} diff --git a/src/Titanium.Web.Proxy/EventArguments/SessionEventArgs.cs b/src/Titanium.Web.Proxy/EventArguments/SessionEventArgs.cs index f54d1a51a..b54895c02 100644 --- a/src/Titanium.Web.Proxy/EventArguments/SessionEventArgs.cs +++ b/src/Titanium.Web.Proxy/EventArguments/SessionEventArgs.cs @@ -597,6 +597,7 @@ public void SetRequestBody(byte[] body) if (request.Locked) throw new InvalidOperationException("You cannot call this function after request is made to server."); request.Body = body; + request.IsBodyRead = true; } /// @@ -672,6 +673,7 @@ public void SetResponseBody(byte[] body) var response = HttpClient.Response; response.Body = body; + response.IsBodyRead = true; } /// diff --git a/src/Titanium.Web.Proxy/EventArguments/SessionEventArgsBase.cs b/src/Titanium.Web.Proxy/EventArguments/SessionEventArgsBase.cs index ed99f699a..21376564c 100644 --- a/src/Titanium.Web.Proxy/EventArguments/SessionEventArgsBase.cs +++ b/src/Titanium.Web.Proxy/EventArguments/SessionEventArgsBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Net; using System.Threading; using Microsoft.Extensions.Logging; @@ -20,6 +20,12 @@ namespace Titanium.Web.Proxy.EventArguments; /// public abstract class SessionEventArgsBase : ProxyEventArgsBase, IDisposable { + /// + /// Shared process-id stub when lookup is unsupported or unused (transparent reverse / RPS). + /// Avoids a allocation per multiplexed H2/H3 stream. + /// + private static readonly Lazy UnknownClientProcessId = new(() => 0); + protected readonly IBufferPool BufferPool; internal readonly CancellationTokenSource CancellationTokenSource; @@ -64,7 +70,9 @@ private protected SessionEventArgsBase(ProxyServer server, ProxyEndPoint endPoin ClientStream = clientStream; HttpClient = new HttpWebClient(connectRequest, request, - new Lazy(() => clientStream.Connection.GetProcessId(endPoint))); + ClientProcessId.IsSupported && endPoint is not TransparentBaseProxyEndPoint + ? new Lazy(() => clientStream.Connection.GetProcessId(endPoint)) + : UnknownClientProcessId); ProxyEndPoint = endPoint; EnableWinAuth = server.EnableWinAuth && IsWindowsAuthenticationSupported; } @@ -123,6 +131,9 @@ public object? UserData set => HttpClient.UserData = value; } + /// Optional response header transforms staged during reverse-proxy request transforms. + internal object? ResponseHeaderTransformPlan { get; set; } + /// /// Per-session override for . /// uses the server default; or negative @@ -213,9 +224,10 @@ public bool EnableWinAuth internal int? UpstreamConnectPort { get; set; } /// - /// Selected cluster destination id for health / retry bookkeeping. + /// Selected cluster destination id when reverse-proxy routing applied this session; + /// otherwise null. Used by Plus circuit breaker / health bookkeeping. /// - internal string? UpstreamDestinationId { get; set; } + public string? UpstreamDestinationId { get; internal set; } /// Active-request lease for . internal IDisposable? DestinationRequestLease { get; set; } diff --git a/src/Titanium.Web.Proxy/Extensions/FuncExtensions.cs b/src/Titanium.Web.Proxy/Extensions/FuncExtensions.cs index 1d73b0ee4..72fba0e0a 100644 --- a/src/Titanium.Web.Proxy/Extensions/FuncExtensions.cs +++ b/src/Titanium.Web.Proxy/Extensions/FuncExtensions.cs @@ -12,13 +12,11 @@ internal static class FuncExtensions internal static Task InvokeAsync(this AsyncEventHandler callback, object sender, T args, ILogger logger) { - var invocationList = callback.GetInvocationList(); - - // Single subscriber is the common case — avoid GetInvocationList allocation churn is already - // paid, but skip an outer async state machine when the handler's Task completed inline. - if (invocationList.Length == 1) - return InvokeOneAsync((AsyncEventHandler)invocationList[0], sender, args, logger); + // Single subscriber is the common Lite case — avoid GetInvocationList() Delegate[] alloc. + if (callback.HasSingleTarget) + return InvokeOneAsync(callback, sender, args, logger); + var invocationList = callback.GetInvocationList(); return InvokeManyAsync(invocationList, sender, args, logger); } diff --git a/src/Titanium.Web.Proxy/Grpc/GrpcWebAdapter.cs b/src/Titanium.Web.Proxy/Grpc/GrpcWebAdapter.cs index ec3ebd7a0..320ed05e5 100644 --- a/src/Titanium.Web.Proxy/Grpc/GrpcWebAdapter.cs +++ b/src/Titanium.Web.Proxy/Grpc/GrpcWebAdapter.cs @@ -1,19 +1,19 @@ using System; using System.Text; -using Titanium.Web.Proxy.Abstractions.Plugins; using Titanium.Web.Proxy.Http; namespace Titanium.Web.Proxy.Grpc; /// -/// Adapter for application/grpc-web* content types. Preserves trailers; optional -/// — Core never embeds protobuf codecs. +/// Adapter for application/grpc-web* content types and length-prefixed gRPC frames. +/// Protobuf codecs live outside Core (e.g. Plus gRPC-JSON transcoder). /// internal static class GrpcWebAdapter { public const string GrpcWebContentType = "application/grpc-web"; public const string GrpcWebProtoContentType = "application/grpc-web+proto"; public const string GrpcWebTextContentType = "application/grpc-web-text"; + public const string GrpcContentType = "application/grpc"; public static bool IsGrpcWeb(HeaderCollection headers) { @@ -33,7 +33,7 @@ public static bool IsGrpcWebText(HeaderCollection headers) ct.StartsWith(GrpcWebTextContentType, StringComparison.OrdinalIgnoreCase); } - /// Decode a length-prefixed gRPC-Web frame (compressed flag + 4-byte BE length + payload). + /// Decode a length-prefixed gRPC frame (compressed flag + 4-byte BE length + payload). public static bool TryReadFrame(ReadOnlySpan buffer, out bool compressed, out ReadOnlySpan payload, out int consumed) { compressed = false; @@ -83,16 +83,6 @@ public static void PromoteTrailersToHeaders(HeaderCollection trailers, HeaderCol } } - public static byte[]? MaybeTranscode(IGrpcTranscodeHook? hook, byte[] requestBody) - { - if (hook is null) - { - return null; - } - - return hook.TryTranscode(requestBody, out var response) ? response : null; - } - public static byte[]? DecodeBase64TextBody(ReadOnlySpan asciiBody) { try diff --git a/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs b/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs index aa92d43fc..74b598f34 100644 --- a/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs +++ b/src/Titanium.Web.Proxy/Handlers/CertificateHandler.cs @@ -36,6 +36,9 @@ internal bool ValidateServerCertificate(object sender, SessionEventArgsBase? ses if (sslPolicyErrors == SslPolicyErrors.None) return true; + if (IgnoreServerCertificateErrors) + return true; + // By default // do not allow this client to communicate with unauthenticated servers. return false; diff --git a/src/Titanium.Web.Proxy/Handlers/H1TerminateFastForward.cs b/src/Titanium.Web.Proxy/Handlers/H1TerminateFastForward.cs index 35f1c3743..5171653ad 100644 --- a/src/Titanium.Web.Proxy/Handlers/H1TerminateFastForward.cs +++ b/src/Titanium.Web.Proxy/Handlers/H1TerminateFastForward.cs @@ -37,9 +37,13 @@ public partial class ProxyServer /// without a session bag; AfterResponse subscribers /// still force the full session path via . /// - private bool CanUseH1TerminateLite(ProxyEndPoint endPoint, Request request, bool enable100Continue, - bool enableWinAuth, bool hasCustomUpstreamProxyFunc) + internal bool CanUseH1TerminateLite(ProxyEndPoint endPoint, Request request, bool enable100Continue, + bool enableWinAuth, bool hasCustomUpstreamProxyFunc, UpstreamHttpProtocol? upstreamProtocol = null) { + // This path only speaks HTTP/1.1 TCP to the origin. H2/H3 must not take lite (historical 100% errors). + if (upstreamProtocol is UpstreamHttpProtocol.Http2 or UpstreamHttpProtocol.Http3) + return false; + if (enable100Continue || enableWinAuth || hasCustomUpstreamProxyFunc) return false; @@ -92,6 +96,7 @@ private bool CanUseH1TerminateLite(ProxyEndPoint endPoint, Request request, bool // (see Request.StripHopByHopConnectionForTransparentOrigin). var clientRequestedClose = H1TerminateClientRequestedClose(request); request.StripHopByHopConnectionForTransparentOrigin(); + request.ApplyTransparentForwardCleartextHost(endPoint); var isHttps = !endPoint.ForwardCleartext && request.IsHttps; // Terminate with ForwardCleartext: origin is cleartext regardless of client TLS. @@ -282,6 +287,8 @@ await connection.Stream.CopyBodyAsync(response, false, clientStream, Transformat var clientRequestedClose = H1TerminateClientRequestedClose(request); request.StripHopByHopConnectionForTransparentOrigin(); + request.ApplyTransparentForwardCleartextHost(endPoint); + var isHttps = !endPoint.ForwardCleartext && request.IsHttps; if (endPoint.ForwardCleartext) isHttps = false; diff --git a/src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs b/src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs index 57496ba0e..b69fd79c6 100644 --- a/src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs +++ b/src/Titanium.Web.Proxy/Handlers/Http11ToHttp2BridgeHandler.cs @@ -144,27 +144,33 @@ public partial class ProxyServer // The client leg here is genuine HTTP/1.1 wire bytes (this bridge only changes // what the *origin* connection speaks), so the same wire-framing rules as // RequestHandler apply before anything observes pre-normalization values. - try + // Fast path (probe / no handlers): skip validator + SetOriginalHeaders copies — + // Mac dual-TLS H1→H2 residual is multiplex, but every keep-alive still paid + // framing + original-header snapshot Gen0 with no user-visible benefit. + if (!args.IsFastPath) { - Http1FramingValidator.Validate(request, ResolveHttp1WireFramingSource(args), - args.Server.PolicyModes.AllowAmbiguousFraming); - } - catch (Http1FramingException framingEx) - { - ProxyMetrics.ParserError("framing"); - args.HttpClient.Response = new GenericResponse(framingEx.StatusCode) + try { - HttpVersion = request.HttpVersion - }; - args.HttpClient.Response.Headers.AddHeader(KnownHeaders.Connection, - KnownHeaders.ConnectionClose); - closeConnection = true; - await clientStream.WriteResponseAsync(args.HttpClient.Response, cancellationToken); - args.IsClientResponseCommitted = true; - return; - } + Http1FramingValidator.Validate(request, ResolveHttp1WireFramingSource(args), + args.Server.PolicyModes.AllowAmbiguousFraming); + } + catch (Http1FramingException framingEx) + { + ProxyMetrics.ParserError("framing"); + args.HttpClient.Response = new GenericResponse(framingEx.StatusCode) + { + HttpVersion = request.HttpVersion + }; + args.HttpClient.Response.Headers.AddHeader(KnownHeaders.Connection, + KnownHeaders.ConnectionClose); + closeConnection = true; + await clientStream.WriteResponseAsync(args.HttpClient.Response, cancellationToken); + args.IsClientResponseCommitted = true; + return; + } - request.SetOriginalHeaders(); + request.SetOriginalHeaders(); + } // Fill default Host before BeforeRequest so handlers can read or override it. if (!args.IsTransparent && !args.IsSocks && request.Host == null) @@ -372,6 +378,16 @@ private async Task OfferRetainedHttp2OriginSeedAsync(SessionEventArgs args, stri if (seedConnection == null) return; + // SoftPick SoftGrow: only seed the first origin leg from the negotiation-retained ALPN + // connection. Later H1 clients must Rent/SoftGrow — Offer-flooding MaxOrigin dual-TLS + // legs from every client left Mac H1 TLS→H2 ~0.89× (pool dig avgMembers≈7–8). + if (Http2OriginConnectionPool.HasAny(poolKey) + || Http2OriginConnectionPool.IsAtMaxOriginCapacity(poolKey)) + { + await TcpConnectionFactory.Release(seedConnection, true); + return; + } + try { var created = await Http2OriginConnection.CreateAsync(seedConnection, logger, @@ -502,8 +518,10 @@ private async Task EstablishHttp2OriginTcpConnectionAsync(S if (request.HasBody && !request.IsBodyRead) { var clientBodyStream = args.ClientStream; - var isChunked = request.OriginalIsChunked; - var contentLength = request.OriginalContentLength; + // Fast path skips SetOriginalHeaders; OriginalContentLength stays 0 and LimitedStream + // would END_STREAM with no DATA while Content-Length is still advertised (origin RST). + var isChunked = args.IsFastPath ? request.IsChunked : request.OriginalIsChunked; + var contentLength = args.IsFastPath ? request.ContentLength : request.OriginalContentLength; copyRequestBody = async (writeData, ct) => { using var limited = new LimitedStream(clientBodyStream, BufferPool, isChunked, diff --git a/src/Titanium.Web.Proxy/Handlers/Http2ToHttp11BridgeHandler.cs b/src/Titanium.Web.Proxy/Handlers/Http2ToHttp11BridgeHandler.cs index 0af3aad6a..75160251a 100644 --- a/src/Titanium.Web.Proxy/Handlers/Http2ToHttp11BridgeHandler.cs +++ b/src/Titanium.Web.Proxy/Handlers/Http2ToHttp11BridgeHandler.cs @@ -320,6 +320,8 @@ await Http2Helper.SendHttp2(clientStream, originStream, } } + request.ApplyTransparentForwardCleartextHost(sessionArgs.ProxyEndPoint); + // RFC 7540 §8.1.2.5: an h2 client may split Cookie across several HEADERS field lines. // Only allocate when multiple Cookie lines actually exist (probe GETs have none). if (request.Headers.NonUniqueHeaders.TryGetValue("Cookie", out var cookieLines) diff --git a/src/Titanium.Web.Proxy/Handlers/RequestHandler.cs b/src/Titanium.Web.Proxy/Handlers/RequestHandler.cs index d5c39016c..df4adae03 100644 --- a/src/Titanium.Web.Proxy/Handlers/RequestHandler.cs +++ b/src/Titanium.Web.Proxy/Handlers/RequestHandler.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Net; using System.Net.Security; using System.Net.Sockets; @@ -140,7 +141,7 @@ public partial class ProxyServer headersAlreadyRead = true; if (CanUseH1TerminateLite(endPoint, preparedRequest, Enable100ContinueBehaviour, - EnableWinAuth, hasCustomUpstreamProxyFunc: false)) + EnableWinAuth, hasCustomUpstreamProxyFunc: false, connectionUpstream)) { try { @@ -323,15 +324,16 @@ public partial class ProxyServer args.IsFastPath = fastPath; - // Middleware requires BeforeRequest; never skip it when configured. - if (fastPath && ReverseProxy?.Middleware is { Count: > 0 }) + // Middleware / gRPC-JSON transcoder require BeforeRequest; never skip when configured. + if (fastPath && + (ReverseProxy?.Middleware is { Count: > 0 } || + ReverseProxy?.GrpcJsonTranscoder is not null)) { fastPath = false; args.IsFastPath = false; } - var requestHeaderRelayBaseline = - MitmCompressedRelayHelper.HeaderRelayBaseline.Capture(request.Headers); + request.Headers.ArmMitmRelayBaseline(); var capturedRequestMethod = request.Method; var capturedRequestPath = request.RequestUriString8; var capturedRequestAuthority = request.Authority; @@ -363,6 +365,8 @@ public partial class ProxyServer return; } + var requestHeaderRelayBaseline = request.Headers.TakeMitmRelayBaseline(); + // Total per-request deadline starts after BeforeRequest so session overrides apply. using var requestDeadline = args.Deadlines.Start(cancellationToken, ResolveRequestTimeout(args), ProxyTimeoutKind.Request); @@ -390,28 +394,28 @@ public partial class ProxyServer PrepareRequestHeaders(request.Headers); // Do NOT overwrite Host here — any value set by the BeforeRequest handler // must be preserved. The default was already filled in above. + } - // Via loop detection and injection (RFC 9110 §7.6.3). - if (!fastPath && !string.IsNullOrEmpty(ViaHeaderPseudonym)) + // Via loop detection and injection (RFC 9110 §7.6.3). Explicit and reverse. + if (!fastPath && !string.IsNullOrEmpty(ViaHeaderPseudonym)) + { + if (HasLoopedVia(request.Headers, ViaHeaderPseudonym)) { - if (HasLoopedVia(request.Headers, ViaHeaderPseudonym)) + args.HttpClient.Response = new Response { - args.HttpClient.Response = new Response - { - HttpVersion = request.HttpVersion, - StatusCode = 508, - StatusDescription = "Loop Detected" - }; - // Drain any request body first so the client stream is clean. - if (!(Enable100ContinueBehaviour && request.ExpectContinue)) - await args.SyphonOutBodyAsync(true, requestToken); - await clientStream.WriteResponseAsync(args.HttpClient.Response, requestToken); - args.IsClientResponseCommitted = true; - return; - } - - AddViaHeader(request.Headers, request.HttpVersion, ViaHeaderPseudonym); + HttpVersion = request.HttpVersion, + StatusCode = 508, + StatusDescription = "Loop Detected" + }; + // Drain any request body first so the client stream is clean. + if (!(Enable100ContinueBehaviour && request.ExpectContinue)) + await args.SyphonOutBodyAsync(true, requestToken); + await clientStream.WriteResponseAsync(args.HttpClient.Response, requestToken); + args.IsClientResponseCommitted = true; + return; } + + AddViaHeader(request.Headers, request.HttpVersion, ViaHeaderPseudonym); } // if win auth is enabled @@ -473,7 +477,7 @@ public partial class ProxyServer && sessionUpstream is not UpstreamHttpProtocol.Http3 && endPoint is TransparentBaseProxyEndPoint mitmTerminateEp && CanUseH1TerminateLite(endPoint, request, Enable100ContinueBehaviour, - EnableWinAuth, GetCustomUpStreamProxyFunc != null) + EnableWinAuth, GetCustomUpStreamProxyFunc != null, sessionUpstream) && !request.IsBodyRead && !request.BodyAvailable && MitmCompressedRelayHelper.AllowsCompressedRelay( @@ -823,6 +827,10 @@ await bodyWriter.CompleteAsync( && !args.EnableWinAuth))) { TcpServerConnection? connection = serverConnection; + // Sticky keep-alive already served a request on this socket — do not remap mid-response + // IO into a retry (that would replay a consumed body). Just-rented (including skip-poll + // pool hits) may be dead; first-IO IOException/SocketException falls through to RetryPolicy. + var justRented = serverConnection == null; try { connection ??= await TcpConnectionFactory.GetServerConnection(this, args, false, @@ -840,6 +848,18 @@ await bodyWriter.CompleteAsync( await TcpConnectionFactory.Release(connection, true); serverConnection = null; } + catch (IOException) when (justRented) + { + if (connection != null) + await TcpConnectionFactory.Release(connection, true); + serverConnection = null; + } + catch (SocketException) when (justRented) + { + if (connection != null) + await TcpConnectionFactory.Release(connection, true); + serverConnection = null; + } } // a connection generator task with captured parameters via closure. @@ -887,6 +907,7 @@ await HandleWebSocketUpgrade(args, args.ClientStream, connection, cancellationTo { var cancellationToken = args.CancellationToken; var request = args.HttpClient.Request; + request.ApplyTransparentForwardCleartextHost(args.ProxyEndPoint); // Transparent reverse tiny GET: send + receive + write without WinAuth / 1xx loop / // SetOriginalHeaders / BeforeResponse. Probe and no-interception servers hit this. @@ -1145,28 +1166,55 @@ await args.CopyRequestBodyAsync(args.HttpClient.Connection.Stream, Transformatio /// /// The session event arguments. /// - private async Task OnBeforeRequest(SessionEventArgs args) + private Task OnBeforeRequest(SessionEventArgs args) { if (args.IsFastPath) - return; + return Task.CompletedTask; args.Timing?.MarkRequestHeadersReceived(); + // Rewrite REST/JSON → gRPC before middleware / user handlers / routing when configured. + if (ReverseProxy?.GrpcJsonTranscoder is { } transcoder) + return OnBeforeRequestWithTranscoderAsync(args, transcoder); + + var middleware = ReverseProxy?.Middleware; + if (middleware is { Count: > 0 }) + return OnBeforeRequestWithMiddlewareAsync(args, middleware); + + if (BeforeRequest != null) + return BeforeRequest.InvokeAsync(this, args, logger); + + return Task.CompletedTask; + } + + private async Task OnBeforeRequestWithTranscoderAsync( + SessionEventArgs args, + Abstractions.Plugins.IGrpcJsonTranscoder transcoder) + { + await transcoder.TryRewriteRequestAsync(args, args.CancellationToken).ConfigureAwait(false); + var middleware = ReverseProxy?.Middleware; if (middleware is { Count: > 0 }) { - var ctx = new Abstractions.Middleware.ProxyMiddlewareContext { Session = args }; - Abstractions.Middleware.ProxyMiddlewareDelegate terminus = async (_, _) => - { - if (BeforeRequest != null) - await BeforeRequest.InvokeAsync(this, args, logger); - }; - await Middleware.ProxyMiddlewarePipeline.Build(middleware, terminus)(ctx, args.CancellationToken); + await OnBeforeRequestWithMiddlewareAsync(args, middleware).ConfigureAwait(false); return; } if (BeforeRequest != null) - await BeforeRequest.InvokeAsync(this, args, logger); + await BeforeRequest.InvokeAsync(this, args, logger).ConfigureAwait(false); + } + + private async Task OnBeforeRequestWithMiddlewareAsync( + SessionEventArgs args, + IReadOnlyList middleware) + { + var ctx = new Abstractions.Middleware.ProxyMiddlewareContext { Session = args }; + Abstractions.Middleware.ProxyMiddlewareDelegate terminus = async (_, _) => + { + if (BeforeRequest != null) + await BeforeRequest.InvokeAsync(this, args, logger); + }; + await Middleware.ProxyMiddlewarePipeline.Build(middleware, terminus)(ctx, args.CancellationToken); } /// diff --git a/src/Titanium.Web.Proxy/Handlers/ResponseHandler.cs b/src/Titanium.Web.Proxy/Handlers/ResponseHandler.cs index 6d456ef1e..d71d55b2c 100644 --- a/src/Titanium.Web.Proxy/Handlers/ResponseHandler.cs +++ b/src/Titanium.Web.Proxy/Handlers/ResponseHandler.cs @@ -316,11 +316,29 @@ internal static bool ShouldReuseConnectionForAuthReRequest(int responseStatusCod /// private Task OnBeforeResponse(SessionEventArgs args) { - if (args.IsFastPath) return Task.CompletedTask; + if (args.IsFastPath) + return Task.CompletedTask; - return BeforeResponse != null - ? BeforeResponse.InvokeAsync(this, args, logger) - : Task.CompletedTask; + // Staged ResponseHeaderSet/Remove from route transforms (null when unused). + ReverseProxySessionDispatch.ApplyResponseTransforms(args); + + // Rewrite gRPC → JSON before user handlers when the request was transcoded. + if (ReverseProxy?.GrpcJsonTranscoder is { } transcoder) + return OnBeforeResponseWithTranscoderAsync(args, transcoder); + + if (BeforeResponse != null) + return BeforeResponse.InvokeAsync(this, args, logger); + + return Task.CompletedTask; + } + + private async Task OnBeforeResponseWithTranscoderAsync( + SessionEventArgs args, + Abstractions.Plugins.IGrpcJsonTranscoder transcoder) + { + await transcoder.TryRewriteResponseAsync(args, args.CancellationToken).ConfigureAwait(false); + if (BeforeResponse != null) + await BeforeResponse.InvokeAsync(this, args, logger).ConfigureAwait(false); } /// diff --git a/src/Titanium.Web.Proxy/Helpers/LinuxBrowserLaunchProxy.cs b/src/Titanium.Web.Proxy/Helpers/LinuxBrowserLaunchProxy.cs new file mode 100644 index 000000000..46561546d --- /dev/null +++ b/src/Titanium.Web.Proxy/Helpers/LinuxBrowserLaunchProxy.cs @@ -0,0 +1,514 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Titanium.Web.Proxy.Helpers; + +/// +/// GNOME gsettings is not enough on XFCE, LXDE, i3/sway, WSLg, or when Chrome is started +/// from a .desktop Exec that never reads org.gnome.system.proxy. Write Chromium-family +/// managed policy plus user .desktop / XFCE helper Exec flags +/// (--proxy-server, <-loopback>, --disable-quic). +/// +internal static class LinuxBrowserLaunchProxy +{ + internal const string PolicyFileName = "titanium-inspector-proxy.json"; + internal const string DesktopMarker = "X-Titanium-Inspector-Proxy=true"; + internal const string ProxyBypassList = "<-loopback>"; + + /// + /// Chromium managed-policy floor we target: modern ProxyMode string enum plus + /// legacy ProxyServerMode int (2 = fixed servers) for older builds that still + /// read the deprecated key. Unknown keys are ignored by Chromium. + /// + internal const int LegacyProxyServerModeFixedServers = 2; + + private const string LocalDirName = ".local"; + private const string ShareDirName = "share"; + private const string ConfigDirName = ".config"; + private const string ChromiumDirName = "chromium"; + private const string ApplicationsDirName = "applications"; + private const string PoliciesManaged = "policies"; + private const string ManagedDirName = "managed"; + private const string MicrosoftEdgeDirName = "microsoft-edge"; + private const string FlatpakConfigDirName = "config"; + + private static readonly string[] DesktopFieldCodes = [" %U", " %u", " %f", " %F", " %s", " \"%s\""]; + + private static readonly string[] DesktopSources = + [ + "/usr/share/applications/google-chrome.desktop", + "/usr/share/applications/google-chrome-stable.desktop", + "/usr/share/applications/chromium.desktop", + "/usr/share/applications/chromium-browser.desktop", + "/usr/share/applications/brave-browser.desktop", + "/usr/share/applications/microsoft-edge.desktop", + "/usr/share/applications/microsoft-edge-stable.desktop", + ]; + + private static readonly string[] ChromeBinaries = + [ + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + "/opt/google/chrome/chrome", + "/usr/bin/chromium-browser", + "/usr/bin/chromium", + "/usr/bin/brave-browser", + "/usr/bin/microsoft-edge-stable", + "/usr/bin/microsoft-edge", + ]; + + /// Returns true when at least one browser-launch hook was written and re-validated. + internal static bool Apply(string hostname, int port, string? proxyOverride = null) + { + var policyCount = WritePolicies(hostname, port); + var policyOk = policyCount > 0; + var desktopCount = WriteBrowserDesktopOverrides(hostname, port); + var desktopOk = desktopCount > 0; + var xfceOk = WriteXfceWebBrowserHelper(hostname, port); + var profileCount = LinuxChromeProfileProxy.Apply(hostname, port); + var profileOk = profileCount > 0; + var firefoxOk = LinuxFirefoxProxy.Apply(hostname, port, proxyOverride); + if (desktopOk) + TryUpdateDesktopDatabase(); + var ok = policyOk || desktopOk || xfceOk || profileOk || firefoxOk; + // Relaunch running Chromium-family browsers so Preference edits take effect. + LinuxChromiumRelaunch.TryRelaunchForProxyChange(hostname, port, enableProxy: true); + return ok; + } + + internal static void Clear() + { + LinuxChromeProfileProxy.Clear(); + LinuxFirefoxProxy.Clear(); + + foreach (var dir in PolicyDirectories()) + { + try + { + File.Delete(Path.Combine(dir, PolicyFileName)); + } + catch + { + // best-effort + } + } + + foreach (var dest in UserDesktopOverridePaths()) + DeleteMarkedDesktopOverride(dest); + + DeleteMarkedDesktopOverride(UserXfceChromeHelperPath()); + RestoreXfceHelpersRc(); + TryUpdateDesktopDatabase(); + + // Relaunch without proxy flags so already-open Chromium browsers drop the dead endpoint immediately. + LinuxChromiumRelaunch.TryRelaunchForProxyChange(null, 0, enableProxy: false); + } + + private static void TryUpdateDesktopDatabase() + { + try + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var apps = Path.Combine(home, LocalDirName, ShareDirName, ApplicationsDirName); + if (!Directory.Exists(apps)) + return; + var updateDesktopDb = UnixProcessPath.Resolve( + "/usr/bin/update-desktop-database", "/usr/local/bin/update-desktop-database"); + if (updateDesktopDb is null) + return; + + using var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = updateDesktopDb, + Arguments = QuoteShellArg(apps), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }); + process?.WaitForExit(3000); + } + catch + { + // optional helper; dock may still pick up overrides without a cache refresh + } + } + + private static string QuoteShellArg(string value) => + "\"" + value.Replace("\"", "\\\"", StringComparison.Ordinal) + "\""; + + /// Writes managed policy JSON; returns count of directories that validated after write. + internal static int WritePolicies(string hostname, int port, IEnumerable? directories = null) + { + string json; + try + { + json = BuildPolicyJson(hostname, port); + } + catch + { + return 0; + } + + if (!TryValidatePolicyJson(json, hostname, port, out _)) + return 0; + + var written = 0; + foreach (var dir in directories ?? PolicyDirectories()) + { + try + { + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, PolicyFileName); + // Atomic-ish: write temp then replace so a crash mid-write cannot leave truncated JSON. + var temp = path + ".tmp"; + File.WriteAllText(temp, json); + File.Move(temp, path, overwrite: true); + var onDisk = File.ReadAllText(path); + if (TryValidatePolicyJson(onDisk, hostname, port, out _)) + written++; + else + { + try { File.Delete(path); } catch { /* ignore */ } + } + } + catch + { + // /etc/opt/chrome and snap roots may not be writable + } + } + + return written; + } + + /// + /// Builds Chromium managed-policy JSON. Prefer modern string keys; + /// also emit deprecated ProxyServerMode=2 for older Chromium that still reads it. + /// + internal static string BuildPolicyJson(string hostname, int port) + { + if (string.IsNullOrWhiteSpace(hostname)) + throw new ArgumentException("hostname is required", nameof(hostname)); + if (port is <= 0 or > 65535) + throw new ArgumentOutOfRangeException(nameof(port)); + + var server = $"http://{hostname}:{port}"; // NOSONAR S5332 -- Chromium ProxyServer is an http CONNECT proxy URL. + var payload = new JsonObject + { + ["ProxyMode"] = "fixed_servers", + // Deprecated int enum (2 = Use a fixed proxy server). Harmless on modern Chrome. + ["ProxyServerMode"] = LegacyProxyServerModeFixedServers, + ["ProxyServer"] = server, + ["ProxyBypassList"] = ProxyBypassList, + ["QuicAllowed"] = false, + }; + + return payload.ToJsonString(new JsonSerializerOptions + { + WriteIndented = true, + // Keep <-loopback> readable; \u003C form is also valid JSON but harder to audit. + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }) + "\n"; + } + + /// + /// True when is valid Chromium managed-policy JSON containing + /// either modern ProxyMode=fixed_servers or legacy ProxyServerMode=2, + /// plus matching host/port. Extra/unknown properties are allowed (forward compatible). + /// + internal static bool TryValidatePolicyJson(string json, string hostname, int port, out string? error) + { + error = null; + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + error = "policy root must be a JSON object"; + return false; + } + + var root = doc.RootElement; + var modeOk = false; + if (root.TryGetProperty("ProxyMode", out var mode) && + mode.ValueKind == JsonValueKind.String && + mode.GetString()?.Equals("fixed_servers", StringComparison.OrdinalIgnoreCase) == true) + { + modeOk = true; + } + + if (root.TryGetProperty("ProxyServerMode", out var legacyMode) && + legacyMode.ValueKind == JsonValueKind.Number && + legacyMode.TryGetInt32(out var legacyInt) && + legacyInt == LegacyProxyServerModeFixedServers) + { + modeOk = true; + } + + if (!modeOk) + { + error = "missing ProxyMode=fixed_servers (or ProxyServerMode=2)"; + return false; + } + + if (!root.TryGetProperty("ProxyServer", out var server) || + server.ValueKind != JsonValueKind.String) + { + error = "missing ProxyServer string"; + return false; + } + + var expected = $"http://{hostname}:{port}"; // NOSONAR S5332 -- Chromium ProxyServer is an http CONNECT proxy URL. + var actual = server.GetString() ?? string.Empty; + if (!actual.Contains($"{hostname}:{port}", StringComparison.Ordinal) && + !actual.Equals(expected, StringComparison.OrdinalIgnoreCase)) + { + error = "ProxyServer does not point at the Inspector endpoint"; + return false; + } + + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + + internal static string InjectChromeProxyArgs(string execLine, string hostname, int port) + { + var flags = ChromeProxyFlags(hostname, port); + if (execLine.Contains("--proxy-server=", StringComparison.Ordinal)) + return execLine; + + var prefix = execLine.StartsWith("Exec=", StringComparison.Ordinal) ? "Exec=" : ""; + var rest = prefix.Length > 0 ? execLine[prefix.Length..] : execLine; + + // Insert before desktop field codes so every Chromium-family Exec line picks up flags. + var insertAt = rest.Length; + foreach (var code in DesktopFieldCodes) + { + var idx = rest.IndexOf(code, StringComparison.Ordinal); + if (idx >= 0 && idx < insertAt) + insertAt = idx; + } + + return prefix + rest[..insertAt] + " " + flags + rest[insertAt..]; + } + + internal static string ChromeProxyFlags(string hostname, int port) => + $"--proxy-server=http://{hostname}:{port} --proxy-bypass-list={ProxyBypassList} --disable-quic"; // NOSONAR S5332 -- Chromium --proxy-server flag requires an http CONNECT proxy URL. + + internal static IEnumerable PolicyDirectories() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + yield return Path.Combine(home, ConfigDirName, "google-chrome", PoliciesManaged, ManagedDirName); + yield return Path.Combine(home, ConfigDirName, "google-chrome-beta", PoliciesManaged, ManagedDirName); + yield return Path.Combine(home, ConfigDirName, "google-chrome-unstable", PoliciesManaged, ManagedDirName); + yield return Path.Combine(home, ConfigDirName, ChromiumDirName, PoliciesManaged, ManagedDirName); + yield return Path.Combine(home, ConfigDirName, "BraveSoftware", "Brave-Browser", PoliciesManaged, ManagedDirName); + yield return Path.Combine(home, ConfigDirName, MicrosoftEdgeDirName, PoliciesManaged, ManagedDirName); + yield return Path.Combine(home, "snap", ChromiumDirName, "common", ChromiumDirName, PoliciesManaged, ManagedDirName); + yield return Path.Combine(home, "snap", ChromiumDirName, "current", ConfigDirName, ChromiumDirName, PoliciesManaged, ManagedDirName); + yield return Path.Combine(home, "snap", MicrosoftEdgeDirName, "common", MicrosoftEdgeDirName, PoliciesManaged, ManagedDirName); + yield return Path.Combine(home, ".var", "app", "com.google.Chrome", FlatpakConfigDirName, "google-chrome", PoliciesManaged, + ManagedDirName); + yield return Path.Combine(home, ".var", "app", "org.chromium.Chromium", FlatpakConfigDirName, ChromiumDirName, PoliciesManaged, + ManagedDirName); + yield return Path.Combine(home, ".var", "app", "com.brave.Browser", FlatpakConfigDirName, "BraveSoftware", "Brave-Browser", + PoliciesManaged, ManagedDirName); + yield return Path.Combine(home, ".var", "app", "com.microsoft.Edge", FlatpakConfigDirName, MicrosoftEdgeDirName, PoliciesManaged, + ManagedDirName); + yield return "/etc/opt/chrome/policies/managed"; + yield return "/etc/chromium/policies/managed"; + yield return "/etc/opt/edge/policies/managed"; + } + + private static int WriteBrowserDesktopOverrides(string hostname, int port) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var destDir = Path.Combine(home, LocalDirName, ShareDirName, ApplicationsDirName); + var written = 0; + foreach (var source in DesktopSources) + { + if (!File.Exists(source)) + continue; + var dest = Path.Combine(destDir, Path.GetFileName(source)); + if (WriteMarkedDesktopExec(source, dest, hostname, port)) + written++; + } + + return written; + } + + private static IEnumerable UserDesktopOverridePaths() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var destDir = Path.Combine(home, LocalDirName, ShareDirName, ApplicationsDirName); + foreach (var source in DesktopSources) + yield return Path.Combine(destDir, Path.GetFileName(source)); + } + + private static bool WriteMarkedDesktopExec(string source, string dest, string hostname, int port) + { + try + { + var text = File.ReadAllText(source); + var rewritten = new StringBuilder(); + foreach (var rawLine in text.Replace("\r\n", "\n").Split('\n')) + { + var line = rawLine; + if (line.StartsWith("Exec=", StringComparison.Ordinal)) + line = InjectChromeProxyArgs(line, hostname, port); + rewritten.Append(line); + rewritten.Append('\n'); + } + + if (!rewritten.ToString().Contains(DesktopMarker, StringComparison.Ordinal)) + { + var firstNl = rewritten.ToString().IndexOf('\n'); + if (firstNl >= 0) + rewritten.Insert(firstNl + 1, DesktopMarker + "\n"); + } + + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + File.WriteAllText(dest, rewritten.ToString()); + + var verify = File.ReadAllText(dest); + return verify.Contains(DesktopMarker, StringComparison.Ordinal) && + verify.Contains("--proxy-server=", StringComparison.Ordinal); + } + catch + { + return false; + } + } + + private static void DeleteMarkedDesktopOverride(string path) + { + try + { + if (!File.Exists(path)) + return; + var text = File.ReadAllText(path); + if (text.Contains(DesktopMarker, StringComparison.Ordinal)) + File.Delete(path); + } + catch + { + // ignore + } + } + + private static bool WriteXfceWebBrowserHelper(string hostname, int port) + { + var chrome = ChromeBinaries.FirstOrDefault(File.Exists); + if (chrome is null) + return false; + + var flags = ChromeProxyFlags(hostname, port); + var helperPath = UserXfceChromeHelperPath(); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!); + File.WriteAllText(helperPath, + "[Desktop Entry]\n" + + "Version=1.0\n" + + "Type=X-XFCE-Helper\n" + + "Name=Web Browser (Titanium Inspector proxy)\n" + + "Icon=google-chrome\n" + + "StartupNotify=true\n" + + DesktopMarker + "\n" + + "X-XFCE-Category=WebBrowser\n" + + "X-XFCE-Binaries=google-chrome;google-chrome-stable;chromium;chromium-browser;brave-browser;\n" + + $"X-XFCE-Commands={chrome} {flags};\n" + + $"X-XFCE-CommandsWithParameter={chrome} {flags} \"%s\";\n"); + + var helperOk = File.Exists(helperPath) && + File.ReadAllText(helperPath).Contains("--proxy-server=", StringComparison.Ordinal); + var rcOk = WriteXfceHelpersRc(); + return helperOk && rcOk; + } + catch + { + return false; + } + } + + private static bool WriteXfceHelpersRc() + { + var path = UserXfceHelpersRcPath(); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var existing = File.Exists(path) ? File.ReadAllText(path) : string.Empty; + if (!existing.Contains(DesktopMarker, StringComparison.Ordinal) && + File.Exists("/etc/xdg/xfce4/helpers.rc") && + string.IsNullOrWhiteSpace(existing)) + { + existing = File.ReadAllText("/etc/xdg/xfce4/helpers.rc"); + } + + var lines = existing.Replace("\r\n", "\n").Split('\n').ToList(); + var found = false; + for (var i = 0; i < lines.Count; i++) + { + if (!lines[i].StartsWith("WebBrowser=", StringComparison.Ordinal)) + continue; + lines[i] = "WebBrowser=google-chrome"; + found = true; + break; + } + + if (!found) + lines.Add("WebBrowser=google-chrome"); + + if (!lines.Exists(l => l.Contains(DesktopMarker, StringComparison.Ordinal))) + lines.Insert(0, "# " + DesktopMarker); + + File.WriteAllText(path, string.Join('\n', lines).TrimEnd() + "\n"); + return File.ReadAllText(path).Contains(DesktopMarker, StringComparison.Ordinal); + } + catch + { + return false; + } + } + + private static void RestoreXfceHelpersRc() + { + var path = UserXfceHelpersRcPath(); + try + { + if (!File.Exists(path)) + return; + var text = File.ReadAllText(path); + if (!text.Contains(DesktopMarker, StringComparison.Ordinal)) + return; + File.Delete(path); + } + catch + { + // ignore + } + } + + private static string UserXfceChromeHelperPath() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home, LocalDirName, ShareDirName, "xfce4", "helpers", "google-chrome.desktop"); + } + + private static string UserXfceHelpersRcPath() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home, ConfigDirName, "xfce4", "helpers.rc"); + } +} diff --git a/src/Titanium.Web.Proxy/Helpers/LinuxChromeProfileProxy.cs b/src/Titanium.Web.Proxy/Helpers/LinuxChromeProfileProxy.cs new file mode 100644 index 000000000..47cd10b97 --- /dev/null +++ b/src/Titanium.Web.Proxy/Helpers/LinuxChromeProfileProxy.cs @@ -0,0 +1,511 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; + +namespace Titanium.Web.Proxy.Helpers; + +/// +/// Persist Chromium Preferences proxy.mode=fixed_servers so launches that bypass +/// .desktop / XFCE helpers (plain /usr/bin/google-chrome-stable) still route through +/// Inspector. User-level managed policy JSON is often ignored by Google Chrome on Linux; +/// profile Preferences are read on browser start. +/// +internal static class LinuxChromeProfileProxy +{ + internal const string BackupSuffix = ".titanium-inspector-proxy.bak"; + private const string MarkerFileName = "chrome-profile-proxy.json"; + private const string ConfigDirName = ".config"; + private const string ProxyKey = "proxy"; + private const string FixedServersMode = "fixed_servers"; + private const string ChromiumDirName = "chromium"; + private const string MicrosoftEdgeDirName = "microsoft-edge"; + private const string FlatpakConfigDirName = "config"; + + private static readonly object Gate = new(); + private static readonly List Watchers = new(); + private static readonly int[] ClearRetryDelaysMs = [300, 800, 1500, 3000]; + private static string? _activeHost; + private static int _activePort; + private static CancellationTokenSource? _clearRetryCts; + + /// Writes fixed proxy into Chromium-family profile Preferences; returns profiles updated. + internal static int Apply(string hostname, int port) + { + lock (Gate) + { + CancelClearRetries_NoLock(); + LinuxProxyFailOpen.Stop(); + _activeHost = hostname; + _activePort = port; + WriteMarker(hostname, port); + + var written = EnumeratePreferencesPaths().Count(prefsPath => TryWritePreferences(prefsPath, hostname, port)); + + RestartWatchers_NoLock(); + return written; + } + } + + internal static void Clear() + { + string? host; + int port; + lock (Gate) + { + host = _activeHost; + port = _activePort; + if ((string.IsNullOrEmpty(host) || port <= 0) && + TryReadMarker(out var markerHost, out var markerPort)) + { + host = markerHost; + port = markerPort; + } + + StopWatchers_NoLock(); + _activeHost = null; + _activePort = 0; + + foreach (var prefsPath in EnumeratePreferencesPaths()) + { + TryRestorePreferences(prefsPath); + if (!string.IsNullOrEmpty(host) && port > 0) + TryStripInspectorProxy(prefsPath, host, port); + } + + DeleteMarker(); + ScheduleClearRetries_NoLock(host, port); + } + + // Outside the lock: ProxyServer may still hold the port for a moment during Stop(). + if (!string.IsNullOrEmpty(host) && port > 0) + { + var h = host; + var p = port; + _ = Task.Run(async () => + { + try + { + await Task.Delay(400, _clearRetryCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + LinuxProxyFailOpen.Start(h, p); + } + catch + { + // ignore + } + }, _clearRetryCts?.Token ?? CancellationToken.None); + } + } + + internal static IEnumerable EnumeratePreferencesPaths() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(home)) + yield break; + + foreach (var root in BrowserConfigRoots(home)) + { + if (!Directory.Exists(root)) + continue; + + var defaultPrefs = Path.Combine(root, "Default", "Preferences"); + if (File.Exists(defaultPrefs) || Directory.Exists(Path.Combine(root, "Default"))) + yield return defaultPrefs; + + string[] profiles; + try + { + profiles = Directory.GetDirectories(root, "Profile *"); + } + catch + { + continue; + } + + foreach (var profileDir in profiles) + yield return Path.Combine(profileDir, "Preferences"); + } + } + + private static IEnumerable BrowserConfigRoots(string home) + { + yield return Path.Combine(home, ConfigDirName, "google-chrome"); + yield return Path.Combine(home, ConfigDirName, "google-chrome-beta"); + yield return Path.Combine(home, ConfigDirName, "google-chrome-unstable"); + yield return Path.Combine(home, ConfigDirName, ChromiumDirName); + yield return Path.Combine(home, ConfigDirName, "BraveSoftware", "Brave-Browser"); + yield return Path.Combine(home, ConfigDirName, MicrosoftEdgeDirName); + yield return Path.Combine(home, "snap", ChromiumDirName, "common", ChromiumDirName); + yield return Path.Combine(home, "snap", MicrosoftEdgeDirName, "common", MicrosoftEdgeDirName); + yield return Path.Combine(home, ".var", "app", "com.google.Chrome", FlatpakConfigDirName, "google-chrome"); + yield return Path.Combine(home, ".var", "app", "org.chromium.Chromium", FlatpakConfigDirName, ChromiumDirName); + yield return Path.Combine(home, ".var", "app", "com.brave.Browser", FlatpakConfigDirName, "BraveSoftware", + "Brave-Browser"); + yield return Path.Combine(home, ".var", "app", "com.microsoft.Edge", FlatpakConfigDirName, MicrosoftEdgeDirName); + } + + // Test hooks + internal static bool TryApplyToFileForTests(string prefsPath, string hostname, int port) => + TryWritePreferences(prefsPath, hostname, port); + + internal static void TryRestoreFileForTests(string prefsPath) => + TryRestorePreferences(prefsPath); + + internal static bool TryStripInspectorProxyForTests(string prefsPath, string hostname, int port) => + TryStripInspectorProxy(prefsPath, hostname, port); + + private static bool TryWritePreferences(string prefsPath, string hostname, int port) + { + try + { + var dir = Path.GetDirectoryName(prefsPath); + if (string.IsNullOrEmpty(dir)) + return false; + Directory.CreateDirectory(dir); + + JsonObject root; + if (File.Exists(prefsPath)) + { + var text = File.ReadAllText(prefsPath); + root = JsonNode.Parse(string.IsNullOrWhiteSpace(text) ? "{}" : text) as JsonObject + ?? new JsonObject(); + } + else + { + root = new JsonObject(); + } + + var backupPath = prefsPath + BackupSuffix; + if (!File.Exists(backupPath)) + { + var original = root[ProxyKey]?.ToJsonString() ?? "null"; + File.WriteAllText(backupPath, original); + } + + root[ProxyKey] = BuildFixedServersProxy(hostname, port); + + AtomicWriteJson(prefsPath, root); + + var verify = File.ReadAllText(prefsPath); + return verify.Contains(FixedServersMode, StringComparison.Ordinal) && + verify.Contains($"{hostname}:{port}", StringComparison.Ordinal); + } + catch + { + return false; + } + } + + private static void TryRestorePreferences(string prefsPath) + { + try + { + var backupPath = prefsPath + BackupSuffix; + if (!File.Exists(backupPath)) + return; + + var original = File.ReadAllText(backupPath).Trim(); + if (!File.Exists(prefsPath)) + { + File.Delete(backupPath); + return; + } + + var text = File.ReadAllText(prefsPath); + var root = JsonNode.Parse(string.IsNullOrWhiteSpace(text) ? "{}" : text) as JsonObject + ?? new JsonObject(); + if (original == "null" || string.IsNullOrWhiteSpace(original)) + root.Remove(ProxyKey); + else + root[ProxyKey] = JsonNode.Parse(original); + + AtomicWriteJson(prefsPath, root); + File.Delete(backupPath); + } + catch + { + // best-effort + } + } + + /// + /// Force Preferences away from Inspector's fixed proxy. Used after restore and on retries so a + /// still-running Chrome that flushes in-memory fixed_servers cannot leave a dead proxy on disk. + /// + private static bool TryStripInspectorProxy(string prefsPath, string hostname, int port) + { + try + { + if (!File.Exists(prefsPath)) + return false; + + var text = File.ReadAllText(prefsPath); + if (!text.Contains($"{hostname}:{port}", StringComparison.Ordinal) && + !text.Contains(FixedServersMode, StringComparison.Ordinal)) + return true; + + var root = JsonNode.Parse(string.IsNullOrWhiteSpace(text) ? "{}" : text) as JsonObject + ?? new JsonObject(); + var proxy = root[ProxyKey] as JsonObject; + if (proxy is null) + return true; + + var server = proxy["server"]?.GetValue() ?? string.Empty; + var mode = proxy["mode"]?.GetValue() ?? string.Empty; + var pointsAtUs = server.Contains($"{hostname}:{port}", StringComparison.OrdinalIgnoreCase) || + (mode.Equals(FixedServersMode, StringComparison.OrdinalIgnoreCase) && + server.Contains(hostname, StringComparison.OrdinalIgnoreCase) && + server.Contains(port.ToString(), StringComparison.Ordinal)); + + if (!pointsAtUs && !mode.Equals(FixedServersMode, StringComparison.OrdinalIgnoreCase)) + return true; + + // Prefer OS/system proxy (gsettings already restored) over a dead fixed endpoint. + root[ProxyKey] = new JsonObject { ["mode"] = "system" }; + AtomicWriteJson(prefsPath, root); + + // Drop backup if strip replaced our endpoint — original restore already attempted. + var backupPath = prefsPath + BackupSuffix; + if (File.Exists(backupPath)) + { + try { File.Delete(backupPath); } catch { /* ignore */ } + } + + var verify = File.ReadAllText(prefsPath); + return !verify.Contains($"{hostname}:{port}", StringComparison.Ordinal); + } + catch + { + return false; + } + } + + private static JsonObject BuildFixedServersProxy(string hostname, int port) => + new() + { + ["mode"] = FixedServersMode, + ["server"] = $"http://{hostname}:{port}", // NOSONAR S5332 -- Chromium proxy.mode fixed_servers requires an http proxy URL. + ["bypass_list"] = LinuxBrowserLaunchProxy.ProxyBypassList, + }; + + private static void AtomicWriteJson(string prefsPath, JsonObject root) + { + var tmp = prefsPath + ".tmp"; + File.WriteAllText(tmp, root.ToJsonString(new JsonSerializerOptions { WriteIndented = false })); + File.Move(tmp, prefsPath, overwrite: true); + } + + private static void ScheduleClearRetries_NoLock(string? host, int port) + { + CancelClearRetries_NoLock(); + if (string.IsNullOrEmpty(host) || port <= 0) + return; + + var cts = new CancellationTokenSource(); + _clearRetryCts = cts; + var token = cts.Token; + _ = Task.Run(async () => + { + // Chrome often flushes Preferences shortly after Inspector restores them. + foreach (var delayMs in ClearRetryDelaysMs) + { + try + { + await Task.Delay(delayMs, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + + lock (Gate) + { + if (token.IsCancellationRequested || _activeHost is not null) + return; + foreach (var prefsPath in EnumeratePreferencesPaths()) + TryStripInspectorProxy(prefsPath, host, port); + } + } + }, token); + } + + private static void CancelClearRetries_NoLock() + { + try + { + _clearRetryCts?.Cancel(); + _clearRetryCts?.Dispose(); + } + catch + { + // ignore + } + + _clearRetryCts = null; + } + + private static string? MarkerPath() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(home)) + return null; + return Path.Combine(home, ConfigDirName, "TitaniumInspector", MarkerFileName); + } + + private static void WriteMarker(string hostname, int port) + { + try + { + var path = MarkerPath(); + if (path is null) + return; + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, + JsonSerializer.Serialize(new { hostname, port })); + } + catch + { + // ignore + } + } + + private static bool TryReadMarker(out string hostname, out int port) + { + hostname = string.Empty; + port = 0; + try + { + var path = MarkerPath(); + if (path is null || !File.Exists(path)) + return false; + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + if (!doc.RootElement.TryGetProperty("hostname", out var h) || + !doc.RootElement.TryGetProperty("port", out var p)) + return false; + hostname = h.GetString() ?? string.Empty; + port = p.GetInt32(); + return !string.IsNullOrWhiteSpace(hostname) && port > 0; + } + catch + { + return false; + } + } + + private static void DeleteMarker() + { + try + { + var path = MarkerPath(); + if (path is not null && File.Exists(path)) + File.Delete(path); + } + catch + { + // ignore + } + } + + private static void RestartWatchers_NoLock() + { + StopWatchers_NoLock(); + if (string.IsNullOrEmpty(_activeHost) || _activePort <= 0) + return; + + foreach (var prefsPath in EnumeratePreferencesPaths().Where(File.Exists).Distinct()) + { + try + { + var dir = Path.GetDirectoryName(prefsPath)!; + var name = Path.GetFileName(prefsPath); + var watcher = new FileSystemWatcher(dir, name) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName, + EnableRaisingEvents = true, + }; + watcher.Changed += OnPreferencesChanged; + watcher.Created += OnPreferencesChanged; + Watchers.Add(watcher); + } + catch + { + // ignore + } + } + } + + private static void StopWatchers_NoLock() + { + foreach (var watcher in Watchers) + { + try + { + watcher.EnableRaisingEvents = false; + watcher.Changed -= OnPreferencesChanged; + watcher.Created -= OnPreferencesChanged; + watcher.Dispose(); + } + catch + { + // ignore + } + } + + Watchers.Clear(); + } + + private static void OnPreferencesChanged(object sender, FileSystemEventArgs e) + { + _ = Task.Run(async () => + { + try + { + await Task.Delay(250, _clearRetryCts?.Token ?? CancellationToken.None).ConfigureAwait(false); + ReassertIfNeeded(e.FullPath); + } + catch + { + // ignore + } + }, _clearRetryCts?.Token ?? CancellationToken.None); + } + + private static void ReassertIfNeeded(string fullPath) + { + string? host; + int port; + lock (Gate) + { + host = _activeHost; + port = _activePort; + } + + if (string.IsNullOrEmpty(host) || port <= 0) + return; + + try + { + if (!File.Exists(fullPath)) + return; + var text = File.ReadAllText(fullPath); + if (text.Contains(FixedServersMode, StringComparison.Ordinal) && + text.Contains($"{host}:{port}", StringComparison.Ordinal)) + return; + + lock (Gate) + { + if (_activeHost != host || _activePort != port) + return; + TryWritePreferences(fullPath, host, port); + } + } + catch + { + // ignore + } + } +} diff --git a/src/Titanium.Web.Proxy/Helpers/LinuxChromiumRelaunch.cs b/src/Titanium.Web.Proxy/Helpers/LinuxChromiumRelaunch.cs new file mode 100644 index 000000000..078011343 --- /dev/null +++ b/src/Titanium.Web.Proxy/Helpers/LinuxChromiumRelaunch.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace Titanium.Web.Proxy.Helpers; + +/// +/// Chrome/Chromium/Edge ignore live Preference file edits and often ignore gsettings. +/// When system proxy is toggled, quit and relaunch each running Chromium-family browser +/// with session restore so traffic switches immediately. +/// +internal static class LinuxChromiumRelaunch +{ + private enum BrowserFamily + { + Chrome, + Chromium, + Brave, + Edge, + } + + private static readonly (BrowserFamily Family, string[] Binaries)[] FamilyLaunchers = + [ + (BrowserFamily.Chrome, + [ + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + "/opt/google/chrome/google-chrome", + ]), + (BrowserFamily.Chromium, + [ + "/usr/bin/chromium-browser", + "/usr/bin/chromium", + ]), + (BrowserFamily.Brave, + [ + "/usr/bin/brave-browser", + ]), + (BrowserFamily.Edge, + [ + "/usr/bin/microsoft-edge-stable", + "/usr/bin/microsoft-edge", + ]), + ]; + + /// + /// If a Chromium-family browser is running, quit it and relaunch with optional proxy flags + /// and --restore-last-session. Returns true when a relaunch was scheduled. + /// Runs detached (setsid) so SIGTERM/app exit cannot abort mid-quit/relaunch. + /// Relaunches each running browser family with that family's own binary (not always Chrome). + /// + internal static bool TryRelaunchForProxyChange(string? hostname, int port, bool enableProxy) + { + var mains = FindMainBrowsers().ToList(); + if (mains.Count == 0) + return false; + + var families = mains.Select(m => m.Family).Distinct().OrderBy(f => f).ToList(); + var launchLines = new List(); + foreach (var family in families) + { + var launch = ResolveLaunchBinary(family); + if (string.IsNullOrEmpty(launch)) + continue; + launchLines.Add(launch); + } + + if (launchLines.Count == 0) + return false; + + var args = "--restore-last-session --disable-quic"; + if (enableProxy && !string.IsNullOrWhiteSpace(hostname) && port > 0) + args = LinuxBrowserLaunchProxy.ChromeProxyFlags(hostname, port) + " " + args; + + var display = LinuxGraphicalSession.TryGetDisplay() + ?? Environment.GetEnvironmentVariable("DISPLAY") + ?? string.Empty; + var dbus = LinuxGraphicalSession.TryGetDbusSessionAddress() + ?? Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS") + ?? string.Empty; + if (!LinuxGraphicalSession.IsUsableDbusAddress(dbus)) + dbus = string.Empty; + var xauth = Environment.GetEnvironmentVariable("XAUTHORITY") ?? string.Empty; + if (string.IsNullOrWhiteSpace(xauth)) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var candidate = Path.Combine(home, ".Xauthority"); + if (File.Exists(candidate)) + xauth = candidate; + } + + try + { + var scriptPath = Path.Combine(Path.GetTempPath(), + $"titanium-chrome-relaunch-{Environment.ProcessId}-{Guid.NewGuid():N}.sh"); + var script = BuildRelaunchScript(scriptPath, mains, launchLines, args, display, dbus, xauth, enableProxy); + File.WriteAllText(scriptPath, script.Replace("\r\n", "\n")); + TryMakeExecutable(scriptPath); + StartDetachedScript(scriptPath); + return true; + } + catch + { + return false; + } + } + + private static string BuildRelaunchScript( // NOSONAR S107 -- Script fields map 1:1 onto the generated shell. + string scriptPath, + List<(int Pid, BrowserFamily Family)> mains, + List launchLines, + string args, + string display, + string dbus, + string xauth, + bool enableProxy) + { + var pidList = string.Join(" ", mains.Select(m => m.Pid)); + var quitCmds = string.Join("\n", launchLines.Select(l => + $"{ShellQuote(l)} --quit >/dev/null 2>&1 || true")); + var startCmds = string.Join("\n", launchLines.Select(l => + $"# shellcheck disable=SC2086\n{ShellQuote(l)} $ARGS >/dev/null 2>&1 &")); + + return $$""" + #!/bin/bash + set +e + ARGS={{ShellQuote(args)}} + DISPLAY_VAL={{ShellQuote(display)}} + DBUS_VAL={{ShellQuote(dbus)}} + XAUTH_VAL={{ShellQuote(xauth)}} + ENABLE={{(enableProxy ? "1" : "0")}} + PIDS="{{pidList}}" + [ -n "$DISPLAY_VAL" ] && export DISPLAY="$DISPLAY_VAL" + [ -n "$DBUS_VAL" ] && export DBUS_SESSION_BUS_ADDRESS="$DBUS_VAL" + [ -n "$XAUTH_VAL" ] && export XAUTHORITY="$XAUTH_VAL" + {{quitCmds}} + for i in $(seq 1 40); do + alive=0 + for p in $PIDS; do [ -d "/proc/$p" ] && alive=1; done + [ "$alive" = "0" ] && break + sleep 0.2 + done + find_mains() { + for d in /proc/[0-9]*; do + pid=${d##*/} + raw=$(tr '\0' ' ' < "$d/cmdline" 2>/dev/null) + [ -z "$raw" ] && continue + case "$raw" in + *--type=*|*crashpad*|*nacl_helper*|*devtools-mcp*|*chrome-devtools-mcp*|*/cursor/*) continue ;; + esac + exe=${raw%% *} + base=${exe##*/} + case "$exe" in + /opt/google/chrome/chrome|/usr/lib/chromium-browser/chromium-browser|/usr/lib/chromium/chromium|/usr/lib/brave.com/brave/brave|/opt/brave.com/brave/brave|/opt/microsoft/msedge/msedge) echo "$pid" ;; + *) case "$base" in chrome|chromium|chromium-browser|brave|msedge) echo "$pid" ;; esac ;; + esac + done + } + for p in $(find_mains); do kill -TERM "$p" 2>/dev/null || true; done + sleep 1 + for p in $(find_mains); do kill -KILL "$p" 2>/dev/null || true; done + sleep 0.5 + {{startCmds}} + if [ "$ENABLE" = "0" ]; then + pidf="$HOME/.config/TitaniumInspector/fail-open-proxy.pid" + if [ -f "$pidf" ]; then + kill "$(cat "$pidf")" 2>/dev/null || true + rm -f "$pidf" + fi + pkill -f 'fail-open-proxy.py' 2>/dev/null || true + fi + rm -f -- {{ShellQuote(scriptPath)}} + """; + } + + private static void TryMakeExecutable(string scriptPath) + { + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + return; + try + { + File.SetUnixFileMode(scriptPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + catch + { + // best-effort + } + } + + private static void StartDetachedScript(string scriptPath) + { + Process.Start(new ProcessStartInfo + { + FileName = "/usr/bin/setsid", + UseShellExecute = false, + CreateNoWindow = true, + ArgumentList = { "-f", "/bin/bash", scriptPath }, + })?.Dispose(); + } + + /// Test hook: resolve launch binary for a running executable path. + internal static string? ResolveLaunchBinaryForExeForTests(string exe) => + TryClassify(exe, out var family) ? ResolveLaunchBinary(family) : null; + + /// Test hook: classify main browser executable into a family name. + internal static string? ClassifyFamilyForTests(string exe) => + TryClassify(exe, out var family) ? family.ToString() : null; + + private static string? ResolveLaunchBinary(BrowserFamily family) + { + var entry = FamilyLaunchers.FirstOrDefault(f => f.Family == family); + return entry.Binaries?.FirstOrDefault(File.Exists); + } + + private static string ShellQuote(string value) => + "'" + (value ?? string.Empty).Replace("'", "'\\''", StringComparison.Ordinal) + "'"; + + private static IEnumerable<(int Pid, BrowserFamily Family)> FindMainBrowsers() // NOSONAR S3776 -- /proc walk identifies Chromium mains without extra process snapshots. + { + IEnumerable dirs; + try { dirs = Directory.EnumerateDirectories("/proc"); } + catch { yield break; } + + foreach (var dir in dirs) + { + if (!int.TryParse(Path.GetFileName(dir), out var pid) || pid <= 1) + continue; + + string raw; + try + { + raw = File.ReadAllText(Path.Combine(dir, "cmdline")); + } + catch + { + continue; + } + + if (string.IsNullOrEmpty(raw)) + continue; + + var full = raw.Replace('\0', ' ').Trim(); + var exe = raw.Split('\0', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty; + if (exe.Contains(' ', StringComparison.Ordinal)) + exe = exe.Split(' ', StringSplitOptions.RemoveEmptyEntries)[0]; + + if (string.IsNullOrEmpty(exe)) + continue; + + if (full.Contains("--type=", StringComparison.Ordinal) || + exe.Contains("crashpad", StringComparison.OrdinalIgnoreCase) || + exe.Contains("nacl_helper", StringComparison.OrdinalIgnoreCase) || + full.Contains("devtools-mcp", StringComparison.OrdinalIgnoreCase) || + full.Contains("chrome-devtools-mcp", StringComparison.OrdinalIgnoreCase) || + exe.Contains("/cursor/", StringComparison.OrdinalIgnoreCase)) + continue; + + if (TryClassify(exe, out var family)) + yield return (pid, family); + } + } + + private static bool TryClassify(string exe, out BrowserFamily family) + { + family = default; + var name = Path.GetFileName(exe); + + if (exe is "/opt/microsoft/msedge/msedge" || + name is "msedge" || + exe.Contains("microsoft-edge", StringComparison.OrdinalIgnoreCase) || + exe.Contains("/msedge/", StringComparison.OrdinalIgnoreCase)) + { + family = BrowserFamily.Edge; + return true; + } + + if (exe.Contains("brave", StringComparison.OrdinalIgnoreCase) || name is "brave") + { + family = BrowserFamily.Brave; + return true; + } + + if (exe is "/usr/lib/chromium-browser/chromium-browser" or "/usr/lib/chromium/chromium" || + name is "chromium" or "chromium-browser") + { + family = BrowserFamily.Chromium; + return true; + } + + if (exe is "/opt/google/chrome/chrome" || name is "chrome") + { + family = BrowserFamily.Chrome; + return true; + } + + return false; + } +} diff --git a/src/Titanium.Web.Proxy/Helpers/LinuxFirefoxProxy.cs b/src/Titanium.Web.Proxy/Helpers/LinuxFirefoxProxy.cs new file mode 100644 index 000000000..524271b9a --- /dev/null +++ b/src/Titanium.Web.Proxy/Helpers/LinuxFirefoxProxy.cs @@ -0,0 +1,414 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using Titanium.Web.Proxy.Network; + +namespace Titanium.Web.Proxy.Helpers; + +/// +/// Firefox often ignores GNOME/KDE system proxy. Write manual network.proxy.* prefs into +/// the default profile and quit/relaunch so enable/disable takes effect immediately. +/// +internal static partial class LinuxFirefoxProxy +{ + private const string BackupFileName = "firefox-proxy-backup.json"; + private const string MarkerPref = "titanium.inspector.proxy.managed"; + private const string NetworkProxyTypeKey = "network.proxy.type"; + private const string NetworkProxyHttpKey = "network.proxy.http"; + private const string NetworkProxyHttpPortKey = "network.proxy.http_port"; + private const string NetworkProxySslKey = "network.proxy.ssl"; + private const string NetworkProxySslPortKey = "network.proxy.ssl_port"; + private const string NetworkProxyShareKey = "network.proxy.share_proxy_settings"; + private const string NetworkProxyBypassKey = "network.proxy.no_proxies_on"; + + [GeneratedRegex( + @"^\s*user_pref\(\s*""(?[^""]+)""\s*,\s*(?.+?)\s*\)\s*;\s*$", + RegexOptions.CultureInvariant, + matchTimeoutMilliseconds: 250)] + private static partial Regex UserPrefLine(); + + private static readonly string[] ManagedProxyKeys = + [ + NetworkProxyTypeKey, + NetworkProxyHttpKey, + NetworkProxyHttpPortKey, + NetworkProxySslKey, + NetworkProxySslPortKey, + NetworkProxyShareKey, + NetworkProxyBypassKey, + MarkerPref, + ]; + + private static readonly string[] SnapshotProxyKeys = + [ + NetworkProxyTypeKey, + NetworkProxyHttpKey, + NetworkProxyHttpPortKey, + NetworkProxySslKey, + NetworkProxySslPortKey, + NetworkProxyShareKey, + NetworkProxyBypassKey, + ]; + + private static readonly string[] FirefoxProcessNames = ["firefox", "firefox-bin"]; + private static readonly string[] FirefoxLaunchCandidates = + [ + "/usr/bin/firefox", + "/usr/bin/firefox-esr", + "/snap/bin/firefox", + ]; + + /// Applies manual proxy prefs; returns true when prefs were written. + internal static bool Apply(string hostname, int port, string? winInetProxyOverride = null) + { + if (!OperatingSystem.IsLinux()) + return false; + if (string.IsNullOrWhiteSpace(hostname) || port <= 0) + return false; + if (!FirefoxCertificateTrust.TryResolveDefaultProfileDirectory(out var profileDir, out _)) + return false; + + var prefsPath = Path.Combine(profileDir, "prefs.js"); + try + { + Directory.CreateDirectory(profileDir); + var existing = File.Exists(prefsPath) ? File.ReadAllText(prefsPath) : string.Empty; + BackupIfNeeded(existing); + + var bypass = BuildFirefoxBypassList(winInetProxyOverride); + var managed = new Dictionary(StringComparer.Ordinal) + { + [NetworkProxyTypeKey] = "1", + [NetworkProxyHttpKey] = JsonSerializer.Serialize(hostname), + [NetworkProxyHttpPortKey] = port.ToString(), + [NetworkProxySslKey] = JsonSerializer.Serialize(hostname), + [NetworkProxySslPortKey] = port.ToString(), + [NetworkProxyShareKey] = "true", + [NetworkProxyBypassKey] = JsonSerializer.Serialize(bypass), + [MarkerPref] = "true", + }; + + File.WriteAllText(prefsPath, MergePrefs(existing, managed)); + TryRelaunchFirefox(enableProxy: true); + return true; + } + catch + { + return false; + } + } + + /// Restores prior prefs (or clears managed manual proxy) and relaunches Firefox if needed. + internal static void Clear() + { + if (!OperatingSystem.IsLinux()) + return; + if (!FirefoxCertificateTrust.TryResolveDefaultProfileDirectory(out var profileDir, out _)) + { + DeleteBackup(); + return; + } + + var prefsPath = Path.Combine(profileDir, "prefs.js"); + try + { + if (!File.Exists(prefsPath)) + { + DeleteBackup(); + return; + } + + var existing = File.ReadAllText(prefsPath); + if (!existing.Contains(MarkerPref, StringComparison.Ordinal) && !HasBackup()) + return; + + string restored; + if (TryReadBackup(out var backup) && backup.Count > 0) + { + // Remove managed keys then re-apply snapshotted values (missing key = delete). + var withoutManaged = RemoveKeys(existing, ManagedProxyKeys); + restored = MergePrefs(withoutManaged, backup); + restored = RemoveKeys(restored, [MarkerPref]); + } + else + { + // No backup: fall back to system proxy settings. + restored = MergePrefs(RemoveKeys(existing, [MarkerPref]), new Dictionary + { + [NetworkProxyTypeKey] = "5", + }); + } + + File.WriteAllText(prefsPath, restored); + DeleteBackup(); + TryRelaunchFirefox(enableProxy: false); + } + catch + { + // best-effort + } + } + + /// Test hook: merge managed prefs into prefs.js text. + internal static string MergePrefsForTests(string existing, IReadOnlyDictionary managed) => + MergePrefs(existing, managed); + + /// Test hook: remove keys from prefs.js text. + internal static string RemoveKeysForTests(string existing, IEnumerable keys) => + RemoveKeys(existing, keys); + + /// Test hook: Firefox bypass list formatting. + internal static string BuildFirefoxBypassListForTests(string? winInetProxyOverride) => + BuildFirefoxBypassList(winInetProxyOverride); + + private static string BuildFirefoxBypassList(string? winInetProxyOverride) + { + var hosts = UnixProxyBypassMapper.ToUnixBypassHosts(winInetProxyOverride).ToList(); + if (hosts.Count == 0) + return "localhost, 127.0.0.1"; + return string.Join(", ", hosts); + } + + private static string MergePrefs(string existing, IReadOnlyDictionary values) + { + var lines = new List(); + var seen = new HashSet(StringComparer.Ordinal); + using var reader = new StringReader(existing ?? string.Empty); + string? line; + while ((line = reader.ReadLine()) != null) + { + var match = UserPrefLine().Match(line); + if (match.Success) + { + var key = match.Groups["key"].Value; + if (values.TryGetValue(key, out var replacement)) + { + lines.Add($"user_pref(\"{key}\", {replacement});"); + seen.Add(key); + continue; + } + } + + lines.Add(line); + } + + foreach (var (key, value) in values) + { + if (seen.Contains(key)) + continue; + lines.Add($"user_pref(\"{key}\", {value});"); + } + + var sb = new StringBuilder(); + foreach (var l in lines) + sb.AppendLine(l); + return sb.ToString(); + } + + private static string RemoveKeys(string existing, IEnumerable keys) + { + var remove = new HashSet(keys, StringComparer.Ordinal); + var sb = new StringBuilder(); + using var reader = new StringReader(existing ?? string.Empty); + string? line; + while ((line = reader.ReadLine()) != null) + { + var match = UserPrefLine().Match(line); + if (match.Success && remove.Contains(match.Groups["key"].Value)) + continue; + sb.AppendLine(line); + } + + return sb.ToString(); + } + + private static void BackupIfNeeded(string existingPrefs) + { + if (HasBackup()) + return; + + var snapshotKeys = new HashSet(SnapshotProxyKeys, StringComparer.Ordinal); + var snapshot = new Dictionary(StringComparer.Ordinal); + using var reader = new StringReader(existingPrefs ?? string.Empty); + string? line; + while ((line = reader.ReadLine()) != null) + { + var match = UserPrefLine().Match(line); + if (!match.Success) + continue; + var key = match.Groups["key"].Value; + if (snapshotKeys.Contains(key)) + snapshot[key] = match.Groups["value"].Value.Trim(); + } + + try + { + var path = BackupPath(); + if (path is null) + return; + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, JsonSerializer.Serialize(snapshot)); + } + catch + { + // ignore + } + } + + private static bool TryReadBackup(out Dictionary backup) + { + backup = new Dictionary(StringComparer.Ordinal); + try + { + var path = BackupPath(); + if (path is null || !File.Exists(path)) + return false; + var doc = JsonSerializer.Deserialize>(File.ReadAllText(path)); + if (doc is null) + return false; + backup = new Dictionary(doc, StringComparer.Ordinal); + return true; + } + catch + { + return false; + } + } + + private static bool HasBackup() + { + var path = BackupPath(); + return path is not null && File.Exists(path); + } + + private static void DeleteBackup() + { + try + { + var path = BackupPath(); + if (path is not null && File.Exists(path)) + File.Delete(path); + } + catch + { + // ignore + } + } + + private static string? BackupPath() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(home)) + return null; + return Path.Combine(home, ".config", "TitaniumInspector", BackupFileName); + } + + private static void TryRelaunchFirefox(bool enableProxy) + { + try + { + var wasRunning = FirefoxCertificateTrust.IsFirefoxProcessRunning(); + if (!wasRunning) + return; + + FirefoxCertificateTrust.TryRequestFirefoxQuit(TimeSpan.FromSeconds(8)); + + // Force remaining firefox processes if still up (proxy switch must not leave stale prefs in memory). + if (FirefoxCertificateTrust.IsFirefoxProcessRunning()) + ForceKillFirefoxProcesses(); + + var launch = ResolveFirefoxLaunch(); + if (string.IsNullOrEmpty(launch)) + return; + + _ = enableProxy; // reserved for future proxy-specific launch flags + StartDetachedFirefox(launch); + } + catch + { + // best-effort + } + } + + private static void ForceKillFirefoxProcesses() + { + foreach (var name in FirefoxProcessNames) + { + try + { + foreach (var p in Process.GetProcessesByName(name)) + { + try { p.Kill(entireProcessTree: true); } catch { /* ignore */ } + finally { p.Dispose(); } + } + } + catch + { + // ignore + } + } + } + + private static void StartDetachedFirefox(string launch) + { + var display = LinuxGraphicalSession.TryGetDisplay() + ?? Environment.GetEnvironmentVariable("DISPLAY") + ?? string.Empty; + var dbus = LinuxGraphicalSession.TryGetDbusSessionAddress() + ?? Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS") + ?? string.Empty; + if (!LinuxGraphicalSession.IsUsableDbusAddress(dbus)) + dbus = string.Empty; + var xauth = Environment.GetEnvironmentVariable("XAUTHORITY") ?? string.Empty; + + var scriptPath = Path.Combine(Path.GetTempPath(), + $"titanium-firefox-relaunch-{Environment.ProcessId}-{Guid.NewGuid():N}.sh"); + var script = $""" + #!/bin/bash + set +e + [ -n {ShellQuote(display)} ] && export DISPLAY={ShellQuote(display)} + [ -n {ShellQuote(dbus)} ] && export DBUS_SESSION_BUS_ADDRESS={ShellQuote(dbus)} + [ -n {ShellQuote(xauth)} ] && export XAUTHORITY={ShellQuote(xauth)} + {ShellQuote(launch)} --new-instance >/dev/null 2>&1 & + rm -f -- {ShellQuote(scriptPath)} + """; + File.WriteAllText(scriptPath, script.Replace("\r\n", "\n")); + TryMakeExecutable(scriptPath); + + Process.Start(new ProcessStartInfo + { + FileName = "/usr/bin/setsid", + UseShellExecute = false, + CreateNoWindow = true, + ArgumentList = { "-f", "/bin/bash", scriptPath }, + })?.Dispose(); + } + + private static void TryMakeExecutable(string scriptPath) + { + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + return; + try + { + File.SetUnixFileMode(scriptPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + catch + { + // best-effort + } + } + + private static string? ResolveFirefoxLaunch() + { + return FirefoxLaunchCandidates.FirstOrDefault(File.Exists); + } + + private static string ShellQuote(string value) => + "'" + (value ?? string.Empty).Replace("'", "'\\''", StringComparison.Ordinal) + "'"; +} diff --git a/src/Titanium.Web.Proxy/Helpers/LinuxGraphicalSession.cs b/src/Titanium.Web.Proxy/Helpers/LinuxGraphicalSession.cs new file mode 100644 index 000000000..d2681578d --- /dev/null +++ b/src/Titanium.Web.Proxy/Helpers/LinuxGraphicalSession.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Titanium.Web.Proxy.Helpers; + +/// +/// Discover DISPLAY / DBUS from the active graphical login (XFCE, GNOME, KDE, xrdp, etc.). +/// Inspector may be started from Cursor/IDE with a poisoned or missing session bus; gsettings +/// must still target the desktop the user is actually using. +/// +internal static class LinuxGraphicalSession +{ + private static readonly string[] SessionProcessNames = + [ + "xfce4-session", + "gnome-session", + "gnome-session-binary", + "gnome-session-b", + "plasmashell", + "startplasma-x11", + "startplasma-wayland", + "cinnamon-session", + "mate-session", + "lxqt-session", + "budgie-desktop", + "xrdp-chansrv", + "x-session-manager", + ]; + + internal static string? TryGetDbusSessionAddress() => + FirstNonEmpty( + Environment.GetEnvironmentVariable("DBUS_SESSION_BUS_ADDRESS"), + TryReadFromSessionProcess("DBUS_SESSION_BUS_ADDRESS")); + + internal static string? TryGetDisplay() => + FirstNonEmpty( + Environment.GetEnvironmentVariable("DISPLAY"), + TryReadFromSessionProcess("DISPLAY")); + + internal static string? TryReadFromSessionProcess(string variableName) + { + foreach (var pid in EnumerateCandidateSessionPids()) + { + var value = TryReadEnvironVariable(pid, variableName); + if (!string.IsNullOrWhiteSpace(value) && + (variableName != "DBUS_SESSION_BUS_ADDRESS" || IsUsableDbusAddress(value))) + { + return value; + } + } + + return null; + } + + internal static bool IsUsableDbusAddress(string? address) => + !string.IsNullOrWhiteSpace(address) && + !address.StartsWith("disabled", StringComparison.OrdinalIgnoreCase); + + internal static IEnumerable EnumerateCandidateSessionPids() + { + IEnumerable dirs; + try + { + dirs = Directory.EnumerateDirectories("/proc"); + } + catch + { + yield break; + } + + foreach (var dir in dirs) + { + if (!int.TryParse(Path.GetFileName(dir), out var pid) || pid <= 1) + continue; + + string? comm = null; + try + { + comm = File.ReadAllText(Path.Combine(dir, "comm")).Trim(); + } + catch + { + continue; + } + + if (SessionProcessNames.Any(name => + comm.Equals(name, StringComparison.OrdinalIgnoreCase) || + comm.StartsWith(name, StringComparison.OrdinalIgnoreCase))) + { + yield return pid; + } + } + } + + internal static string? TryReadEnvironVariable(int pid, string variableName) + { + try + { + var raw = File.ReadAllBytes($"/proc/{pid}/environ"); + var start = 0; + for (var i = 0; i <= raw.Length; i++) + { + if (i != raw.Length && raw[i] != 0) + continue; + + var len = i - start; + if (len > 0) + { + var entry = System.Text.Encoding.UTF8.GetString(raw, start, len); + var eq = entry.IndexOf('='); + if (eq > 0 && + entry.AsSpan(0, eq).SequenceEqual(variableName.AsSpan())) + { + return entry[(eq + 1)..]; + } + } + + start = i + 1; + } + } + catch + { + // not readable or process exited + } + + return null; + } + + private static string? FirstNonEmpty(params string?[] values) => + values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); +} diff --git a/src/Titanium.Web.Proxy/Helpers/LinuxProcNetTcp.cs b/src/Titanium.Web.Proxy/Helpers/LinuxProcNetTcp.cs new file mode 100644 index 000000000..3a5220411 --- /dev/null +++ b/src/Titanium.Web.Proxy/Helpers/LinuxProcNetTcp.cs @@ -0,0 +1,169 @@ +using System; +using System.Globalization; +using System.IO; + +namespace Titanium.Web.Proxy.Helpers; + +/// +/// Parses Linux /proc/net/tcp / /proc/net/tcp6 lines and resolves socket inodes to PIDs. +/// +internal static class LinuxProcNetTcp +{ + /// + /// Tries to parse local port (host order) and inode from a single /proc/net/tcp[6] data line. + /// + internal static bool TryParseLocalPortAndInode(ReadOnlySpan line, out int localPort, out long inode) + { + localPort = 0; + inode = 0; + + line = line.Trim(); + if (line.IsEmpty || line.StartsWith("sl", StringComparison.Ordinal)) + { + return false; + } + + // sl local_address rem_address st ... uid timeout inode + var rest = line; + if (!TryTakeField(ref rest, out _)) + { + return false; + } + + if (!TryTakeField(ref rest, out var localAddress)) + { + return false; + } + + var colon = localAddress.LastIndexOf(':'); + if (colon <= 0 || colon >= localAddress.Length - 1) + { + return false; + } + + if (!int.TryParse(localAddress[(colon + 1)..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, + out localPort)) + { + return false; + } + + // rem_address, st, tx_queue:rx_queue, tr:tm->when, retrnsmt, uid, timeout, inode + for (var i = 0; i < 7; i++) + { + if (!TryTakeField(ref rest, out _)) + { + return false; + } + } + + if (!TryTakeField(ref rest, out var inodeField)) + { + return false; + } + + return long.TryParse(inodeField, NumberStyles.Integer, CultureInfo.InvariantCulture, out inode) && inode > 0; + } + + /// + /// Finds the inode for in a /proc/net/tcp[6] file body. + /// + internal static bool TryFindInodeForLocalPort(string procNetTcpContents, int localPort, out long inode) + { + inode = 0; + using var reader = new StringReader(procNetTcpContents); + string? line; + while ((line = reader.ReadLine()) is not null) + { + if (TryParseLocalPortAndInode(line, out var port, out var found) && port == localPort) + { + inode = found; + return true; + } + } + + return false; + } + + /// + /// Resolves a socket inode to an owning process id by scanning /proc/*/fd. + /// + internal static int FindProcessIdByInode(long inode, string procRoot = "/proc") // NOSONAR S3776 -- /proc fd inode walk is a single namespace scan. + { + if (inode <= 0) + { + return 0; + } + + var needle = $"socket:[{inode}]"; + string[] pidDirs; + try + { + pidDirs = Directory.GetDirectories(procRoot); + } + catch + { + return 0; + } + + foreach (var pidDir in pidDirs) + { + var dirName = Path.GetFileName(pidDir); + if (!int.TryParse(dirName, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid) || pid <= 0) + { + continue; + } + + string fdDir = Path.Combine(pidDir, "fd"); + string[] fds; + try + { + fds = Directory.GetFiles(fdDir); + } + catch + { + continue; + } + + foreach (var fdPath in fds) + { + try + { + var target = new FileInfo(fdPath).LinkTarget; + if (target is not null && + target.Equals(needle, StringComparison.Ordinal)) + { + return pid; + } + } + catch + { + // Permission or raced exit — skip. + } + } + } + + return 0; + } + + private static bool TryTakeField(ref ReadOnlySpan rest, out ReadOnlySpan field) + { + rest = rest.TrimStart(); + if (rest.IsEmpty) + { + field = default; + return false; + } + + var space = rest.IndexOfAny(' ', '\t'); + if (space < 0) + { + field = rest; + rest = default; + return true; + } + + field = rest[..space]; + rest = rest[(space + 1)..]; + return true; + } +} diff --git a/src/Titanium.Web.Proxy/Helpers/LinuxProxyFailOpen.cs b/src/Titanium.Web.Proxy/Helpers/LinuxProxyFailOpen.cs new file mode 100644 index 000000000..c8c314cb6 --- /dev/null +++ b/src/Titanium.Web.Proxy/Helpers/LinuxProxyFailOpen.cs @@ -0,0 +1,209 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; + +namespace Titanium.Web.Proxy.Helpers; + +/// +/// After Inspector releases :port, a still-running Chrome with Preferences +/// fixed_servers keeps dialing that port. Spawn a tiny detached CONNECT/HTTP +/// tunnel so browsing does not break until Chrome is next restarted. +/// +internal static class LinuxProxyFailOpen +{ + private const string PidFileName = "fail-open-proxy.pid"; + private const string ScriptFileName = "fail-open-proxy.py"; + + internal static void Stop() + { + try + { + var pidPath = PidPath(); + if (pidPath is null || !File.Exists(pidPath)) + return; + if (int.TryParse(File.ReadAllText(pidPath).Trim(), out var pid) && pid > 1) + { + var kill = UnixProcessPath.Resolve("/bin/kill", "/usr/bin/kill"); + if (kill is not null) + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = kill, + Arguments = $"-TERM {pid}", + UseShellExecute = false, + CreateNoWindow = true, + })?.WaitForExit(2000); + } + catch + { + // ignore + } + } + } + + try { File.Delete(pidPath); } catch { /* ignore */ } + } + catch + { + // ignore + } + } + + internal static void Start(string hostname, int port) + { + Stop(); + try + { + var dir = ConfigDir(); + if (dir is null) + return; + Directory.CreateDirectory(dir); + var script = Path.Combine(dir, ScriptFileName); + File.WriteAllText(script, FailOpenScript); + var pidPath = Path.Combine(dir, PidFileName); + var python = UnixProcessPath.Resolve("/usr/bin/python3", "/usr/local/bin/python3"); + if (python is null) + return; + + var psi = new ProcessStartInfo + { + FileName = python, + Arguments = Quote(script) + " " + Quote(hostname) + " " + port + " " + Quote(pidPath), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = dir, + }; + // Detach: do not wait; child double-forks in the script. + Process.Start(psi)?.Dispose(); + } + catch + { + // best-effort — Preferences strip still runs for the next Chrome start + } + } + + private static string? ConfigDir() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(home)) + return null; + return Path.Combine(home, ".config", "TitaniumInspector"); + } + + private static string? PidPath() + { + var dir = ConfigDir(); + return dir is null ? null : Path.Combine(dir, PidFileName); + } + + private static string Quote(string value) => + "\"" + value.Replace("\"", "\\\"", StringComparison.Ordinal) + "\""; + + // Minimal HTTP/HTTPS forwarder: CONNECT tunnels + absolute-form HTTP requests. + private const string FailOpenScript = + """ + #!/usr/bin/env python3 + import os, socket, select, sys, time + + host, port, pid_path = sys.argv[1], int(sys.argv[2]), sys.argv[3] + + if os.fork() > 0: + sys.exit(0) + os.setsid() + if os.fork() > 0: + sys.exit(0) + with open(pid_path, "w") as f: + f.write(str(os.getpid())) + + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + for _ in range(80): + try: + srv.bind((host, port)) + break + except OSError: + time.sleep(0.15) + else: + sys.exit(1) + srv.listen(128) + + def pipe(a, b): + try: + while True: + r, _, _ = select.select([a, b], [], [], 120) + if not r: + break + for s in r: + data = s.recv(65536) + if not data: + return + (b if s is a else a).sendall(data) + except Exception: + pass + finally: + try: a.close() + except Exception: pass + try: b.close() + except Exception: pass + + def handle(c): + try: + c.settimeout(30) + buf = b"" + while b"\r\n\r\n" not in buf and len(buf) < 65536: + chunk = c.recv(4096) + if not chunk: + c.close(); return + buf += chunk + head, _, rest = buf.partition(b"\r\n\r\n") + lines = head.split(b"\r\n") + req = lines[0].decode("latin1", "replace") + parts = req.split(" ") + if len(parts) < 2: + c.close(); return + method, target = parts[0].upper(), parts[1] + if method == "CONNECT": + hostport = target + if ":" in hostport: + h, p = hostport.rsplit(":", 1) + p = int(p) + else: + h, p = hostport, 443 + up = socket.create_connection((h, p), timeout=30) + c.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n") + if rest: + up.sendall(rest) + pipe(c, up) + else: + # absolute-form: GET http://host/path HTTP/1.1 + from urllib.parse import urlsplit + u = urlsplit(target) + h = u.hostname or "" + p = u.port or (443 if u.scheme == "https" else 80) + path = u.path or "/" + if u.query: + path += "?" + u.query + up = socket.create_connection((h, p), timeout=30) + new_req = f"{method} {path} {parts[2] if len(parts) > 2 else 'HTTP/1.1'}\r\n".encode() + # drop absolute Host confusion; keep remaining headers + hdrs = b"\r\n".join(lines[1:]) + b"\r\n\r\n" + up.sendall(new_req + hdrs + rest) + pipe(c, up) + except Exception: + try: c.close() + except Exception: pass + + while True: + try: + c, _ = srv.accept() + except Exception: + break + import threading + threading.Thread(target=handle, args=(c,), daemon=True).start() + """; +} diff --git a/src/Titanium.Web.Proxy/Helpers/LinuxSystemProxyBackend.cs b/src/Titanium.Web.Proxy/Helpers/LinuxSystemProxyBackend.cs index 90687093f..d5054c079 100644 --- a/src/Titanium.Web.Proxy/Helpers/LinuxSystemProxyBackend.cs +++ b/src/Titanium.Web.Proxy/Helpers/LinuxSystemProxyBackend.cs @@ -1,13 +1,18 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Runtime.InteropServices; using System.Runtime.Versioning; +using System.Text; using Titanium.Web.Proxy.Models; namespace Titanium.Web.Proxy.Helpers; /// -/// Linux system proxy: GNOME gsettings + KDE kwriteconfig + process http(s)_proxy / no_proxy. +/// Linux system proxy: GNOME gsettings + KDE kwriteconfig + process/session http(s)_proxy +/// + Chromium launch hooks. Ubuntu 24.04 libproxy often ignores gsettings and returns DIRECT +/// unless http_proxy is set; Chrome also needs a full quit/relaunch to pick up hooks. /// [SupportedOSPlatform("linux")] internal sealed class LinuxSystemProxyBackend : ISystemProxyBackend @@ -17,6 +22,10 @@ internal sealed class LinuxSystemProxyBackend : ISystemProxyBackend private const string GnomeSystemProxyHttpsSchema = "org.gnome.system.proxy.https"; private const string KdeProxyTypeKey = "ProxyType"; private const string GsettingsCommand = "gsettings"; + private const string GsettingsEnabledKey = "enabled"; + private const string DbusSessionBusAddress = "DBUS_SESSION_BUS_ADDRESS"; + private const string SessionEnvDropInFileName = "90-titanium-inspector-proxy.conf"; + private const string ConfigDirName = ".config"; private static readonly string[] EnvKeys = [ @@ -24,53 +33,192 @@ internal sealed class LinuxSystemProxyBackend : ISystemProxyBackend ]; private readonly IProcessRunner _runner; + private readonly bool _applyBrowserLaunchHooks; private readonly Dictionary _originalEnv = new(StringComparer.Ordinal); private GnomeSnapshot? _gnome; private KdeSnapshot? _kde; private bool _hasSnapshot; private bool _disposed; + private bool _dbusSanitized; + private bool _sessionEnvApplied; private readonly EventHandler _processExitHandler; private readonly UnhandledExceptionEventHandler _unhandledExceptionHandler; + private readonly List _posixSignals = new(); - public LinuxSystemProxyBackend(IProcessRunner? runner = null) + public LinuxSystemProxyBackend(IProcessRunner? runner = null, bool applyBrowserLaunchHooks = true) { _runner = runner ?? new ProcessRunner(); + _applyBrowserLaunchHooks = applyBrowserLaunchHooks; _processExitHandler = (_, _) => RestoreOriginalSettings(); _unhandledExceptionHandler = (_, _) => RestoreOriginalSettings(); AppDomain.CurrentDomain.ProcessExit += _processExitHandler; AppDomain.CurrentDomain.UnhandledException += _unhandledExceptionHandler; + // SIGTERM/SIGINT skip Avalonia Exit; still must restore gsettings and drop Chrome proxy flags. + try + { + _posixSignals.Add(PosixSignalRegistration.Create(PosixSignal.SIGTERM, _ => + { + RestoreOriginalSettings(); + })); + _posixSignals.Add(PosixSignalRegistration.Create(PosixSignal.SIGINT, _ => + { + RestoreOriginalSettings(); + })); + } + catch + { + // older runtimes / non-POSIX + } } public void SetProxy(string hostname, int port, ProxyProtocolType protocolType, string? proxyOverride) { EnsureSnapshot(); - if (HasGnome()) - ApplyGnome(hostname, port, protocolType, proxyOverride); + var gnome = HasGnome(); + var kde = HasKde(); + Exception? desktopError = null; - if (HasKde()) - ApplyKde(hostname, port, protocolType, proxyOverride); + // Apply every desktop path that exists. GNOME verify must not skip XFCE/KDE/Chrome hooks. + if (gnome) + { + try + { + ApplyGnome(hostname, port, protocolType, proxyOverride); + } + catch (Exception ex) + { + desktopError = ex; + } + } - ApplyProcessEnvironment(hostname, port, protocolType, proxyOverride); + if (kde) + { + try + { + ApplyKde(hostname, port, protocolType, proxyOverride); + desktopError = null; + } + catch (Exception ex) + { + desktopError ??= ex; + } + } + + // Process env alone does not affect Chrome/Firefox already running in the desktop session. + try + { + ApplyProcessEnvironment(hostname, port, protocolType, proxyOverride); + } + catch + { + // best-effort + } + + // libproxy 0.5 on Ubuntu often ignores gsettings; publish http_proxy to the user session too. + var sessionEnvApplied = false; + try + { + sessionEnvApplied = ApplyUserSessionProxyEnvironment(hostname, port, protocolType, proxyOverride); + } + catch + { + // best-effort + } + + // XFCE/i3/WSLg/RDP dock Chrome ignores GNOME gsettings; pin Chromium-family via policy + .desktop Exec. + var hooksApplied = false; + if (_applyBrowserLaunchHooks) + { + try + { + hooksApplied = LinuxBrowserLaunchProxy.Apply(hostname, port, proxyOverride); + } + catch + { + // best-effort + } + } + + if (desktopError is not null && !hooksApplied) + throw desktopError; + + if (!gnome && !kde && !hooksApplied && !sessionEnvApplied) + { + throw new InvalidOperationException( + "Linux system proxy requires GNOME gsettings, KDE kwriteconfig, writable " + + "Chrome/Chromium desktop/policy files, or a user session environment; only this " + + "process's http(s)_proxy environment was updated."); + } } public void RemoveProxy(ProxyProtocolType protocolType, bool saveOriginalConfig = true) { - if (saveOriginalConfig) EnsureSnapshot(); + try + { + if (saveOriginalConfig) EnsureSnapshot(); + } + catch + { + // continue disable even if snapshot fails + } // Full disable is the practical Linux equivalent of removing http/https entries. - if (HasGnome()) + try + { + if (HasGnome()) + { + GsettingsSet(GnomeSystemProxySchema, "mode", "'none'"); + GsettingsSet(GnomeSystemProxyHttpSchema, GsettingsEnabledKey, "false"); + } + } + catch { - GsettingsSet(GnomeSystemProxySchema, "mode", "'none'"); + // best-effort } - if (HasKde()) + try { - KdeWrite(KdeProxyTypeKey, "0"); - KdeReload(); + if (HasKde()) + { + KdeWrite(KdeProxyTypeKey, "0"); + KdeReload(); + } + } + catch + { + // best-effort } - ClearProcessProxyEnv(); + try + { + ClearProcessProxyEnv(); + } + catch + { + // best-effort + } + + try + { + ClearUserSessionProxyEnvironment(); + } + catch + { + // best-effort + } + + if (_applyBrowserLaunchHooks) + { + try + { + LinuxBrowserLaunchProxy.Clear(); + } + catch + { + // best-effort + } + } } public void DisableAllProxy() @@ -79,37 +227,80 @@ public void DisableAllProxy() RemoveProxy(ProxyProtocolType.AllHttp, saveOriginalConfig: false); } - public void RestoreOriginalSettings() + public void RestoreOriginalSettings() // NOSONAR S3776 -- Snapshot restore must apply gsettings/env together. { if (!_hasSnapshot) return; - if (_gnome is not null && HasGnome()) + try + { + if (_gnome is not null && HasGnome()) + { + GsettingsSet(GnomeSystemProxySchema, "mode", QuoteGsettings(_gnome.Mode)); + GsettingsSet(GnomeSystemProxyHttpSchema, "host", QuoteGsettings(_gnome.HttpHost)); + GsettingsSet(GnomeSystemProxyHttpSchema, "port", _gnome.HttpPort.ToString()); + GsettingsSet(GnomeSystemProxyHttpSchema, GsettingsEnabledKey, _gnome.HttpEnabled ? "true" : "false"); + GsettingsSet(GnomeSystemProxyHttpsSchema, "host", QuoteGsettings(_gnome.HttpsHost)); + GsettingsSet(GnomeSystemProxyHttpsSchema, "port", _gnome.HttpsPort.ToString()); + GsettingsSet(GnomeSystemProxySchema, "ignore-hosts", _gnome.IgnoreHosts); + } + } + catch + { + // process-exit restore must not throw + } + + try + { + if (_kde is not null && HasKde()) + { + KdeWrite(KdeProxyTypeKey, _kde.ProxyType); + KdeWrite("httpProxy", _kde.HttpProxy); + KdeWrite("httpsProxy", _kde.HttpsProxy); + KdeWrite("NoProxyFor", _kde.NoProxyFor); + KdeReload(); + } + } + catch { - GsettingsSet(GnomeSystemProxySchema, "mode", QuoteGsettings(_gnome.Mode)); - GsettingsSet(GnomeSystemProxyHttpSchema, "host", QuoteGsettings(_gnome.HttpHost)); - GsettingsSet(GnomeSystemProxyHttpSchema, "port", _gnome.HttpPort.ToString()); - GsettingsSet(GnomeSystemProxyHttpsSchema, "host", QuoteGsettings(_gnome.HttpsHost)); - GsettingsSet(GnomeSystemProxyHttpsSchema, "port", _gnome.HttpsPort.ToString()); - GsettingsSet(GnomeSystemProxySchema, "ignore-hosts", _gnome.IgnoreHosts); + // process-exit restore must not throw } - if (_kde is not null && HasKde()) + try { - KdeWrite(KdeProxyTypeKey, _kde.ProxyType); - KdeWrite("httpProxy", _kde.HttpProxy); - KdeWrite("httpsProxy", _kde.HttpsProxy); - KdeWrite("NoProxyFor", _kde.NoProxyFor); - KdeReload(); + foreach (var key in EnvKeys) + { + if (_originalEnv.TryGetValue(key, out var value)) + { + if (value is null) + Environment.SetEnvironmentVariable(key, null); + else + Environment.SetEnvironmentVariable(key, value); + } + } + } + catch + { + // process-exit restore must not throw } - foreach (var key in EnvKeys) + try + { + ClearUserSessionProxyEnvironment(); + } + catch + { + // process-exit restore must not throw + } + + if (_applyBrowserLaunchHooks) { - if (_originalEnv.TryGetValue(key, out var value)) + try + { + LinuxBrowserLaunchProxy.Clear(); + } + catch { - if (value is null) - Environment.SetEnvironmentVariable(key, null); - else - Environment.SetEnvironmentVariable(key, value); + // process-exit restore must not throw } } @@ -123,7 +314,9 @@ public void RestoreOriginalSettings() { if (HasGnome()) { - var result = _runner.Run(GsettingsCommand, $"get {GnomeSystemProxySchema} ignore-hosts"); + EnsureUsableDbusSession(); + var result = _runner.Run(GsettingsCommand, $"get {GnomeSystemProxySchema} ignore-hosts", + DbusEnvironmentOverride()); if (result is { Succeeded: true }) return ParseGsettingsArray(result.StandardOutput); } @@ -151,6 +344,12 @@ public void Dispose() if (_disposed) return; AppDomain.CurrentDomain.ProcessExit -= _processExitHandler; AppDomain.CurrentDomain.UnhandledException -= _unhandledExceptionHandler; + foreach (var reg in _posixSignals) + { + try { reg.Dispose(); } catch { /* ignore */ } + } + + _posixSignals.Clear(); _disposed = true; } @@ -167,6 +366,7 @@ private void EnsureSnapshot() GsettingsGet(GnomeSystemProxySchema, "mode")?.Trim('\'', '"') ?? "none", GsettingsGet(GnomeSystemProxyHttpSchema, "host")?.Trim('\'', '"') ?? string.Empty, ParseInt(GsettingsGet(GnomeSystemProxyHttpSchema, "port")), + ParseGsettingsBool(GsettingsGet(GnomeSystemProxyHttpSchema, GsettingsEnabledKey)), GsettingsGet(GnomeSystemProxyHttpsSchema, "host")?.Trim('\'', '"') ?? string.Empty, ParseInt(GsettingsGet(GnomeSystemProxyHttpsSchema, "port")), GsettingsGet(GnomeSystemProxySchema, "ignore-hosts") ?? "[]"); @@ -186,11 +386,15 @@ private void EnsureSnapshot() private void ApplyGnome(string hostname, int port, ProxyProtocolType protocolType, string? proxyOverride) { + EnsureUsableDbusSession(); + GsettingsSet(GnomeSystemProxySchema, "mode", "'manual'"); if ((protocolType & ProxyProtocolType.Http) != 0) { GsettingsSet(GnomeSystemProxyHttpSchema, "host", QuoteGsettings(hostname)); GsettingsSet(GnomeSystemProxyHttpSchema, "port", port.ToString()); + // GIO/Chrome treat mode=manual with enabled=false as DIRECT (no sessions in Inspector). + GsettingsSet(GnomeSystemProxyHttpSchema, GsettingsEnabledKey, "true"); } if ((protocolType & ProxyProtocolType.Https) != 0) @@ -202,8 +406,85 @@ private void ApplyGnome(string hostname, int port, ProxyProtocolType protocolTyp if (proxyOverride != null) GsettingsSet(GnomeSystemProxySchema, "ignore-hosts", UnixProxyBypassMapper.ToGsettingsArray(proxyOverride)); + + // gsettings often exits 0 even when dconf cannot commit (e.g. DBUS_SESSION_BUS_ADDRESS=disabled:). + // Verify so Inspector does not show System proxy on while Chrome still sees mode=none. + VerifyGnomeApplied(hostname, port, protocolType); } + private void VerifyGnomeApplied(string hostname, int port, ProxyProtocolType protocolType) + { + var mode = GsettingsGet(GnomeSystemProxySchema, "mode")?.Trim('\'', '"') ?? string.Empty; + if (!mode.Equals("manual", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Failed to apply GNOME system proxy (gsettings mode is still " + + $"'{mode}' — is a D-Bus session available?)."); + } + + if ((protocolType & ProxyProtocolType.Http) != 0) + { + var host = GsettingsGet(GnomeSystemProxyHttpSchema, "host")?.Trim('\'', '"') ?? string.Empty; + var appliedPort = ParseInt(GsettingsGet(GnomeSystemProxyHttpSchema, "port")); + if (!host.Equals(hostname, StringComparison.OrdinalIgnoreCase) || appliedPort != port) + { + throw new InvalidOperationException( + $"Failed to apply GNOME HTTP proxy (got {host}:{appliedPort}, expected {hostname}:{port})."); + } + + if (!ParseGsettingsBool(GsettingsGet(GnomeSystemProxyHttpSchema, GsettingsEnabledKey))) + { + throw new InvalidOperationException( + "Failed to apply GNOME HTTP proxy (org.gnome.system.proxy.http enabled is still false; " + + "Chrome/GIO treat that as DIRECT and no sessions appear)."); + } + } + + if ((protocolType & ProxyProtocolType.Https) != 0) + { + var host = GsettingsGet(GnomeSystemProxyHttpsSchema, "host")?.Trim('\'', '"') ?? string.Empty; + var appliedPort = ParseInt(GsettingsGet(GnomeSystemProxyHttpsSchema, "port")); + if (!host.Equals(hostname, StringComparison.OrdinalIgnoreCase) || appliedPort != port) + { + throw new InvalidOperationException( + $"Failed to apply GNOME HTTPS proxy (got {host}:{appliedPort}, expected {hostname}:{port})."); + } + } + } + + /// + /// Clear poisoned session-bus addresses (e.g. Cursor/sandbox disabled:) and adopt the + /// graphical login bus (XFCE/GNOME/xrdp) so gsettings/dconf match what the desktop uses. + /// + private void EnsureUsableDbusSession() + { + if (_dbusSanitized) + return; + + var address = Environment.GetEnvironmentVariable(DbusSessionBusAddress); + if (IsUnusableDbusAddress(address)) + { + Environment.SetEnvironmentVariable(DbusSessionBusAddress, null); + var discovered = LinuxGraphicalSession.TryGetDbusSessionAddress(); + if (!IsUnusableDbusAddress(discovered)) + Environment.SetEnvironmentVariable(DbusSessionBusAddress, discovered); + } + + var display = Environment.GetEnvironmentVariable("DISPLAY"); + if (string.IsNullOrWhiteSpace(display)) + { + var sessionDisplay = LinuxGraphicalSession.TryGetDisplay(); + if (!string.IsNullOrWhiteSpace(sessionDisplay)) + Environment.SetEnvironmentVariable("DISPLAY", sessionDisplay); + } + + _dbusSanitized = true; + } + + internal static bool IsUnusableDbusAddress(string? address) => + string.IsNullOrWhiteSpace(address) || + address.StartsWith("disabled", StringComparison.OrdinalIgnoreCase); + private void ApplyKde(string hostname, int port, ProxyProtocolType protocolType, string? proxyOverride) { KdeWrite(KdeProxyTypeKey, "1"); @@ -240,6 +521,101 @@ private static void ApplyProcessEnvironment(string hostname, int port, ProxyProt Environment.SetEnvironmentVariable("NO_PROXY", noProxy); } + /// + /// Publish http(s)_proxy to systemd --user and environment.d so libproxy-based apps + /// (and newly started session tools) see the Inspector. Does not restart browsers. + /// + private bool ApplyUserSessionProxyEnvironment(string hostname, int port, ProxyProtocolType protocolType, + string? proxyOverride) + { + var url = $"http://{hostname}:{port}"; // NOSONAR S5332 + var noProxy = UnixProxyBypassMapper.ToNoProxyEnv(proxyOverride); + var assignments = new List(); + if ((protocolType & ProxyProtocolType.Http) != 0) + { + assignments.Add($"http_proxy={url}"); + assignments.Add($"HTTP_PROXY={url}"); + } + + if ((protocolType & ProxyProtocolType.Https) != 0) + { + assignments.Add($"https_proxy={url}"); + assignments.Add($"HTTPS_PROXY={url}"); + } + + assignments.Add($"no_proxy={noProxy}"); + assignments.Add($"NO_PROXY={noProxy}"); + + var any = false; + var joined = string.Join(' ', assignments.Select(QuoteShell)); + var systemctl = _runner.Run("systemctl", $"--user set-environment {joined}"); + if (systemctl is { Succeeded: true }) + any = true; + + var names = string.Join(' ', + assignments.Select(a => a.Split('=', 2)[0])); + var dbusUpdate = _runner.Run("dbus-update-activation-environment", $"--systemd {names}"); + if (dbusUpdate is { Succeeded: true }) + any = true; + + if (TryWriteSessionEnvDropIn(assignments)) + any = true; + + _sessionEnvApplied = any; + return any; + } + + private void ClearUserSessionProxyEnvironment() + { + if (_sessionEnvApplied) + { + _runner.Run("systemctl", + "--user unset-environment http_proxy https_proxy HTTP_PROXY HTTPS_PROXY no_proxy NO_PROXY"); + _sessionEnvApplied = false; + } + + TryDeleteSessionEnvDropIn(); + } + + private static bool TryWriteSessionEnvDropIn(IReadOnlyList assignments) + { + try + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(home)) + return false; + + var dir = Path.Combine(home, ConfigDirName, "environment.d"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, SessionEnvDropInFileName); + var sb = new StringBuilder(); + sb.AppendLine("# Managed by Titanium Inspector — removed when system proxy is restored"); + foreach (var line in assignments) + sb.AppendLine(line); + File.WriteAllText(path, sb.ToString()); + return File.Exists(path); + } + catch + { + return false; + } + } + + private static void TryDeleteSessionEnvDropIn() + { + try + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(home)) + return; + File.Delete(Path.Combine(home, ConfigDirName, "environment.d", SessionEnvDropInFileName)); + } + catch + { + // best-effort + } + } + private static void ClearProcessProxyEnv() { foreach (var key in EnvKeys) @@ -248,11 +624,12 @@ private static void ClearProcessProxyEnv() private bool HasGnome() { - var which = _runner.Run("sh", "-c \"command -v gsettings\""); + EnsureUsableDbusSession(); + var which = _runner.Run("sh", "-c \"command -v gsettings\"", DbusEnvironmentOverride()); if (which is not { Succeeded: true } || string.IsNullOrWhiteSpace(which.StandardOutput)) return false; - var schema = _runner.Run(GsettingsCommand, "list-schemas"); + var schema = _runner.Run(GsettingsCommand, "list-schemas", DbusEnvironmentOverride()); return schema is { Succeeded: true } && schema.StandardOutput.Contains(GnomeSystemProxySchema, StringComparison.Ordinal); } @@ -295,12 +672,32 @@ private void KdeReload() private string? GsettingsGet(string schema, string key) { - var result = _runner.Run(GsettingsCommand, $"get {schema} {key}"); + EnsureUsableDbusSession(); + var result = _runner.Run(GsettingsCommand, $"get {schema} {key}", DbusEnvironmentOverride()); return result is { Succeeded: true } ? result.StandardOutput.Trim() : null; } - private void GsettingsSet(string schema, string key, string value) => - _runner.Run(GsettingsCommand, $"set {schema} {key} {value}"); + private void GsettingsSet(string schema, string key, string value) + { + EnsureUsableDbusSession(); + _runner.Run(GsettingsCommand, $"set {schema} {key} {value}", DbusEnvironmentOverride()); + } + + /// + /// Ensure child gsettings processes do not inherit a poisoned bus address even if something + /// re-set DBUS_SESSION_BUS_ADDRESS after . + /// + private static Dictionary? DbusEnvironmentOverride() + { + var address = Environment.GetEnvironmentVariable(DbusSessionBusAddress); + if (!IsUnusableDbusAddress(address)) + return null; + + return new Dictionary(StringComparer.Ordinal) + { + [DbusSessionBusAddress] = null + }; + } private static string QuoteGsettings(string value) => $"'{value.Replace("'", @"'\''")}'"; @@ -310,6 +707,12 @@ private static string QuoteShell(string value) => private static int ParseInt(string? text) => int.TryParse(text?.Trim(), out var n) ? n : 0; + private static bool ParseGsettingsBool(string? text) + { + var value = text?.Trim().Trim('\'', '"'); + return value is "true" or "True" or "1"; + } + private static string ParseGsettingsArray(string output) { // e.g. ['localhost', '127.0.0.1'] @@ -367,7 +770,7 @@ private static bool TryParseProxyUri(string? value, out string host, out int por } private sealed record GnomeSnapshot( - string Mode, string HttpHost, int HttpPort, string HttpsHost, int HttpsPort, string IgnoreHosts); + string Mode, string HttpHost, int HttpPort, bool HttpEnabled, string HttpsHost, int HttpsPort, string IgnoreHosts); private sealed record KdeSnapshot(string ProxyType, string HttpProxy, string HttpsProxy, string NoProxyFor); } diff --git a/src/Titanium.Web.Proxy/Helpers/MacOsSystemProxyBackend.cs b/src/Titanium.Web.Proxy/Helpers/MacOsSystemProxyBackend.cs index 5432950bc..7950ee2c1 100644 --- a/src/Titanium.Web.Proxy/Helpers/MacOsSystemProxyBackend.cs +++ b/src/Titanium.Web.Proxy/Helpers/MacOsSystemProxyBackend.cs @@ -9,6 +9,8 @@ namespace Titanium.Web.Proxy.Helpers; /// /// macOS system proxy via networksetup, with optional admin elevation on auth failure. +/// Disables PAC / WPAD / SOCKS while Inspector is the system proxy so CFNetwork (Firefox) +/// sees the HTTP(S) proxy rather than an auto-config script. /// [SupportedOSPlatform("macos")] [SupportedOSPlatform("osx")] @@ -37,37 +39,63 @@ public MacOsSystemProxyBackend(IProcessRunner? runner = null, IElevationPrompt? public void SetProxy(string hostname, int port, ProxyProtocolType protocolType, string? proxyOverride) { - EnsureSnapshot(); - var bypass = UnixProxyBypassMapper.ToCommaSeparated(proxyOverride); - foreach (var service in ListNetworkServices()) + try { - if ((protocolType & ProxyProtocolType.Http) != 0) + EnsureSnapshot(); + var services = ListNetworkServices(); + if (services.Count == 0) { - RunNetworkSetup($"-setwebproxy \"{Escape(service)}\" {hostname} {port}", true); - RunNetworkSetup($"-setwebproxystate \"{Escape(service)}\" on", true); + throw new InvalidOperationException( + "networksetup listed no network services; macOS system proxy was not changed"); } - if ((protocolType & ProxyProtocolType.Https) != 0) + var bypass = UnixProxyBypassMapper.ToCommaSeparated(proxyOverride); + foreach (var service in services) { - RunNetworkSetup($"-setsecurewebproxy \"{Escape(service)}\" {hostname} {port}", true); - RunNetworkSetup($"-setsecurewebproxystate \"{Escape(service)}\" on", true); + // PAC / WPAD / SOCKS take precedence in CFNetwork (Firefox system-proxy mode). + DisableConflictingProxyModes(service); + + if ((protocolType & ProxyProtocolType.Http) != 0) + { + RunNetworkSetup($"-setwebproxy \"{Escape(service)}\" {hostname} {port}", true); + RunNetworkSetup($"-setwebproxystate \"{Escape(service)}\" on", true); + } + + if ((protocolType & ProxyProtocolType.Https) != 0) + { + RunNetworkSetup($"-setsecurewebproxy \"{Escape(service)}\" {hostname} {port}", true); + RunNetworkSetup($"-setsecurewebproxystate \"{Escape(service)}\" on", true); + } + + if (proxyOverride != null) + RunNetworkSetup($"-setproxybypassdomains \"{Escape(service)}\" {FormatBypassArgs(bypass)}", true); } - if (proxyOverride != null) - RunNetworkSetup($"-setproxybypassdomains \"{Escape(service)}\" {FormatBypassArgs(bypass)}", true); + VerifyMacApplied(services, hostname, port, protocolType); + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to apply macOS system proxy: " + ex.Message, ex); } } public void RemoveProxy(ProxyProtocolType protocolType, bool saveOriginalConfig = true) { - if (saveOriginalConfig) EnsureSnapshot(); - foreach (var service in ListNetworkServices()) + try { - if ((protocolType & ProxyProtocolType.Http) != 0) - RunNetworkSetup($"-setwebproxystate \"{Escape(service)}\" off", true); + if (saveOriginalConfig) EnsureSnapshot(); + foreach (var service in ListNetworkServices()) + { + if ((protocolType & ProxyProtocolType.Http) != 0) + RunNetworkSetup($"-setwebproxystate \"{Escape(service)}\" off", true); - if ((protocolType & ProxyProtocolType.Https) != 0) - RunNetworkSetup($"-setsecurewebproxystate \"{Escape(service)}\" off", true); + if ((protocolType & ProxyProtocolType.Https) != 0) + RunNetworkSetup($"-setsecurewebproxystate \"{Escape(service)}\" off", true); + } + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to disable macOS system proxy: " + ex.Message, ex); } } @@ -77,10 +105,12 @@ public void DisableAllProxy() RemoveProxy(ProxyProtocolType.AllHttp, saveOriginalConfig: false); } - public void RestoreOriginalSettings() + public void RestoreOriginalSettings() // NOSONAR S3776 -- Snapshot restore must apply every captured service in one pass. { if (!_hasSnapshot) return; + try + { foreach (var snap in _original) { if (snap.HttpEnabled) @@ -104,12 +134,42 @@ public void RestoreOriginalSettings() RunNetworkSetup($"-setsecurewebproxystate \"{Escape(snap.Service)}\" off", true); } + if (snap.SocksEnabled) + { + RunNetworkSetup( + $"-setsocksfirewallproxy \"{Escape(snap.Service)}\" {snap.SocksHost} {snap.SocksPort}", true); + RunNetworkSetup($"-setsocksfirewallproxystate \"{Escape(snap.Service)}\" on", true); + } + else + { + RunNetworkSetup($"-setsocksfirewallproxystate \"{Escape(snap.Service)}\" off", true); + } + + if (snap.AutoProxyEnabled && !string.IsNullOrWhiteSpace(snap.AutoProxyUrl) && + !snap.AutoProxyUrl.Equals("(null)", StringComparison.OrdinalIgnoreCase)) + { + RunNetworkSetup($"-setautoproxyurl \"{Escape(snap.Service)}\" \"{Escape(snap.AutoProxyUrl)}\"", true); + RunNetworkSetup($"-setautoproxystate \"{Escape(snap.Service)}\" on", true); + } + else + { + RunNetworkSetup($"-setautoproxystate \"{Escape(snap.Service)}\" off", true); + } + + RunNetworkSetup( + $"-setproxyautodiscovery \"{Escape(snap.Service)}\" {(snap.AutoDiscovery ? "on" : "off")}", true); + RunNetworkSetup( $"-setproxybypassdomains \"{Escape(snap.Service)}\" {FormatBypassArgs(snap.BypassDomains)}", true); } _original.Clear(); _hasSnapshot = false; + } + catch + { + // process-exit restore must not throw + } } public string? GetCurrentProxyOverride() @@ -150,6 +210,13 @@ public void Dispose() _disposed = true; } + private void DisableConflictingProxyModes(string service) + { + RunNetworkSetup($"-setautoproxystate \"{Escape(service)}\" off", true); + RunNetworkSetup($"-setproxyautodiscovery \"{Escape(service)}\" off", true); + RunNetworkSetup($"-setsocksfirewallproxystate \"{Escape(service)}\" off", true); + } + private void EnsureSnapshot() { if (_hasSnapshot) return; @@ -158,6 +225,12 @@ private void EnsureSnapshot() { var http = ParseProxyState(_runner.Run(NetworkSetupCommand, $"-getwebproxy \"{Escape(service)}\"")); var https = ParseProxyState(_runner.Run(NetworkSetupCommand, $"-getsecurewebproxy \"{Escape(service)}\"")); + var socks = ParseProxyState( + _runner.Run(NetworkSetupCommand, $"-getsocksfirewallproxy \"{Escape(service)}\"")); + var autoProxy = ParseAutoProxyUrl( + _runner.Run(NetworkSetupCommand, $"-getautoproxyurl \"{Escape(service)}\"")); + var autoDiscovery = ParseAutoDiscovery( + _runner.Run(NetworkSetupCommand, $"-getproxyautodiscovery \"{Escape(service)}\"")); var bypassResult = _runner.Run(NetworkSetupCommand, $"-getproxybypassdomains \"{Escape(service)}\""); var bypass = bypassResult?.StandardOutput ?? string.Empty; if (bypass.Contains("There aren't any", StringComparison.OrdinalIgnoreCase)) @@ -167,6 +240,9 @@ private void EnsureSnapshot() service, http.Enabled, http.Host, http.Port, https.Enabled, https.Host, https.Port, + socks.Enabled, socks.Host, socks.Port, + autoProxy.Enabled, autoProxy.Url, + autoDiscovery, string.Join(",", bypass.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()) .Where(x => x.Length > 0)))); @@ -192,14 +268,196 @@ private void RunNetworkSetup(string arguments, bool elevateOnAuthFailure) { var result = _runner.Run(NetworkSetupCommand, arguments); if (result is { Succeeded: true }) return; - if (!elevateOnAuthFailure) return; + if (!elevateOnAuthFailure) + { + throw new InvalidOperationException( + "networksetup failed: " + (result?.StandardError ?? result?.StandardOutput ?? "unknown error")); + } var err = (result?.StandardError ?? string.Empty) + (result?.StandardOutput ?? string.Empty); - if (result is not null && !LooksLikeAuthFailure(err)) return; + if (result is not null && !LooksLikeAuthFailure(err)) + { + throw new InvalidOperationException( + "networksetup failed: " + (string.IsNullOrWhiteSpace(err) ? "non-zero exit" : err.Trim())); + } - _elevation.RunElevated("/usr/sbin/networksetup", arguments); + var elevated = _elevation.RunElevated("/usr/sbin/networksetup", arguments); + if (elevated is not { Succeeded: true }) + { + throw new InvalidOperationException( + "Elevated networksetup failed: " + + (elevated?.StandardError ?? elevated?.StandardOutput ?? "cancelled or denied")); + } } + private void VerifyMacApplied( // NOSONAR S3776 -- Post-apply verify retries networksetup/scutil together. + IReadOnlyList services, string hostname, int port, ProxyProtocolType protocolType) + { + var scutil = _runner.Run("scutil", "--proxy"); + if (TryParseScutilProxy(scutil?.StandardOutput, out var effective) && + ScutilMatches(effective, hostname, port, protocolType)) + { + return; + } + + Exception? last = null; + foreach (var service in services) + { + try + { + if ((protocolType & ProxyProtocolType.Http) != 0) + { + var http = ParseProxyState(_runner.Run(NetworkSetupCommand, $"-getwebproxy \"{Escape(service)}\"")); + if (!http.Enabled || + !http.Host.Equals(hostname, StringComparison.OrdinalIgnoreCase) || + http.Port != port) + { + last = new InvalidOperationException( + $"HTTP proxy on '{service}' did not stick (enabled={http.Enabled}, {http.Host}:{http.Port})"); + continue; + } + } + + if ((protocolType & ProxyProtocolType.Https) != 0) + { + var https = ParseProxyState( + _runner.Run(NetworkSetupCommand, $"-getsecurewebproxy \"{Escape(service)}\"")); + if (!https.Enabled || + !https.Host.Equals(hostname, StringComparison.OrdinalIgnoreCase) || + https.Port != port) + { + last = new InvalidOperationException( + $"HTTPS proxy on '{service}' did not stick (enabled={https.Enabled}, {https.Host}:{https.Port})"); + continue; + } + } + + return; // at least one service verified + } + catch (Exception ex) + { + last = ex; + } + } + + throw last ?? new InvalidOperationException("macOS system proxy could not be verified after apply"); + } + + internal static bool TryParseScutilProxy(string? output, out ScutilProxyState state) // NOSONAR S3776 -- scutil --proxy parse is a single key/value walk. + { + state = default; + if (string.IsNullOrWhiteSpace(output)) + return false; + + var httpEnable = false; + var httpsEnable = false; + var pacEnable = false; + var socksEnable = false; + var httpHost = ""; + var httpsHost = ""; + var httpPort = 0; + var httpsPort = 0; + var any = false; + + foreach (var raw in output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + var line = raw.Trim(); + var sep = line.IndexOf(':'); + if (sep < 0) continue; + var key = line[..sep].Trim(); + var value = line[(sep + 1)..].Trim(); + + if (key.Equals("HTTPEnable", StringComparison.OrdinalIgnoreCase)) + { + httpEnable = IsScutilEnabled(value); + any = true; + } + else if (key.Equals("HTTPSEnable", StringComparison.OrdinalIgnoreCase)) + { + httpsEnable = IsScutilEnabled(value); + any = true; + } + else if (key.Equals("HTTPProxy", StringComparison.OrdinalIgnoreCase)) + { + httpHost = value; + any = true; + } + else if (key.Equals("HTTPSProxy", StringComparison.OrdinalIgnoreCase)) + { + httpsHost = value; + any = true; + } + else if (key.Equals("HTTPPort", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var hp)) + { + httpPort = hp; + any = true; + } + else if (key.Equals("HTTPSPort", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var sp)) + { + httpsPort = sp; + any = true; + } + else if (key.Equals("ProxyAutoConfigEnable", StringComparison.OrdinalIgnoreCase)) + { + pacEnable = IsScutilEnabled(value); + any = true; + } + else if (key.Equals("ProxyAutoDiscoveryEnable", StringComparison.OrdinalIgnoreCase)) + { + if (IsScutilEnabled(value)) + pacEnable = true; + any = true; + } + else if (key.Equals("SOCKSEnable", StringComparison.OrdinalIgnoreCase)) + { + socksEnable = IsScutilEnabled(value); + any = true; + } + } + + if (!any) + return false; + + state = new ScutilProxyState(httpEnable, httpHost, httpPort, httpsEnable, httpsHost, httpsPort, pacEnable, + socksEnable); + return true; + } + + internal static bool ScutilMatches( + ScutilProxyState state, string hostname, int port, ProxyProtocolType protocolType) + { + // PAC / WPAD still enabled means Firefox will not use the manual HTTP proxy. + if (state.PacEnabled) + return false; + + if ((protocolType & ProxyProtocolType.Http) != 0 && + (!state.HttpEnabled || + !state.HttpHost.Equals(hostname, StringComparison.OrdinalIgnoreCase) || + state.HttpPort != port)) + { + return false; + } + + if ((protocolType & ProxyProtocolType.Https) != 0 && + (!state.HttpsEnabled || + !state.HttpsHost.Equals(hostname, StringComparison.OrdinalIgnoreCase) || + state.HttpsPort != port)) + { + return false; + } + + return true; + } + + internal readonly record struct ScutilProxyState( + bool HttpEnabled, string HttpHost, int HttpPort, + bool HttpsEnabled, string HttpsHost, int HttpsPort, + bool PacEnabled, bool SocksEnabled); + + private static bool IsScutilEnabled(string value) => + value == "1" || value.Equals("true", StringComparison.OrdinalIgnoreCase) || + value.Equals("yes", StringComparison.OrdinalIgnoreCase); + private static bool LooksLikeAuthFailure(string text) => text.Contains("permission", StringComparison.OrdinalIgnoreCase) || text.Contains("authoriz", StringComparison.OrdinalIgnoreCase) || @@ -230,6 +488,41 @@ private static (bool Enabled, string Host, int Port) ParseProxyState(ProcessRunR return (enabled, host, port); } + internal static (bool Enabled, string Url) ParseAutoProxyUrl(ProcessRunResult? result) + { + var enabled = false; + var url = ""; + if (result is null) return (false, url); + foreach (var line in result.StandardOutput.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + var idx = line.IndexOf(':'); + if (idx < 0) continue; + var key = line[..idx].Trim(); + var value = line[(idx + 1)..].Trim(); + if (key.Equals("Enabled", StringComparison.OrdinalIgnoreCase)) + enabled = value.Equals("Yes", StringComparison.OrdinalIgnoreCase); + else if (key.Equals("URL", StringComparison.OrdinalIgnoreCase)) + url = value; + } + + return (enabled, url); + } + + internal static bool ParseAutoDiscovery(ProcessRunResult? result) + { + if (result is null) return false; + foreach (var line in result.StandardOutput.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + var idx = line.LastIndexOf(':'); + var value = idx < 0 ? line.Trim() : line[(idx + 1)..].Trim(); + if (value.Equals("On", StringComparison.OrdinalIgnoreCase) || + value.Equals("Yes", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + private static string FormatBypassArgs(string bypassCsv) { if (string.IsNullOrWhiteSpace(bypassCsv) || @@ -252,5 +545,8 @@ private sealed record ServiceSnapshot( string Service, bool HttpEnabled, string HttpHost, int HttpPort, bool HttpsEnabled, string HttpsHost, int HttpsPort, + bool SocksEnabled, string SocksHost, int SocksPort, + bool AutoProxyEnabled, string AutoProxyUrl, + bool AutoDiscovery, string BypassDomains); } diff --git a/src/Titanium.Web.Proxy/Helpers/MitmCompressedRelayHelper.cs b/src/Titanium.Web.Proxy/Helpers/MitmCompressedRelayHelper.cs index d95a89c0b..d4183c0f5 100644 --- a/src/Titanium.Web.Proxy/Helpers/MitmCompressedRelayHelper.cs +++ b/src/Titanium.Web.Proxy/Helpers/MitmCompressedRelayHelper.cs @@ -55,14 +55,18 @@ internal readonly bool Contains(string name, StringComparison comparison = Strin internal readonly struct AddedHeader { - internal AddedHeader(string name, string value) + internal AddedHeader(HttpHeader header) { - Name = name; - Value = value; + Header = header; } - internal string Name { get; } - internal string Value { get; } + /// Wire header already holding NameData/ValueData — avoid string↔bytes on HPACK append. + internal HttpHeader Header { get; } + + internal string Name => Header.Name; + internal string Value => Header.Value; + internal ByteString NameData => Header.NameData; + internal ByteString ValueData => Header.ValueData; } /// Stack-friendly buffer for up to four appended header literals. @@ -71,17 +75,19 @@ internal struct AddedHeaderBuffer private AddedHeader _h0, _h1, _h2, _h3; internal int Count { get; private set; } - internal void Add(string name, string value) + internal void Add(HttpHeader header) { switch (Count++) { - case 0: _h0 = new AddedHeader(name, value); break; - case 1: _h1 = new AddedHeader(name, value); break; - case 2: _h2 = new AddedHeader(name, value); break; - default: _h3 = new AddedHeader(name, value); break; + case 0: _h0 = new AddedHeader(header); break; + case 1: _h1 = new AddedHeader(header); break; + case 2: _h2 = new AddedHeader(header); break; + default: _h3 = new AddedHeader(header); break; } } + internal void Add(string name, string value) => Add(new HttpHeader(name, value)); + internal readonly AddedHeader this[int index] => index switch { 0 => _h0, @@ -107,17 +113,20 @@ internal readonly bool ContainsName(string name, StringComparison comparison = S internal readonly struct HeaderRelayBaseline { private readonly int _mutationCount; - private readonly Dictionary _unique; - private readonly Dictionary> _nonUniqueSnapshot; + private readonly Dictionary? _unique; + private readonly Dictionary>? _nonUniqueSnapshot; private readonly int _nonUniqueNamesAtCapture; + private readonly AddedHeaderBuffer _precomputedAppends; - internal HeaderRelayBaseline(int mutationCount, Dictionary unique, - Dictionary> nonUniqueSnapshot, int nonUniqueNamesAtCapture) + internal HeaderRelayBaseline(int mutationCount, Dictionary? unique, + Dictionary>? nonUniqueSnapshot, int nonUniqueNamesAtCapture, + AddedHeaderBuffer precomputedAppends = default) { _mutationCount = mutationCount; _unique = unique; _nonUniqueSnapshot = nonUniqueSnapshot; _nonUniqueNamesAtCapture = nonUniqueNamesAtCapture; + _precomputedAppends = precomputedAppends; } internal static HeaderRelayBaseline Capture(HeaderCollection headers) @@ -139,12 +148,53 @@ internal static HeaderRelayBaseline Capture(HeaderCollection headers) headers.NonUniqueHeaders.Count); } + /// + /// MITM unchanged-lite hot path: store only. + /// Avoids cloning unique/non-unique dictionaries when the finish path is + /// . + /// Append/drop diff () returns false — callers fall back + /// to full HPACK/QPACK re-encode when handlers mutate. + /// + internal static HeaderRelayBaseline CaptureMutationCount(HeaderCollection headers) => + new(headers.MutationCount, null, null, MutationCountOnlySentinel); + + /// MutationCount-only baseline from a previously armed COW count (no live collection). + internal static HeaderRelayBaseline CaptureMutationCountFromCount(int mutationCount) => + new(mutationCount, null, null, MutationCountOnlySentinel); + + /// + /// Pure-append COW log: handlers only added new unique headers (no wire snapshot). + /// + internal static HeaderRelayBaseline FromAppendLog(int mutationCount, AddedHeaderBuffer appends) => + new(mutationCount, null, null, AppendLogSentinel, appends); + + private const int MutationCountOnlySentinel = -1; + private const int AppendLogSentinel = -2; + internal int MutationCount => _mutationCount; + internal bool IsMutationCountOnly => _nonUniqueNamesAtCapture == MutationCountOnlySentinel; + + internal bool TryGetPrecomputedAppends(out AddedHeaderBuffer added) + { + if (_nonUniqueNamesAtCapture == AppendLogSentinel && _precomputedAppends.Count > 0) + { + added = _precomputedAppends; + return true; + } + + added = default; + return false; + } + internal bool TryDiffAppendOnly(HeaderCollection after, int maxAdds, out AddedHeaderBuffer added) { added = default; + // MutationCount-only baselines cannot append-diff (no pre-handler header snapshot). + if (_unique is null || IsMutationCountOnly) + return false; + if (_nonUniqueNamesAtCapture > 0 || after.NonUniqueHeaders.Count > 0) return TryDiffNonUniqueTrailingAppend(after, maxAdds, out added); @@ -198,7 +248,8 @@ private bool TryDiffNonUniqueTrailingAppend(HeaderCollection after, int maxAdds, private bool TryMatchUniqueHeadersAllowingGrowth( HeaderCollection after, int maxAdds, ref AddedHeaderBuffer added) { - foreach (var kv in _unique) + var unique = _unique!; // NOSONAR S8969 -- Capture snapshot is initialized before match; operator documents that contract. + foreach (var kv in unique) { if (after.NonUniqueHeaders.TryGetValue(kv.Key, out var grownList)) { @@ -239,9 +290,10 @@ private static bool TryAppendGrownUniqueValues( private bool TryAppendNewUniqueHeaders( HeaderCollection after, int maxAdds, ref AddedHeaderBuffer added) { + var unique = _unique!; // NOSONAR S8969 -- Capture snapshot is initialized before match; operator documents that contract. foreach (var kv in after.Headers) { - if (_unique.ContainsKey(kv.Key)) + if (unique.ContainsKey(kv.Key)) continue; if (added.Count >= maxAdds) @@ -256,7 +308,8 @@ private bool TryAppendNewUniqueHeaders( private bool TryMatchNonUniqueTrailing( HeaderCollection after, int maxAdds, ref AddedHeaderBuffer added) { - foreach (var kv in _nonUniqueSnapshot) + var nonUniqueSnapshot = _nonUniqueSnapshot!; + foreach (var kv in nonUniqueSnapshot) { if (!after.NonUniqueHeaders.TryGetValue(kv.Key, out var afterList)) return false; @@ -296,9 +349,11 @@ private static bool TryMatchNonUniquePrefixAndAppend( private bool NonUniqueNamesAreKnown(HeaderCollection after) { + var unique = _unique!; // NOSONAR S8969 -- Capture snapshot is initialized before match; operator documents that contract. + var nonUniqueSnapshot = _nonUniqueSnapshot!; foreach (var name in after.NonUniqueHeaders.Keys) // NOSONAR S3267 -- Explicit loop avoids LINQ enumerator allocation on hot path. { - if (!_nonUniqueSnapshot.ContainsKey(name) && !_unique.ContainsKey(name)) + if (!nonUniqueSnapshot.ContainsKey(name) && !unique.ContainsKey(name)) return false; } @@ -310,6 +365,9 @@ internal bool TryDiffDropOnly(HeaderCollection after, int maxDrops, out DroppedN { dropped = default; + if (_unique is null || IsMutationCountOnly) + return false; + if (_mutationCount == after.MutationCount) return false; @@ -319,9 +377,10 @@ internal bool TryDiffDropOnly(HeaderCollection after, int maxDrops, out DroppedN if (!TryCollectDrops(after, maxDrops, out dropped, out var dropCount) || dropCount == 0) return false; + var unique = _unique!; // NOSONAR S8969 -- Capture snapshot is initialized before match; operator documents that contract. foreach (var kv in after.Headers) // NOSONAR S3267 -- Explicit loop avoids LINQ enumerator allocation on hot path. { - if (!_unique.ContainsKey(kv.Key)) + if (!unique.ContainsKey(kv.Key)) return false; } @@ -333,7 +392,8 @@ private bool TryCollectDrops( { dropped = default; dropCount = 0; - foreach (var kv in _unique) + var unique = _unique!; // NOSONAR S8969 -- Capture snapshot is initialized before match; operator documents that contract. + foreach (var kv in unique) { if (after.Headers.TryGetValue(kv.Key, out var header)) { @@ -356,8 +416,20 @@ internal static bool AllowsCompressedRelay( HeaderRelayBaseline baseline, HeaderCollection after, int maxAdds, - out AddedHeaderBuffer added) => - baseline.TryDiffAppendOnly(after, maxAdds, out added); + out AddedHeaderBuffer added) + { + // COW Lite Take produces MutationCount-only baselines — TryDiffAppendOnly always + // returns false without a header snapshot. Match the int overload (H2 unchanged-lite). + if (baseline.IsMutationCountOnly) + return AllowsCompressedRelay(baseline.MutationCount, after, maxAdds, out added); + + // COW Full append-log: adds are already applied on `after`; finish paths that speak H1 + // (H3→H1 ForwardOverTcpFastAsync) or static append (H2/H3 QPACK) can proceed. + if (baseline.TryGetPrecomputedAppends(out added)) + return added.Count <= maxAdds; + + return baseline.TryDiffAppendOnly(after, maxAdds, out added); + } /// /// MutationCount-only gate for unchanged headers. When counts diverge, caller must use diff --git a/src/Titanium.Web.Proxy/Helpers/MitmStaticRebuildHelper.cs b/src/Titanium.Web.Proxy/Helpers/MitmStaticRebuildHelper.cs index 7a8329ecd..bcdd64ab8 100644 --- a/src/Titanium.Web.Proxy/Helpers/MitmStaticRebuildHelper.cs +++ b/src/Titanium.Web.Proxy/Helpers/MitmStaticRebuildHelper.cs @@ -124,6 +124,18 @@ internal static bool TryPrepareStaticQpackRelay( blockToRelay = capturedBlock; appendLiterals = default; + // Unchanged Lite: MutationCount match → skip O(headers) append/drop diff. + if (baseline.MutationCount == after.MutationCount) + return true; + + // Pure-append COW log (Full MITM probe / single AddHeader): skip snapshot diff. + if (baseline.TryGetPrecomputedAppends(out appendLiterals)) + return true; + + // MutationCount-only capture: no snapshot for append/drop — caller re-encodes. + if (baseline.IsMutationCountOnly) + return false; + if (baseline.TryDiffAppendOnly(after, MitmCompressedRelayHelper.DefaultMaxAppendHeaders, out appendLiterals)) return true; @@ -145,6 +157,18 @@ internal static bool TryPrepareStaticHpackRelay( blockToRelay = capturedBlock; appendLiterals = default; + // Unchanged Lite: MutationCount match → skip O(headers) append/drop diff. + if (baseline.MutationCount == after.MutationCount) + return true; + + // Pure-append COW log (Full MITM probe / single AddHeader): skip snapshot diff. + if (baseline.TryGetPrecomputedAppends(out appendLiterals)) + return true; + + // MutationCount-only capture: no snapshot for append/drop — caller re-encodes. + if (baseline.IsMutationCountOnly) + return false; + if (baseline.TryDiffAppendOnly(after, MitmCompressedRelayHelper.DefaultMaxAppendHeaders, out appendLiterals)) return true; diff --git a/src/Titanium.Web.Proxy/Helpers/NativeMethods.MacProc.cs b/src/Titanium.Web.Proxy/Helpers/NativeMethods.MacProc.cs new file mode 100644 index 000000000..fc83bb634 --- /dev/null +++ b/src/Titanium.Web.Proxy/Helpers/NativeMethods.MacProc.cs @@ -0,0 +1,36 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Titanium.Web.Proxy.Helpers; + +internal partial class NativeMethods +{ + internal const int ProcAllPids = 1; + internal const int ProcPidListFds = 1; + internal const int ProcPidFdSocketInfo = 3; + internal const int ProxFdTypeSocket = 2; + internal const int SockInfoTcp = 2; + internal const int DarwinAfInet = 2; + internal const int DarwinAfInet6 = 30; + internal const int IpProtoTcp = 6; + internal const int SocketFdInfoSize = 792; + internal const int ProcFdInfoSize = 8; + + // Offsets within socket_fdinfo on 64-bit Darwin (verified against sys/proc_info.h). + internal const int SocketFdInfoOffsetSoiProtocol = 180; + internal const int SocketFdInfoOffsetSoiFamily = 184; + internal const int SocketFdInfoOffsetSoiKind = 256; + internal const int SocketFdInfoOffsetInsiLport = 268; + + [SupportedOSPlatform("macos")] + [LibraryImport("libproc", EntryPoint = "proc_listpids")] + internal static partial int ProcListPids(uint type, uint typeInfo, [In][Out] int[]? buffer, int bufferSize); + + [SupportedOSPlatform("macos")] + [LibraryImport("libproc", EntryPoint = "proc_pidinfo")] + internal static partial int ProcPidInfo(int pid, int flavor, ulong arg, [In][Out] byte[]? buffer, int bufferSize); + + [SupportedOSPlatform("macos")] + [LibraryImport("libproc", EntryPoint = "proc_pidfdinfo")] + internal static partial int ProcPidFdInfo(int pid, int fd, int flavor, [In][Out] byte[] buffer, int bufferSize); +} diff --git a/src/Titanium.Web.Proxy/Helpers/RunTime.cs b/src/Titanium.Web.Proxy/Helpers/RunTime.cs index 1a9bc2d7f..3fe077827 100644 --- a/src/Titanium.Web.Proxy/Helpers/RunTime.cs +++ b/src/Titanium.Web.Proxy/Helpers/RunTime.cs @@ -55,6 +55,7 @@ internal static class RunTime [SupportedOSPlatformGuard("windows")] public static bool IsUwpOnWindows => IsWindows && UwpHelper.IsRunningAsUwp(); + [SupportedOSPlatformGuard("macos")] public static bool IsMac => IsRunningOnMac; private static bool? _isSocketReuseAvailable; diff --git a/src/Titanium.Web.Proxy/Helpers/SystemProxy.cs b/src/Titanium.Web.Proxy/Helpers/SystemProxy.cs index 58fd84d39..0eac79f21 100644 --- a/src/Titanium.Web.Proxy/Helpers/SystemProxy.cs +++ b/src/Titanium.Web.Proxy/Helpers/SystemProxy.cs @@ -144,7 +144,11 @@ public void SetProxy(string hostname, int port, ProxyProtocolType protocolType, { using (var reg = OpenInternetSettingsKey()) { - if (reg == null) return; + if (reg == null) + { + throw new InvalidOperationException( + "Could not open HKCU Internet Settings; Windows system proxy was not changed"); + } SaveOriginalProxyConfiguration(reg); PrepareRegistry(reg); @@ -158,13 +162,24 @@ public void SetProxy(string hostname, int port, ProxyProtocolType protocolType, if ((protocolType & ProxyProtocolType.Https) != 0) existingSystemProxyValues.Add(new HttpSystemProxyValue(hostname, port, ProxyProtocolType.Https)); + var proxyServerValue = string.Join(";", existingSystemProxyValues.Select(x => x.ToString()).ToArray()); reg.DeleteValue(RegAutoConfigUrl, false); reg.SetValue(RegProxyEnable, 1); - reg.SetValue(RegProxyServer, - string.Join(";", existingSystemProxyValues.Select(x => x.ToString()).ToArray())); + reg.SetValue(RegProxyServer, proxyServerValue); if (proxyOverride != null) reg.SetValue(RegProxyOverride, proxyOverride); Refresh(); + + // Re-read so a silent registry failure cannot look like success to Inspector. + var enableObj = reg.GetValue(RegProxyEnable); + var enable = enableObj is null ? 0 : Convert.ToInt32(enableObj); + var server = reg.GetValue(RegProxyServer) as string ?? string.Empty; + if (enable != 1 || + !server.Contains($"{hostname}:{port}", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "WinINET proxy write did not stick (ProxyEnable/ProxyServer mismatch after set)"); + } } } @@ -268,6 +283,8 @@ public void RestoreOriginalSettings() var ov = originalValues; if (ov == null) return; + try + { using (var reg = Registry.CurrentUser.OpenSubKey(RegKeyInternetSettings, true)) { if (reg == null) return; @@ -307,6 +324,11 @@ public void RestoreOriginalSettings() // in Windows 7 or earlier at system shutdown. Refresh(); } + } + catch + { + // process-exit restore must not throw + } } internal static ProxyInfo? GetProxyInfoFromRegistry() diff --git a/src/Titanium.Web.Proxy/Helpers/TcpHelper.cs b/src/Titanium.Web.Proxy/Helpers/TcpHelper.cs index e077aa96c..6020b7b98 100644 --- a/src/Titanium.Web.Proxy/Helpers/TcpHelper.cs +++ b/src/Titanium.Web.Proxy/Helpers/TcpHelper.cs @@ -1,7 +1,9 @@ using System; +using System.Buffers.Binary; using System.IO; using System.Net.Sockets; using System.Runtime.InteropServices; +using System.Runtime.Versioning; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -13,10 +15,30 @@ namespace Titanium.Web.Proxy.Helpers; internal static class TcpHelper { /// - /// Gets the process id by local port number. + /// Gets the process id by local port number on the current OS. /// - /// Process id. - internal static unsafe int GetProcessIdByLocalPort(AddressFamily addressFamily, int localPort) // NOSONAR S6640 + /// Process id, or 0 when not found. + internal static int GetProcessIdByLocalPort(AddressFamily addressFamily, int localPort) + { + if (RunTime.IsWindows) + { + return GetProcessIdByLocalPortWindows(addressFamily, localPort); + } + + if (RunTime.IsLinux) + { + return GetProcessIdByLocalPortLinux(addressFamily, localPort); + } + + if (RunTime.IsMac) + { + return GetProcessIdByLocalPortMac(addressFamily, localPort); + } + + return 0; + } + + private static unsafe int GetProcessIdByLocalPortWindows(AddressFamily addressFamily, int localPort) // NOSONAR S6640 { var tcpTable = IntPtr.Zero; var tcpTableLength = 0; @@ -67,13 +89,155 @@ internal static unsafe int GetProcessIdByLocalPort(AddressFamily addressFamily, return 0; } + private static int GetProcessIdByLocalPortLinux(AddressFamily addressFamily, int localPort) + { + var path = addressFamily == AddressFamily.InterNetwork ? "/proc/net/tcp" : "/proc/net/tcp6"; + string contents; + try + { + contents = File.ReadAllText(path); + } + catch + { + return 0; + } + + if (!LinuxProcNetTcp.TryFindInodeForLocalPort(contents, localPort, out var inode)) + { + return 0; + } + + return LinuxProcNetTcp.FindProcessIdByInode(inode); + } + + [SupportedOSPlatform("macos")] + private static int GetProcessIdByLocalPortMac(AddressFamily addressFamily, int localPort) // NOSONAR S3776 -- macOS sysctl walk; splitting would not change Inspector-only cost. + { + var expectedFamily = addressFamily == AddressFamily.InterNetwork + ? NativeMethods.DarwinAfInet + : NativeMethods.DarwinAfInet6; + + int listBytes; + try + { + listBytes = NativeMethods.ProcListPids(NativeMethods.ProcAllPids, 0, null, 0); + } + catch + { + return 0; + } + + if (listBytes <= 0) + { + return 0; + } + + var pids = new int[listBytes / sizeof(int)]; + listBytes = NativeMethods.ProcListPids(NativeMethods.ProcAllPids, 0, pids, listBytes); + if (listBytes <= 0) + { + return 0; + } + + var pidCount = listBytes / sizeof(int); + var socketInfo = new byte[NativeMethods.SocketFdInfoSize]; + + for (var i = 0; i < pidCount; i++) + { + var pid = pids[i]; + if (pid <= 0) + { + continue; + } + + int fdBytes; + try + { + fdBytes = NativeMethods.ProcPidInfo(pid, NativeMethods.ProcPidListFds, 0, null, 0); + } + catch + { + continue; + } + + if (fdBytes <= 0) + { + continue; + } + + var fdBuffer = new byte[fdBytes]; + fdBytes = NativeMethods.ProcPidInfo(pid, NativeMethods.ProcPidListFds, 0, fdBuffer, fdBytes); + if (fdBytes <= 0) + { + continue; + } + + var fdCount = fdBytes / NativeMethods.ProcFdInfoSize; + for (var f = 0; f < fdCount; f++) + { + var offset = f * NativeMethods.ProcFdInfoSize; + var fd = BinaryPrimitives.ReadInt32LittleEndian(fdBuffer.AsSpan(offset, 4)); + var fdType = BinaryPrimitives.ReadUInt32LittleEndian(fdBuffer.AsSpan(offset + 4, 4)); + if (fdType != NativeMethods.ProxFdTypeSocket) + { + continue; + } + + int written; + try + { + written = NativeMethods.ProcPidFdInfo(pid, fd, NativeMethods.ProcPidFdSocketInfo, socketInfo, + NativeMethods.SocketFdInfoSize); + } + catch + { + continue; + } + + if (written < NativeMethods.SocketFdInfoSize) + { + continue; + } + + var soiKind = BinaryPrimitives.ReadInt32LittleEndian( + socketInfo.AsSpan(NativeMethods.SocketFdInfoOffsetSoiKind, 4)); + if (soiKind != NativeMethods.SockInfoTcp) + { + continue; + } + + var soiProtocol = BinaryPrimitives.ReadInt32LittleEndian( + socketInfo.AsSpan(NativeMethods.SocketFdInfoOffsetSoiProtocol, 4)); + if (soiProtocol != NativeMethods.IpProtoTcp) + { + continue; + } + + var soiFamily = BinaryPrimitives.ReadInt32LittleEndian( + socketInfo.AsSpan(NativeMethods.SocketFdInfoOffsetSoiFamily, 4)); + if (soiFamily != expectedFamily) + { + continue; + } + + var lportNet = BinaryPrimitives.ReadInt32LittleEndian( + socketInfo.AsSpan(NativeMethods.SocketFdInfoOffsetInsiLport, 4)); + var port = System.Net.IPAddress.NetworkToHostOrder(unchecked((short)lportNet)) & 0xFFFF; + if (port == localPort) + { + return pid; + } + } + } + + return 0; + } + /// /// Converts 32-bit integer from native byte order (little-endian) /// to network byte order for port, /// switches 0th and 1st bytes, and 2nd and 3rd bytes /// - /// - /// private static uint ToNetworkByteOrder(uint port) { return ((port >> 8) & 0x00FF00FFu) | ((port << 8) & 0xFF00FF00u); @@ -85,13 +249,6 @@ private static uint ToNetworkByteOrder(uint port) /// Useful for websocket requests /// Task-based Asynchronous Pattern /// - /// - /// - /// - /// - /// - /// - /// private static async Task SendRawTap(Stream clientStream, Stream serverStream, IBufferPool bufferPool, Action? onDataSend, Action? onDataReceive, CancellationTokenSource cancellationTokenSource) @@ -113,14 +270,6 @@ private static async Task SendRawTap(Stream clientStream, Stream serverStream, I /// as prefix /// Useful for websocket requests /// - /// - /// - /// - /// - /// - /// - /// The logger to report relay failures through. - /// internal static Task SendRaw(Stream clientStream, Stream serverStream, IBufferPool bufferPool, Action? onDataSend, Action? onDataReceive, CancellationTokenSource cancellationTokenSource, @@ -130,4 +279,4 @@ internal static Task SendRaw(Stream clientStream, Stream serverStream, IBufferPo return SendRawTap(clientStream, serverStream, bufferPool, onDataSend, onDataReceive, cancellationTokenSource); } -} \ No newline at end of file +} diff --git a/src/Titanium.Web.Proxy/Helpers/UnixCertificateTrust.cs b/src/Titanium.Web.Proxy/Helpers/UnixCertificateTrust.cs index 648561b8b..82cd7a884 100644 --- a/src/Titanium.Web.Proxy/Helpers/UnixCertificateTrust.cs +++ b/src/Titanium.Web.Proxy/Helpers/UnixCertificateTrust.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Security.Cryptography.X509Certificates; +using Titanium.Web.Proxy.Network; namespace Titanium.Web.Proxy.Helpers; @@ -9,11 +12,47 @@ namespace Titanium.Web.Proxy.Helpers; /// internal static class UnixCertificateTrust { + private const string SecurityBinary = "security"; + private const string NssDbDirName = "nssdb"; + private const string LibraryDirName = "Library"; + private const string KeychainsDirName = "Keychains"; + private const string LoginKeychainDbFile = "login.keychain-db"; + private const string LoginKeychainFile = "login.keychain"; + + private static readonly string[] WindowsCertutilCandidates = + [ + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + "NSS", "certutil.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Programs", "nss", "certutil.exe"), + ]; + + private static readonly string[] MacCertutilCandidates = + [ + "/opt/homebrew/opt/nss/bin/certutil", + "/usr/local/opt/nss/bin/certutil", + ]; + + private static readonly string[] MacBrewCandidates = + [ + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".homebrew", "bin", "brew"), + "/opt/homebrew/bin/brew", + "/usr/local/bin/brew", + ]; + + private static readonly string[] MacDumpTrustSettingsArgs = + [ + "dump-trust-settings -d", + "dump-trust-settings", + ]; + + private static readonly bool[] MacTrustExportAdminDomain = [true, false]; /// /// Trusts for SSL in the current-user store backends - /// (login keychain on macOS, NSS db on Linux). Returns false when tools are missing or fail. + /// (login keychain on macOS, NSS db on Linux). /// - public static bool TrustUserSsl(X509Certificate2 certificate, string friendlyName, + public static CertificateOsTrustResult TrustUserSsl(X509Certificate2 certificate, string friendlyName, IProcessRunner? runner = null) { runner ??= new ProcessRunner(); @@ -21,12 +60,20 @@ public static bool TrustUserSsl(X509Certificate2 certificate, string friendlyNam try { if (RunTime.IsMac) - return TrustMacUser(runner, cerPath); + return TrustMacUserDetailed(runner, cerPath, certificate); if (RunTime.IsLinux) - return TrustLinuxNss(runner, cerPath, friendlyName); + return TrustLinuxNssDetailed(runner, cerPath, friendlyName); - return false; + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Unsupported, + "OS SSL trust helpers are only available on macOS and Linux"); + } + catch (Exception ex) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, + "OS SSL trust failed: " + ex.Message); } finally { @@ -34,20 +81,35 @@ public static bool TrustUserSsl(X509Certificate2 certificate, string friendlyNam } } + /// Bool wrapper for callers that only need success/failure. + public static bool TryTrustUserSsl(X509Certificate2 certificate, string friendlyName, + IProcessRunner? runner = null) => + TrustUserSsl(certificate, friendlyName, runner).Succeeded; + /// /// Removes user SSL trust previously added by . + /// On macOS this also best-effort clears matching System keychain copies and trust + /// settings (admin password may be required) — Chrome trusts System roots even when + /// the login / .NET user store entry is gone. /// public static bool UntrustUserSsl(X509Certificate2 certificate, string friendlyName, - IProcessRunner? runner = null) + IProcessRunner? runner = null, IElevationPrompt? elevation = null) { runner ??= new ProcessRunner(); - if (RunTime.IsMac) - return UntrustMacUser(runner, certificate); + try + { + if (RunTime.IsMac) + return UntrustMacThorough(runner, certificate, friendlyName, elevation); - if (RunTime.IsLinux) - return UntrustLinuxNss(runner, friendlyName); + if (RunTime.IsLinux) + return UntrustLinuxNss(runner, certificate, friendlyName); - return false; + return false; + } + catch + { + return false; + } } /// @@ -69,6 +131,10 @@ public static bool TrustMachineSsl(X509Certificate2 certificate, string friendly return false; } + catch + { + return false; + } finally { TryDelete(cerPath); @@ -83,74 +149,844 @@ public static bool UntrustMachineSsl(X509Certificate2 certificate, string friend { runner ??= new ProcessRunner(); elevation ??= new OsElevationPrompt(runner); + try + { + if (RunTime.IsMac) + return UntrustMacThorough(runner, certificate, friendlyName, elevation); + + if (RunTime.IsLinux) + return UntrustLinuxSystem(elevation, friendlyName); + + return false; + } + catch + { + return false; + } + } + + /// + /// Detects how to install NSS certutil on this OS (package or Homebrew), if possible. + /// + public static CertificateOsTrustResult ProbeCertutilInstall(IProcessRunner? runner = null) + { + runner ??= new ProcessRunner(); + if (FindCertutil(runner) != null) + return CertificateOsTrustResult.Ok("certutil is available"); + + if (RunTime.IsLinux) + return ProbeLinuxCertutilInstall(runner); + + if (RunTime.IsMac) + return ProbeMacCertutilInstall(runner); + + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.CertutilMissing, + "certutil not found on PATH", + packageHint: null); + } + + private static CertificateOsTrustResult ProbeLinuxCertutilInstall(IProcessRunner runner) + { + var hint = DetectLinuxNssPackage(runner); + if (hint is null) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.CertutilMissing, + "certutil not found and no supported package manager (apt/dnf/zypper) was detected", + packageHint: null); + } + + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.CertutilMissing, + $"certutil not found. Install {hint.Package} for Chrome/Chromium (and Firefox profile) trust.", + packageHint: hint.Package); + } + + private static CertificateOsTrustResult ProbeMacCertutilInstall(IProcessRunner runner) + { + var brew = FindBrew(runner); + if (brew != null) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.CertutilMissing, + "certutil not found. Install via Homebrew: brew install nss", + packageHint: "nss", + brewAvailable: true); + } + + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.HomebrewMissing, + "certutil not found and Homebrew is not installed. Export the CA and import it in Firefox Authorities, or install Homebrew then retry.", + packageHint: "nss", + brewAvailable: false); + } + + private static CertificateOsTrustResult TryInstallLinuxNssCertutil( + IProcessRunner runner, IElevationPrompt elevation) + { + var hint = DetectLinuxNssPackage(runner); + if (hint is null) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.CertutilMissing, + "No supported package manager found to install certutil"); + } + + var result = elevation.RunElevated(hint.FileName, hint.Arguments); + if (result is null) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Cancelled, + "Package install cancelled or elevation unavailable"); + } + + if (!result.Succeeded) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, + string.IsNullOrWhiteSpace(result.StandardError) + ? $"Failed to install {hint.Package} (exit {result.ExitCode})" + : result.StandardError.Trim(), + packageHint: hint.Package); + } + + return FindCertutil(runner) != null + ? CertificateOsTrustResult.Ok($"Installed {hint.Package}") + : CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, + $"{hint.Package} install finished but certutil is still not on PATH", + packageHint: hint.Package); + } + + /// + /// Installs NSS tools providing certutil after explicit user consent + /// (Linux elevated package manager, or macOS brew install nss). + /// + public static CertificateOsTrustResult TryInstallNssCertutil( + IProcessRunner? runner = null, + IElevationPrompt? elevation = null) + { + runner ??= new ProcessRunner(); + elevation ??= new OsElevationPrompt(runner); + + if (FindCertutil(runner) != null) + return CertificateOsTrustResult.Ok("certutil already available"); + + if (RunTime.IsLinux) + return TryInstallLinuxNssCertutil(runner, elevation); + + if (RunTime.IsMac) + { + var brew = FindBrew(runner); + if (brew is null) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.HomebrewMissing, + "Homebrew not found; cannot install nss automatically"); + } + + var result = runner.Run(brew, "install nss"); + if (result is not { Succeeded: true }) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, + result?.StandardError.Trim() is { Length: > 0 } err + ? err + : "brew install nss failed", + packageHint: "nss", + brewAvailable: true); + } + + return FindCertutil(runner) != null + ? CertificateOsTrustResult.Ok("Installed nss via Homebrew") + : CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Failed, + "brew install nss finished but certutil is still not on PATH", + packageHint: "nss", + brewAvailable: true); + } + + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.Unsupported, + "Automatic certutil install is only supported on Linux and macOS"); + } + + /// Opens Keychain Access and optionally the certificate file for manual Always Trust. + public static bool OpenMacKeychainGuidance(string? cerPath = null, IProcessRunner? runner = null) + { + if (!RunTime.IsMac) return false; + runner ??= new ProcessRunner(); + runner.Run("open", "-a \"Keychain Access\""); + if (!string.IsNullOrWhiteSpace(cerPath) && File.Exists(cerPath)) + runner.Run("open", $"\"{cerPath}\""); + return true; + } + /// Writes a temp .cer and opens Keychain guidance for . + public static string? OpenMacKeychainGuidanceForCertificate( + X509Certificate2 certificate, + IProcessRunner? runner = null) + { + if (!RunTime.IsMac) return null; + // Friendly file name so Keychain's "trust this certificate?" dialog is recognizable. + var cerPath = WriteTempCer(certificate, forUserGuidance: true); + OpenMacKeychainGuidance(cerPath, runner); + return cerPath; + } + + /// Verifies whether the certificate is trusted for SSL on macOS (best-effort). + public static bool VerifyUserSslTrust(X509Certificate2 certificate, IProcessRunner? runner = null) + { + runner ??= new ProcessRunner(); if (RunTime.IsMac) - return UntrustMacSystem(elevation, certificate); + return VerifyMacSslTrust(runner, certificate); if (RunTime.IsLinux) - return UntrustLinuxSystem(elevation, friendlyName); + return VerifyLinuxNssTrust(runner, certificate); + + return false; + } + + /// + /// True when is present in the user NSS DB (any nickname) + /// with trust attributes that include SSL (Chromium reads ~/.pki/nssdb). + /// + internal static bool VerifyLinuxNssTrust(IProcessRunner runner, X509Certificate2 certificate) + { + var certutil = FindCertutil(runner); + if (certutil is null) return false; + + foreach (var nssDir in LinuxNssDatabaseDirectories().Where(Directory.Exists)) + { + var list = runner.Run(certutil, $"-d sql:{nssDir} -L"); + if (list is not { Succeeded: true }) + continue; + + // Match by certificate bytes, not nickname/CN. The same DER can sit under a + // legacy nickname ("Titanium Inspector Root Certificate") while the product CN + // is "Titanium Root Certificate Authority", and a CN substring hit would also + // false-positive against an unrelated nickname. + if (LinuxNssContainsCertificate(runner, certutil, nssDir, certificate, list.StandardOutput)) + return true; + } return false; } + /// + /// Best-effort: true when the certificate appears in the login keychain (by SHA-1 hash). + /// Presence does not imply SSL Always Trust. + /// + public static bool IsCertificateInLoginKeychain(X509Certificate2 certificate, IProcessRunner? runner = null) + { + if (!RunTime.IsMac) + return false; + + runner ??= new ProcessRunner(); + var sha1 = certificate.GetCertHashString(); + if (string.IsNullOrWhiteSpace(sha1)) + return false; + + // -a: all matching; -Z: print SHA-1. Match our hash in the dump. + var byHash = runner.Run(SecurityBinary, $"find-certificate -a -Z {sha1}"); + if (byHash is { Succeeded: true } && + byHash.StandardOutput.Contains(sha1, StringComparison.OrdinalIgnoreCase)) + return true; + + var commonName = certificate.GetNameInfo(X509NameType.SimpleName, forIssuer: false); + if (string.IsNullOrWhiteSpace(commonName)) + return false; + + var loginDb = UserLoginKeychainDbPath(); + var args = File.Exists(loginDb) + ? $"find-certificate -a -c \"{Escape(commonName)}\" -Z \"{loginDb}\"" + : $"find-certificate -a -c \"{Escape(commonName)}\" -Z"; + var byName = runner.Run(SecurityBinary, args); + return byName is { Succeeded: true } && + byName.StandardOutput.Contains(sha1, StringComparison.OrdinalIgnoreCase); + } + + /// Resolves NSS certutil on PATH (not Windows system certutil.exe). + public static string? FindCertutil(IProcessRunner runner) + { + if (RunTime.IsWindows) + { + // Windows ships Microsoft certutil.exe — it is not NSS and must not be used for profile DBs. + return WindowsCertutilCandidates.FirstOrDefault(File.Exists); + } + + var which = runner.Run("sh", "-c \"command -v certutil\""); + if (which is { Succeeded: true } && !string.IsNullOrWhiteSpace(which.StandardOutput)) + { + var line = which.StandardOutput + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + if (line.Length > 0 && !string.IsNullOrWhiteSpace(line[0])) + return line[0].Trim(); + } + + if (RunTime.IsMac) + { + return MacCertutilCandidates.FirstOrDefault(File.Exists); + } + + return null; + } + + internal static LinuxPackageHint? DetectLinuxNssPackage(IProcessRunner runner) + { + if (CommandExists(runner, "apt-get")) + return new LinuxPackageHint("apt-get", "install -y libnss3-tools", "libnss3-tools"); + if (CommandExists(runner, "dnf")) + return new LinuxPackageHint("dnf", "install -y nss-tools", "nss-tools"); + if (CommandExists(runner, "yum")) + return new LinuxPackageHint("yum", "install -y nss-tools", "nss-tools"); + if (CommandExists(runner, "zypper")) + return new LinuxPackageHint("zypper", "--non-interactive install mozilla-nss-tools", "mozilla-nss-tools"); + return null; + } + + internal static string? FindBrew(IProcessRunner runner) + { + if (!RunTime.IsMac) return null; + var which = runner.Run("sh", "-c \"command -v brew\""); + if (which is { Succeeded: true } && !string.IsNullOrWhiteSpace(which.StandardOutput)) + return which.StandardOutput.Trim().Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)[0]; + + return MacBrewCandidates.FirstOrDefault(File.Exists); + } + + private static bool CommandExists(IProcessRunner runner, string name) + { + var which = runner.Run("sh", $"-c \"command -v {name}\""); + return which is { Succeeded: true } && !string.IsNullOrWhiteSpace(which.StandardOutput); + } + + private static CertificateOsTrustResult TrustMacUserDetailed( + IProcessRunner runner, string cerPath, X509Certificate2 certificate) + { + var added = TrustMacUser(runner, cerPath); + if (!added) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.MacKeychainFailed, + "Failed to add the root CA to the login keychain"); + } + + if (VerifyMacSslTrust(runner, certificate)) + return CertificateOsTrustResult.Ok("Root CA trusted in login keychain"); + + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.MacNeedsManualTrustConfirm, + "Root CA was added to Keychain but may need Always Trust for SSL. Open Keychain Access, find the certificate, and set Trust → Always Trust."); + } + private static bool TrustMacUser(IProcessRunner runner, string cerPath) { - // -d: add to admin cert store domain; -r trustRoot: trust as root CA. - var keychain = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", "Keychains", "login.keychain-db"); - var result = runner.Run("security", - $"add-trusted-cert -d -r trustRoot -k \"{keychain}\" \"{cerPath}\""); + // User trust domain (no -d). Using -d writes admin-domain stubs and often leaves + // System.keychain copies that Chrome keeps trusting after "Remove CA". + // -r trustRoot: trust as root CA in the login keychain. + var keychain = UserLoginKeychainDbPath(); + var result = runner.Run(SecurityBinary, + $"add-trusted-cert -r trustRoot -k \"{keychain}\" \"{cerPath}\""); if (result is { Succeeded: true }) return true; // Older macOS keychain name - keychain = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Library", "Keychains", "login.keychain"); - result = runner.Run("security", - $"add-trusted-cert -d -r trustRoot -k \"{keychain}\" \"{cerPath}\""); + keychain = UserLoginKeychainPath(); + result = runner.Run(SecurityBinary, + $"add-trusted-cert -r trustRoot -k \"{keychain}\" \"{cerPath}\""); return result is { Succeeded: true }; } - private static bool UntrustMacUser(IProcessRunner runner, X509Certificate2 certificate) + private static bool VerifyMacSslTrust(IProcessRunner runner, X509Certificate2 certificate) + { + // IMPORTANT: `security verify-cert -p ssl` often succeeds when the CA is merely present + // in login.keychain. Keychain Access Get Info can also show "Always Trust" for incomplete + // trust-list entries that have no policy array — Chrome still rejects MITM until real + // SecTrustSettings policies exist (dump-trust-settings / export with trustSettings). + return HasExplicitMacSslTrustSettings(runner, certificate); + } + + /// + /// True when macOS has persisted SSL/root trust policies for this certificate + /// (not merely a trust-list stub or Keychain UI display state). + /// + internal static bool HasExplicitMacSslTrustSettings(IProcessRunner runner, X509Certificate2 certificate) { var sha1 = certificate.GetCertHashString(); - var result = runner.Run("security", $"delete-certificate -Z {sha1}"); - return result is { Succeeded: true }; + var commonName = certificate.GetNameInfo(X509NameType.SimpleName, forIssuer: false) ?? ""; + + if (DumpTrustSettingsMentionsPolicies(runner, sha1, commonName)) + return true; + + return TrustSettingsExportHasPolicies(runner, sha1); } - private static bool TrustMacSystem(IElevationPrompt elevation, string cerPath) + private static bool DumpTrustSettingsMentionsPolicies( + IProcessRunner runner, string sha1, string commonName) { - var result = elevation.RunElevated("/usr/bin/security", - $"add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain \"{cerPath}\""); - return result is { Succeeded: true }; + foreach (var args in MacDumpTrustSettingsArgs) + { + var dump = runner.Run(SecurityBinary, args); + if (dump is null) + continue; + + var text = dump.StandardOutput + "\n" + dump.StandardError; + if (text.Contains("No Trust Settings were found", StringComparison.OrdinalIgnoreCase)) + continue; + + var mentionsCert = + (!string.IsNullOrEmpty(sha1) && + text.Contains(sha1, StringComparison.OrdinalIgnoreCase)) || + (!string.IsNullOrEmpty(commonName) && + text.Contains(commonName, StringComparison.OrdinalIgnoreCase)); + if (!mentionsCert) + continue; + + // dump-trust-settings only lists certs that have policy rows when healthy. + if (text.Contains("kSecTrustSettingsResultTrustRoot", StringComparison.Ordinal) || + text.Contains("kSecTrustSettingsResultProceed", StringComparison.Ordinal) || + text.Contains("Trust Root", StringComparison.OrdinalIgnoreCase) || + text.Contains("Number of trust settings", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + /// Parses security trust-settings-export. A trustList stub without + /// trustSettings policies is NOT enough (Keychain UI may still show Always Trust). + /// + private static bool TrustSettingsExportHasPolicies(IProcessRunner runner, string sha1) // NOSONAR S3776 -- trust-settings-export parse is a single plist window scan. + { + if (string.IsNullOrEmpty(sha1)) + return false; + + foreach (var adminDomain in MacTrustExportAdminDomain) + { + var path = Path.Combine(Path.GetTempPath(), "twp-trust-" + Guid.NewGuid().ToString("N") + ".plist"); + try + { + var args = adminDomain + ? $"trust-settings-export -d \"{path}\"" + : $"trust-settings-export \"{path}\""; + var export = runner.Run(SecurityBinary, args); + if (export is not { Succeeded: true } || !File.Exists(path)) + continue; + + // Avoid pulling a plist library dependency: scan the XML/binary via plutil text. + var printed = runner.Run("plutil", $"-p \"{path}\""); + if (printed is null) + continue; + + var text = printed.StandardOutput; + // Look for our SHA-1 key block; require a nested trustSettings array nearby. + var keyIdx = text.IndexOf(sha1, StringComparison.OrdinalIgnoreCase); + if (keyIdx < 0) + continue; + + // Heuristic: within the next ~2KB after the hash key, require trustSettings. + var window = text.Substring(keyIdx, Math.Min(2048, text.Length - keyIdx)); + if (!window.Contains("trustSettings", StringComparison.OrdinalIgnoreCase)) + continue; + + // Empty array / missing policies: reject. + if (window.Contains("trustSettings => [\n ]", StringComparison.Ordinal) || + window.Contains("trustSettings => []", StringComparison.Ordinal)) + continue; + + if (window.Contains("kSecTrustSettingsResultTrustRoot", StringComparison.Ordinal) || + window.Contains("kSecTrustSettingsResultProceed", StringComparison.Ordinal) || + window.Contains("TrustRoot", StringComparison.OrdinalIgnoreCase) || + window.Contains("result", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + finally + { + TryDelete(path); + } + } + + return false; } - private static bool UntrustMacSystem(IElevationPrompt elevation, X509Certificate2 certificate) + /// + /// Removes every Titanium-named / current-hash copy from login + System keychains and + /// clears user/admin trust settings. System deletes require an admin password prompt. + /// + private static bool UntrustMacThorough( + IProcessRunner runner, + X509Certificate2 certificate, + string friendlyName, + IElevationPrompt? elevation = null) { + elevation ??= new OsElevationPrompt(runner); + + var hashes = new HashSet(StringComparer.OrdinalIgnoreCase); var sha1 = certificate.GetCertHashString(); + if (!string.IsNullOrWhiteSpace(sha1)) + hashes.Add(sha1); + + foreach (var cn in MacRootCommonNames(friendlyName, certificate)) + CollectMacCertificateHashes(runner, cn, hashes); + + var any = false; + var cerPath = WriteTempCer(certificate); + try + { + foreach (var hash in hashes) + { + // -t also drops user trust settings for this cert. + var login = runner.Run(SecurityBinary, $"delete-certificate -Z {hash} -t"); + if (login is { Succeeded: true }) + any = true; + } + + // One admin prompt for System.keychain + admin trust domain. + if (hashes.Count > 0 || File.Exists(cerPath)) + { + var parts = new List(); + foreach (var hash in hashes) + { + parts.Add( + $"/usr/bin/security delete-certificate -Z {hash} -t /Library/Keychains/System.keychain || true"); + } + + parts.Add($"/usr/bin/security remove-trusted-cert -d \"{cerPath}\" || true"); + var script = string.Join("; ", parts); + var elevated = elevation.RunElevated("/bin/sh", $"-c \"{EscapeShell(script)}\""); + if (elevated is { Succeeded: true }) + any = true; + } + + var userTrust = runner.Run(SecurityBinary, $"remove-trusted-cert \"{cerPath}\""); + if (userTrust is { Succeeded: true }) + any = true; + } + finally + { + TryDelete(cerPath); + } + + return any || hashes.Count == 0; + } + + private static HashSet MacRootCommonNames(string friendlyName, X509Certificate2 certificate) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + void Add(string? name) + { + if (!string.IsNullOrWhiteSpace(name)) + seen.Add(name.Trim()); + } + + Add(friendlyName); + Add(certificate.GetNameInfo(X509NameType.SimpleName, forIssuer: false)); + Add("Titanium Root Certificate Authority"); + Add("Titanium Inspector Root Certificate"); + return seen; + } + + private static void CollectMacCertificateHashes( + IProcessRunner runner, string commonName, HashSet hashes) + { + if (string.IsNullOrWhiteSpace(commonName)) + return; + + var loginDb = UserLoginKeychainDbPath(); + var searches = new List + { + $"find-certificate -a -c \"{Escape(commonName)}\" -Z", + }; + if (File.Exists(loginDb)) + searches.Add($"find-certificate -a -c \"{Escape(commonName)}\" -Z \"{loginDb}\""); + searches.Add( + $"find-certificate -a -c \"{Escape(commonName)}\" -Z /Library/Keychains/System.keychain"); + + foreach (var args in searches) + { + var dump = runner.Run(SecurityBinary, args); + if (dump is null) + continue; + + foreach (var line in (dump.StandardOutput + "\n" + dump.StandardError) + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + // "SHA-1 hash: AABBCC..." + const string marker = "SHA-1 hash:"; + var idx = line.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + continue; + var hash = line[(idx + marker.Length)..].Trim(); + if (hash.Length >= 40) + hashes.Add(hash); + } + } + } + + /// + /// True when any Titanium-named certificate remains in login or System keychain, + /// or the current root hash is still findable. + /// + internal static bool IsMacRootStillPresent( + IProcessRunner runner, X509Certificate2 certificate, string friendlyName) + { + var found = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var cn in MacRootCommonNames(friendlyName, certificate)) + CollectMacCertificateHashes(runner, cn, found); + if (found.Count > 0) + return true; + + var sha1 = certificate.GetCertHashString(); + if (string.IsNullOrWhiteSpace(sha1)) + return false; + + var byHash = runner.Run(SecurityBinary, $"find-certificate -a -Z {sha1}"); + return byHash is { Succeeded: true } && + byHash.StandardOutput.Contains(sha1, StringComparison.OrdinalIgnoreCase); + } + + private static bool TrustMacSystem(IElevationPrompt elevation, string cerPath) + { var result = elevation.RunElevated("/usr/bin/security", - $"delete-certificate -Z {sha1} /Library/Keychains/System.keychain"); + $"add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain \"{cerPath}\""); return result is { Succeeded: true }; } - private static bool TrustLinuxNss(IProcessRunner runner, string cerPath, string friendlyName) + private static CertificateOsTrustResult TrustLinuxNssDetailed( + IProcessRunner runner, string cerPath, string friendlyName) { + if (FindCertutil(runner) is null) + return ProbeCertutilInstall(runner); + var nssDir = EnsureLinuxNssDb(runner); - if (nssDir is null) return false; + if (nssDir is null) + { + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.NssFailed, + "Could not initialize the user NSS database (~/.pki/nssdb)"); + } + + var certutil = FindCertutil(runner); + if (certutil is null) + return ProbeCertutilInstall(runner); - var result = runner.Run("certutil", + // Drop any prior nickname so -A is not a silent no-op when the DER already exists + // under a different nickname (certutil exits 0 without listing the new name). + runner.Run(certutil, $"-d sql:{nssDir} -D -n \"{Escape(friendlyName)}\""); + + var result = runner.Run(certutil, $"-d sql:{nssDir} -A -t \"C,,\" -n \"{Escape(friendlyName)}\" -i \"{cerPath}\""); - return result is { Succeeded: true }; + if (result is not { Succeeded: true }) + { + var error = result?.StandardError; + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.NssFailed, + string.IsNullOrWhiteSpace(error) + ? "certutil failed to add the root CA to ~/.pki/nssdb" + : error.Trim()); + } + + if (NssListContainsNickname(runner, certutil, nssDir, friendlyName)) + { + TryTrustAdditionalLinuxNss(runner, certutil, cerPath, friendlyName); + return CertificateOsTrustResult.Ok("Root CA trusted in user NSS database"); + } + + // DER collision under another nickname: remove matching entries and re-add. + if (TryReloadCertificateFromCer(cerPath) is { } cert) + RemoveLinuxNssEntriesMatching(runner, certutil, nssDir, cert); + + result = runner.Run(certutil, + $"-d sql:{nssDir} -A -t \"C,,\" -n \"{Escape(friendlyName)}\" -i \"{cerPath}\""); + if (result is { Succeeded: true } && + NssListContainsNickname(runner, certutil, nssDir, friendlyName)) + { + TryTrustAdditionalLinuxNss(runner, certutil, cerPath, friendlyName); + return CertificateOsTrustResult.Ok("Root CA trusted in user NSS database"); + } + + return CertificateOsTrustResult.Fail( + CertificateOsTrustKind.NssFailed, + "certutil reported success but the root CA nickname is missing from ~/.pki/nssdb"); } - private static bool UntrustLinuxNss(IProcessRunner runner, string friendlyName) + private static bool NssListContainsNickname( + IProcessRunner runner, string certutil, string nssDir, string friendlyName) { - var nssDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".pki", "nssdb"); - if (!Directory.Exists(nssDir)) return false; - var result = runner.Run("certutil", $"-d sql:{nssDir} -D -n \"{Escape(friendlyName)}\""); - return result is { Succeeded: true }; + var list = runner.Run(certutil, $"-d sql:{nssDir} -L"); + return list is { Succeeded: true } && + list.StandardOutput.Contains(friendlyName, StringComparison.OrdinalIgnoreCase); + } + + private static X509Certificate2? TryReloadCertificateFromCer(string cerPath) + { + try + { + return X509CertificateLoader.LoadCertificateFromFile(cerPath); + } + catch + { + return null; + } + } + + private static void RemoveLinuxNssEntriesMatching( + IProcessRunner runner, string certutil, string nssDir, X509Certificate2 certificate) + { + var list = runner.Run(certutil, $"-d sql:{nssDir} -L"); + if (list is not { Succeeded: true }) + return; + + foreach (var nick in ParseNssNicknames(list.StandardOutput)) + { + if (!LinuxNssNicknameMatches(runner, certutil, nssDir, nick, certificate)) + continue; + runner.Run(certutil, $"-d sql:{nssDir} -D -n \"{Escape(nick)}\""); + } + } + + private static bool LinuxNssContainsCertificate( + IProcessRunner runner, string certutil, string nssDir, X509Certificate2 certificate, string listOutput) + { + return ParseNssNicknames(listOutput).Any(nick => + LinuxNssNicknameMatches(runner, certutil, nssDir, nick, certificate)); + } + + private static bool LinuxNssNicknameMatches( + IProcessRunner runner, string certutil, string nssDir, string nickname, X509Certificate2 certificate) + { + var dumped = runner.Run(certutil, $"-d sql:{nssDir} -L -n \"{Escape(nickname)}\" -a"); + if (dumped is not { Succeeded: true } || string.IsNullOrWhiteSpace(dumped.StandardOutput)) + return false; + + try + { + using var loaded = X509Certificate2.CreateFromPem(dumped.StandardOutput); + return string.Equals(loaded.Thumbprint, certificate.Thumbprint, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + private static IEnumerable ParseNssNicknames(string certutilListOutput) + { + foreach (var raw in certutilListOutput.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + var line = raw.TrimEnd(); + if (line.Length == 0 || + line.StartsWith("Certificate Nickname", StringComparison.OrdinalIgnoreCase) || + line.StartsWith("SSL,", StringComparison.OrdinalIgnoreCase) || + line.All(c => c == '-' || char.IsWhiteSpace(c))) + continue; + + // "Nickname ... spaces ... Trust" + var nick = line; + var trustIdx = line.LastIndexOf(" ", StringComparison.Ordinal); + if (trustIdx > 0) + nick = line[..trustIdx].TrimEnd(); + if (nick.Length > 0) + yield return nick; + } + } + + private static bool UntrustLinuxNss( + IProcessRunner runner, X509Certificate2 certificate, string friendlyName) + { + var certutil = FindCertutil(runner); + if (certutil is null) + return false; + + var anyDb = false; + var deleted = false; + foreach (var nssDir in LinuxNssDatabaseDirectories().Where(Directory.Exists)) + { + anyDb = true; + if (UntrustLinuxNssDirectory(runner, certutil, nssDir, certificate, friendlyName)) + deleted = true; + } + + if (!anyDb) + return true; + + return deleted || !VerifyLinuxNssTrust(runner, certificate); + } + + private static bool UntrustLinuxNssDirectory( + IProcessRunner runner, string certutil, string nssDir, X509Certificate2 certificate, string friendlyName) + { + // certutil -A is a silent no-op when the same DER exists under another nickname + // (legacy "Titanium Inspector Root Certificate" vs current CN). Delete every + // matching nickname so Remove CA actually clears Chrome trust. + var nicks = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!string.IsNullOrWhiteSpace(friendlyName)) + nicks.Add(friendlyName); + + var list = runner.Run(certutil, $"-d sql:{nssDir} -L"); + if (list is { Succeeded: true }) + { + foreach (var nick in ParseNssNicknames(list.StandardOutput)) // NOSONAR S3267 -- nickname set is mutated while matching NSS dumps. + { + if (nicks.Contains(nick) || + LinuxNssNicknameMatches(runner, certutil, nssDir, nick, certificate)) + nicks.Add(nick); + } + } + + var deleted = false; + foreach (var nick in nicks) + { + var result = runner.Run(certutil, $"-d sql:{nssDir} -D -n \"{Escape(nick)}\""); + if (result is { Succeeded: true }) + deleted = true; + } + + return deleted; + } + + private static void TryTrustAdditionalLinuxNss( + IProcessRunner runner, string certutil, string cerPath, string friendlyName) + { + var primary = UserPkiNssDbPath(); + foreach (var nssDir in LinuxNssDatabaseDirectories() + .Where(d => !string.Equals(d, primary, StringComparison.Ordinal)) + .Where(Directory.Exists)) + { + try + { + runner.Run(certutil, $"-d sql:{nssDir} -D -n \"{Escape(friendlyName)}\""); + runner.Run(certutil, + $"-d sql:{nssDir} -A -t \"C,,\" -n \"{Escape(friendlyName)}\" -i \"{cerPath}\""); + } + catch + { + // Snap/Flatpak DBs are best-effort. + } + } + } + + internal static IEnumerable LinuxNssDatabaseDirectories() + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + yield return Path.Combine(home, ".pki", NssDbDirName); + yield return Path.Combine(home, "snap", "chromium", "common", ".pki", NssDbDirName); + yield return Path.Combine(home, "snap", "chromium", "current", ".pki", NssDbDirName); + yield return Path.Combine(home, "snap", "google-chrome", "common", ".pki", NssDbDirName); + yield return Path.Combine(home, "snap", "google-chrome", "current", ".pki", NssDbDirName); + yield return Path.Combine(home, ".var", "app", "org.chromium.Chromium", ".pki", NssDbDirName); + yield return Path.Combine(home, ".var", "app", "com.google.Chrome", ".pki", NssDbDirName); + yield return Path.Combine(home, ".var", "app", "com.brave.Browser", ".pki", NssDbDirName); + // Microsoft Edge (deb) shares ~/.pki/nssdb; Snap/Flatpak keep private DBs when present. + yield return Path.Combine(home, "snap", "microsoft-edge", "common", ".pki", NssDbDirName); + yield return Path.Combine(home, "snap", "microsoft-edge", "current", ".pki", NssDbDirName); + yield return Path.Combine(home, ".var", "app", "com.microsoft.Edge", ".pki", NssDbDirName); } private static bool TrustLinuxSystem(IElevationPrompt elevation, string cerPath, string friendlyName) @@ -175,26 +1011,39 @@ private static bool UntrustLinuxSystem(IElevationPrompt elevation, string friend private static string? EnsureLinuxNssDb(IProcessRunner runner) { - var which = runner.Run("sh", "-c \"command -v certutil\""); - if (which is not { Succeeded: true } || string.IsNullOrWhiteSpace(which.StandardOutput)) + var certutil = FindCertutil(runner); + if (certutil is null) return null; - var nssDir = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".pki", "nssdb"); + var nssDir = UserPkiNssDbPath(); Directory.CreateDirectory(nssDir); if (!File.Exists(Path.Combine(nssDir, "cert9.db")) && !File.Exists(Path.Combine(nssDir, "cert8.db"))) { - var init = runner.Run("certutil", $"-d sql:{nssDir} -N --empty-password"); + var init = runner.Run(certutil, $"-d sql:{nssDir} -N --empty-password"); if (init is not { Succeeded: true }) return null; } return nssDir; } - private static string WriteTempCer(X509Certificate2 certificate) + internal static string WriteTempCer(X509Certificate2 certificate, bool forUserGuidance = false) { - var path = Path.Combine(Path.GetTempPath(), "twp-" + Guid.NewGuid().ToString("N") + ".cer"); + string fileName; + if (forUserGuidance) + { + var cn = certificate.GetNameInfo(X509NameType.SimpleName, forIssuer: false); + if (string.IsNullOrWhiteSpace(cn)) + cn = "Titanium-Inspector-Root-CA"; + fileName = SanitizeFileName(cn.Trim()) + ".cer"; + } + else + { + // Internal ops: unique name avoids races between concurrent trust helpers. + fileName = "twp-" + Guid.NewGuid().ToString("N") + ".cer"; + } + + var path = Path.Combine(Path.GetTempPath(), fileName); File.WriteAllBytes(path, certificate.Export(X509ContentType.Cert)); return path; } @@ -205,6 +1054,20 @@ private static void TryDelete(string path) catch { /* best effort */ } } + private static string UserLoginKeychainDbPath() => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + LibraryDirName, KeychainsDirName, LoginKeychainDbFile); + + private static string UserLoginKeychainPath() => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + LibraryDirName, KeychainsDirName, LoginKeychainFile); + + private static string UserPkiNssDbPath() => + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".pki", NssDbDirName); + private static string Escape(string value) => value.Replace("\"", "\\\""); private static string EscapeShell(string value) => value.Replace("\"", "\\\""); @@ -218,4 +1081,6 @@ private static string SanitizeFileName(string name) chars[i] = '-'; return new string(chars); } + + internal sealed record LinuxPackageHint(string FileName, string Arguments, string Package); } diff --git a/src/Titanium.Web.Proxy/Helpers/UnixProcessPath.cs b/src/Titanium.Web.Proxy/Helpers/UnixProcessPath.cs new file mode 100644 index 000000000..ab60181c2 --- /dev/null +++ b/src/Titanium.Web.Proxy/Helpers/UnixProcessPath.cs @@ -0,0 +1,11 @@ +using System.IO; +using System.Linq; + +namespace Titanium.Web.Proxy.Helpers; + +/// Resolves well-known Unix binaries to absolute paths for Process.Start (S4036). +internal static class UnixProcessPath +{ + internal static string? Resolve(params string[] candidates) => + candidates.FirstOrDefault(path => !string.IsNullOrEmpty(path) && File.Exists(path)); +} diff --git a/src/Titanium.Web.Proxy/Helpers/UnixProxyBypassMapper.cs b/src/Titanium.Web.Proxy/Helpers/UnixProxyBypassMapper.cs index 0eed28dcf..0d392e931 100644 --- a/src/Titanium.Web.Proxy/Helpers/UnixProxyBypassMapper.cs +++ b/src/Titanium.Web.Proxy/Helpers/UnixProxyBypassMapper.cs @@ -9,7 +9,7 @@ namespace Titanium.Web.Proxy.Helpers; /// /// Maps WinINET-style semicolon bypass lists to macOS/Linux formats. /// -internal static class UnixProxyBypassMapper +public static class UnixProxyBypassMapper { public static IReadOnlyList ToUnixBypassHosts(string? winInetProxyOverride) { @@ -59,16 +59,43 @@ public static string ToGsettingsArray(string? winInetProxyOverride) return sb.ToString(); } - public static string ToNoProxyEnv(string? winInetProxyOverride) + public static string ToNoProxyEnv(string? winInetProxyOverride) => + ToNoProxyEnv(winInetProxyOverride, HasLoopbackSubtractRule(winInetProxyOverride)); + + /// + /// Builds a NO_PROXY value. When is true, localhost is omitted + /// so loopback traffic can use the proxy (parity with WinINET <-loopback>). + /// + public static string ToNoProxyEnv(string? winInetProxyOverride, bool proxyLoopback) { var hosts = ToUnixBypassHosts(winInetProxyOverride).ToList(); - if (!hosts.Any(h => h.Equals("localhost", StringComparison.OrdinalIgnoreCase))) - hosts.Insert(0, "localhost"); - if (!hosts.Any(h => h.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase))) - hosts.Insert(0, "127.0.0.1"); + if (!proxyLoopback) + { + if (!hosts.Any(h => h.Equals("localhost", StringComparison.OrdinalIgnoreCase))) + { + hosts.Insert(0, "localhost"); + } + + if (!hosts.Any(h => h.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase))) + { + hosts.Insert(0, "127.0.0.1"); + } + } + return string.Join(",", hosts); } + private static bool HasLoopbackSubtractRule(string? winInetProxyOverride) + { + if (string.IsNullOrWhiteSpace(winInetProxyOverride)) + { + return false; + } + + return winInetProxyOverride.Split(';') + .Any(r => r.Trim().Equals("<-loopback>", StringComparison.OrdinalIgnoreCase)); + } + public static bool IsLocalHost(string? host) { if (string.IsNullOrWhiteSpace(host)) return false; diff --git a/src/Titanium.Web.Proxy/Http/HeaderBuilder.cs b/src/Titanium.Web.Proxy/Http/HeaderBuilder.cs index 6d69b5802..7d527f849 100644 --- a/src/Titanium.Web.Proxy/Http/HeaderBuilder.cs +++ b/src/Titanium.Web.Proxy/Http/HeaderBuilder.cs @@ -14,6 +14,9 @@ internal class HeaderBuilder private static HeaderBuilder? cached; private readonly MemoryStream stream = new(256); +#if DEBUG + private bool inUse; +#endif /// Rents a thread-local builder (cleared). Caller must it. public static HeaderBuilder Rent() @@ -23,10 +26,17 @@ public static HeaderBuilder Rent() { cached = null; builder.stream.SetLength(0); +#if DEBUG + builder.inUse = true; +#endif return builder; } - return new HeaderBuilder(); + var created = new HeaderBuilder(); +#if DEBUG + created.inUse = true; +#endif + return created; } /// Returns a builder to the thread-local cache. @@ -36,6 +46,9 @@ public static HeaderBuilder Rent() /// public static void Return(HeaderBuilder builder) { +#if DEBUG + builder.inUse = false; +#endif if (cached == null) cached = builder; } @@ -208,6 +221,10 @@ private void WriteDecimal(int value) public ArraySegment GetBuffer() { +#if DEBUG + if (!inUse) + throw new InvalidOperationException("HeaderBuilder.GetBuffer after Return; the instance may have been reused."); +#endif if (!stream.TryGetBuffer(out var buffer)) throw new InvalidOperationException("The header buffer is unexpectedly unavailable."); diff --git a/src/Titanium.Web.Proxy/Http/HeaderCollection.cs b/src/Titanium.Web.Proxy/Http/HeaderCollection.cs index 1db21b845..2c090b6ff 100644 --- a/src/Titanium.Web.Proxy/Http/HeaderCollection.cs +++ b/src/Titanium.Web.Proxy/Http/HeaderCollection.cs @@ -14,11 +14,11 @@ namespace Titanium.Web.Proxy.Http; [TypeConverter(typeof(ExpandableObjectConverter))] public class HeaderCollection : IEnumerable { - private readonly Dictionary headers; + private Dictionary headers; - private readonly Dictionary> nonUniqueHeaders; + private Dictionary> nonUniqueHeaders; - private readonly Dictionary> nonUniqueHeadersReadOnly; + private Dictionary> nonUniqueHeadersReadOnly; /// /// Monotonic counter bumped on every mutating API (, , @@ -27,29 +27,172 @@ public class HeaderCollection : IEnumerable /// internal int MutationCount { get; private set; } + /// + /// When armed, pure AddHeader of new unique names are logged without a full snapshot + /// (Full MITM append-relay). Other mutations either snapshot (legacy COW) or mark dirty + /// so Take falls back to re-encode. + /// + private bool _mitmRelayCowArmed; + private int _mitmRelayCowMutationCount; + private Dictionary? _mitmRelayCowUnique; + private Dictionary>? _mitmRelayCowNonUnique; + private int _mitmRelayCowNonUniqueNames; + private Helpers.MitmCompressedRelayHelper.AddedHeaderBuffer _mitmRelayAppends; + private bool _mitmRelayAppendDirty; + + /// + /// Arm copy-on-write baseline for MITM unchanged-lite. Call after wire decode, before handlers. + /// Lite (no mutations) never allocates a header snapshot; Full append-only logs adds without + /// cloning the wire header maps. + /// + internal void ArmMitmRelayBaseline() + { + _mitmRelayCowArmed = true; + _mitmRelayCowMutationCount = MutationCount; + _mitmRelayCowUnique = null; + _mitmRelayCowNonUnique = null; + _mitmRelayCowNonUniqueNames = 0; + _mitmRelayAppends = default; + _mitmRelayAppendDirty = false; + } + + /// + /// Build the relay baseline after handlers: MutationCount-only when unchanged, append-log when + /// only new unique headers were added, else the pre-mutation snapshot taken on first complex mutate. + /// + internal Helpers.MitmCompressedRelayHelper.HeaderRelayBaseline TakeMitmRelayBaseline() + { + _mitmRelayCowArmed = false; + if (_mitmRelayCowUnique is null) + { + if (!_mitmRelayAppendDirty && _mitmRelayAppends.Count > 0) + { + var appendBaseline = Helpers.MitmCompressedRelayHelper.HeaderRelayBaseline.FromAppendLog( + _mitmRelayCowMutationCount, _mitmRelayAppends); + _mitmRelayAppends = default; + return appendBaseline; + } + + _mitmRelayAppends = default; + _mitmRelayAppendDirty = false; + return Helpers.MitmCompressedRelayHelper.HeaderRelayBaseline.CaptureMutationCountFromCount( + _mitmRelayCowMutationCount); + } + + var baseline = new Helpers.MitmCompressedRelayHelper.HeaderRelayBaseline( + _mitmRelayCowMutationCount, _mitmRelayCowUnique, _mitmRelayCowNonUnique, + _mitmRelayCowNonUniqueNames); + _mitmRelayCowUnique = null; + _mitmRelayCowNonUnique = null; + _mitmRelayAppends = default; + _mitmRelayAppendDirty = false; + return baseline; + } + + /// + /// Move decoded headers from a scratch collection into this empty destination (H2 MITM). + /// Swaps the dictionary instances when this bag is empty so the hot path avoids a second + /// insert pass; scratch keeps the prior empty maps for the next decode. + /// + internal void TakeContentsFrom(HeaderCollection source) + { + if (ReferenceEquals(this, source)) + return; + + if (headers.Count != 0 || nonUniqueHeaders.Count != 0) + { + foreach (var header in source) + AddHeader(header); + source.Clear(); + return; + } + + // Swap maps — O(1). Destination inherits MutationCount; scratch is left empty for Reset/reuse. + (headers, source.headers) = (source.headers, headers); + (nonUniqueHeaders, source.nonUniqueHeaders) = (source.nonUniqueHeaders, nonUniqueHeaders); + (nonUniqueHeadersReadOnly, source.nonUniqueHeadersReadOnly) = + (source.nonUniqueHeadersReadOnly, nonUniqueHeadersReadOnly); + MutationCount = source.MutationCount; + source.MutationCount = 0; + source._mitmRelayCowArmed = false; + source._mitmRelayCowUnique = null; + source._mitmRelayCowNonUnique = null; + source._mitmRelayCowNonUniqueNames = 0; + source._mitmRelayAppends = default; + source._mitmRelayAppendDirty = false; + headersView = null; + nonUniqueHeadersView = null; + source.headersView = null; + source.nonUniqueHeadersView = null; + } + + private void InvalidateMitmRelayAppendLog() + { + _mitmRelayAppends = default; + _mitmRelayAppendDirty = true; + } + + private void EnsureMitmRelayCowSnapshot() + { + if (!_mitmRelayCowArmed || _mitmRelayCowUnique is not null) + return; + + // Append-log already committed adds into this collection; falling back to a snapshot of the + // *current* maps would treat those adds as baseline. Mark dirty so Take forces re-encode. + if (_mitmRelayAppends.Count > 0 || _mitmRelayAppendDirty) + { + InvalidateMitmRelayAppendLog(); + return; + } + + var unique = new Dictionary(headers.Count, StringComparer.OrdinalIgnoreCase); + foreach (var kv in headers) + unique[kv.Key] = kv.Value.Value; + + var nonUnique = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var kv in nonUniqueHeaders) + { + var values = new List(kv.Value.Count); + foreach (var h in kv.Value) + values.Add(h.Value); + nonUnique[kv.Key] = values; + } + + _mitmRelayCowUnique = unique; + _mitmRelayCowNonUnique = nonUnique; + _mitmRelayCowNonUniqueNames = nonUniqueHeaders.Count; + _mitmRelayAppends = default; + _mitmRelayAppendDirty = false; + } + /// /// Initializes a new instance of the class. /// public HeaderCollection() { - headers = new Dictionary(StringComparer.OrdinalIgnoreCase); - nonUniqueHeaders = new Dictionary>(StringComparer.OrdinalIgnoreCase); + // Probe GETs / H2 Lite carry a handful of unique headers; keep the three maps small + // so empty Request/Response shells (before TakeContentsFrom) cost less per stream. + headers = new Dictionary(8, StringComparer.OrdinalIgnoreCase); + nonUniqueHeaders = new Dictionary>(2, StringComparer.OrdinalIgnoreCase); nonUniqueHeadersReadOnly = - new Dictionary>(StringComparer.OrdinalIgnoreCase); - Headers = new ReadOnlyDictionary(headers); - NonUniqueHeaders = new ReadOnlyDictionary>(nonUniqueHeadersReadOnly); + new Dictionary>(2, StringComparer.OrdinalIgnoreCase); } + private ReadOnlyDictionary? headersView; + private ReadOnlyDictionary>? nonUniqueHeadersView; + /// /// Unique Request header collection. /// - public ReadOnlyDictionary Headers { get; } + public ReadOnlyDictionary Headers => + headersView ??= new ReadOnlyDictionary(headers); /// /// Non-unique headers. Values are read-only views over the internal lists so callers cannot /// Add/Clear storage that still belongs to this collection. /// - public ReadOnlyDictionary> NonUniqueHeaders { get; } + public ReadOnlyDictionary> NonUniqueHeaders => + nonUniqueHeadersView ??= new ReadOnlyDictionary>(nonUniqueHeadersReadOnly); /// /// Returns an enumerator that iterates through the collection. @@ -273,6 +416,19 @@ internal void AddHeader(KnownHeader name, KnownHeader value) /// public void AddHeader(HttpHeader newHeader) { + if (_mitmRelayCowArmed && _mitmRelayCowUnique is null && !_mitmRelayAppendDirty + && !headers.ContainsKey(newHeader.Name) + && !nonUniqueHeaders.ContainsKey(newHeader.Name) + && _mitmRelayAppends.Count < Helpers.MitmCompressedRelayHelper.DefaultMaxAppendHeaders) + { + // Pure append of a new unique name: log for compressed relay without cloning wire headers. + _mitmRelayAppends.Add(newHeader); + MutationCount++; + headers.Add(newHeader.Name, newHeader); + return; + } + + EnsureMitmRelayCowSnapshot(); MutationCount++; // if header exist in non-unique header collection add it there if (nonUniqueHeaders.TryGetValue(newHeader.Name, out var list)) @@ -351,6 +507,7 @@ public void AddHeaders(IEnumerable>? newHeaders /// public bool RemoveHeader(string headerName) { + EnsureMitmRelayCowSnapshot(); var result = headers.Remove(headerName); // do not convert to '||' expression to avoid lazy evaluation @@ -374,6 +531,7 @@ public bool RemoveHeader(string headerName) /// public bool RemoveHeader(KnownHeader headerName) { + EnsureMitmRelayCowSnapshot(); var result = headers.Remove(headerName.String); // do not convert to '||' expression to avoid lazy evaluation @@ -393,6 +551,7 @@ public bool RemoveHeader(KnownHeader headerName) /// Returns true if header exists and was removed public bool RemoveHeader(HttpHeader header) { + EnsureMitmRelayCowSnapshot(); if (headers.TryGetValue(header.Name, out var existing)) { if (!existing.Equals(header)) return false; @@ -420,6 +579,7 @@ public bool RemoveHeader(HttpHeader header) /// public void Clear() { + EnsureMitmRelayCowSnapshot(); if (headers.Count > 0 || nonUniqueHeaders.Count > 0) MutationCount++; headers.Clear(); @@ -427,6 +587,24 @@ public void Clear() nonUniqueHeadersReadOnly.Clear(); } + /// + /// Reset a connection-scoped HPACK decode scratch without MutationCount / COW side effects. + /// Safe only when this collection is not a live Request/Response header bag. + /// + internal void ResetForDecodeScratch() + { + headers.Clear(); + nonUniqueHeaders.Clear(); + nonUniqueHeadersReadOnly.Clear(); + MutationCount = 0; + _mitmRelayCowArmed = false; + _mitmRelayCowUnique = null; + _mitmRelayCowNonUnique = null; + _mitmRelayCowNonUniqueNames = 0; + _mitmRelayAppends = default; + _mitmRelayAppendDirty = false; + } + /// /// Rewrites Title-Case HTTP/1.1 field names to lowercase ASCII in place (RFC 9113 / 9114). /// Used before HPACK/QPACK encode so the hot path can skip per-field ToLowerInvariant. @@ -503,8 +681,12 @@ internal void SetOrAddHeaderValue(KnownHeader headerName, string? value) if (headers.TryGetValue(headerName.String, out var header)) { + EnsureMitmRelayCowSnapshot(); MutationCount++; - header.SetValue(value); + if (header.IsSharedStaticTableEntry) + headers[headerName.String] = new HttpHeader(headerName, value); + else + header.SetValue(value); } else { @@ -516,8 +698,12 @@ internal void SetOrAddHeaderValue(KnownHeader headerName, KnownHeader value) { if (headers.TryGetValue(headerName.String, out var header)) { + EnsureMitmRelayCowSnapshot(); MutationCount++; - header.SetValue(value); + if (header.IsSharedStaticTableEntry) + headers[headerName.String] = new HttpHeader(headerName, value); + else + header.SetValue(value); } else { diff --git a/src/Titanium.Web.Proxy/Http/ProxyResults.cs b/src/Titanium.Web.Proxy/Http/ProxyResults.cs index aac28b85b..c4027f9e6 100644 --- a/src/Titanium.Web.Proxy/Http/ProxyResults.cs +++ b/src/Titanium.Web.Proxy/Http/ProxyResults.cs @@ -187,7 +187,7 @@ public static StreamingProxyResult File(string path, string contentType, HttpSta return Stream(status, contentType, async (stream, ct) => { await using var fileStream = new FileStream( - path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 81920, useAsync: true); + path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete, bufferSize: 81920, useAsync: true); await fileStream.CopyToAsync(stream, ct).ConfigureAwait(false); }, fileInfo.Length); } diff --git a/src/Titanium.Web.Proxy/Http/Request.cs b/src/Titanium.Web.Proxy/Http/Request.cs index bd70fa11d..9720f178b 100644 --- a/src/Titanium.Web.Proxy/Http/Request.cs +++ b/src/Titanium.Web.Proxy/Http/Request.cs @@ -157,6 +157,25 @@ public string? Host set => Headers.SetOrAddHeaderValue(KnownHeaders.Host, value); } + /// + /// TLS-terminate reverse () must + /// send Host as the origin bind identity, not the public listen host:port. + /// HttpListener (and similar) match Host to the registered prefix; keeping + /// 127.0.0.1:listenPort yields 404 on macOS/Linux. + /// + internal void ApplyTransparentForwardCleartextHost(ProxyEndPoint? endPoint) + { + if (endPoint is not TransparentBaseProxyEndPoint + { + ForwardCleartext: true, + ForwardHost: { Length: > 0 } forwardHost + } transparent) + return; + + var port = transparent.ForwardPort ?? 80; + Host = port == 80 ? forwardHost : $"{forwardHost}:{port}"; + } + /// /// Does this request has a 100-continue header? /// diff --git a/src/Titanium.Web.Proxy/Http2/Hpack/Decoder.cs b/src/Titanium.Web.Proxy/Http2/Hpack/Decoder.cs index 7fb6cf88f..da74bfd79 100644 --- a/src/Titanium.Web.Proxy/Http2/Hpack/Decoder.cs +++ b/src/Titanium.Web.Proxy/Http2/Hpack/Decoder.cs @@ -468,7 +468,29 @@ private void ReadName(int index) private void IndexHeader(int index, IHeaderListener headerListener) { var headerField = GetHeaderField(index); - AddHeader(headerListener, headerField.NameData, headerField.ValueData, false); + // Static-table rows are immutable shared instances — avoid per-stream HttpHeader alloc. + if (index <= StaticTable.Length) + AddHeader(headerListener, headerField, sensitive: false); + else + AddHeader(headerListener, headerField.NameData, headerField.ValueData, false); + } + + private void AddHeader(IHeaderListener headerListener, HttpHeader header, bool sensitive) // NOSONAR S4136 -- Overloads stay next to the decode step that uses them; moving would scramble the HPACK state machine. + { + if (header.NameData.Length == 0) throw new ArgumentException("name is empty"); + + var newSize = headerSize + header.NameData.Length + header.ValueData.Length; + if (newSize <= maxHeaderSize) + { + headerSize = (int)newSize; + headerListener.AddHeader(header, sensitive); + } + else + { + // truncation always fails for now (RFC 7540 / 7541). + // mark as truncated; EndHeaderBlock will report. + headerSize = maxHeaderSize + 1; + } } private void InsertHeader(IHeaderListener headerListener, ByteString name, ByteString value, diff --git a/src/Titanium.Web.Proxy/Http2/Hpack/IHeaderListener.cs b/src/Titanium.Web.Proxy/Http2/Hpack/IHeaderListener.cs index 7f171ce66..9047f3beb 100644 --- a/src/Titanium.Web.Proxy/Http2/Hpack/IHeaderListener.cs +++ b/src/Titanium.Web.Proxy/Http2/Hpack/IHeaderListener.cs @@ -29,4 +29,11 @@ internal interface IHeaderListener /// Value. /// If set to true sensitive. void AddHeader(ByteString name, ByteString value, bool sensitive); + + /// + /// Prefer when the decoder already holds an (static-table index). + /// Default copies name/value bytes into a new header via . + /// + void AddHeader(HttpHeader header, bool sensitive) => + AddHeader(header.NameData, header.ValueData, sensitive); } \ No newline at end of file diff --git a/src/Titanium.Web.Proxy/Http2/Hpack/StaticTable.cs b/src/Titanium.Web.Proxy/Http2/Hpack/StaticTable.cs index 7999c8e66..fa3438d79 100644 --- a/src/Titanium.Web.Proxy/Http2/Hpack/StaticTable.cs +++ b/src/Titanium.Web.Proxy/Http2/Hpack/StaticTable.cs @@ -121,7 +121,7 @@ private void Create(string name, string value) private void Create(ByteString name, string value) { - StaticTable.Add(new HttpHeader(name, (ByteString)value)); + StaticTable.Add(HttpHeader.CreateSharedStaticTableEntry(name, (ByteString)value)); // Record only the first (lowest) index for repeated names. if (!StaticIndexByName.ContainsKey(name)) diff --git a/src/Titanium.Web.Proxy/Http2/Http2FrameWriter.cs b/src/Titanium.Web.Proxy/Http2/Http2FrameWriter.cs index d77bf7ecc..6c868d448 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2FrameWriter.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2FrameWriter.cs @@ -30,6 +30,8 @@ internal sealed class Http2FrameWriter : IAsyncDisposable private readonly SemaphoreSlim? writeLock; private readonly CancellationTokenSource cts = new(); private readonly Task drainTask; + // SingleReader drain: reuse coalesce scratch (avoids new ArraySegment[64] under multiplex). + private readonly ArraySegment[] coalesceFrames = new ArraySegment[CoalesceMaxFrames]; private int disposed; public Http2FrameWriter(System.IO.Stream output, SemaphoreSlim? writeLock = null) @@ -40,6 +42,8 @@ public Http2FrameWriter(System.IO.Stream output, SemaphoreSlim? writeLock = null { SingleReader = true, SingleWriter = false, + // Keep ASC=false: EnqueueRented runs under origin writeLock; ASC=true MaxOrigin=1 + // SoftPick long A/B ~0.78× H1 / ~0.88× H3 — sync drain under writeLock regresses. AllowSynchronousContinuations = false }); drainTask = Task.Run(() => DrainAsync(cts.Token), cts.Token); @@ -70,10 +74,33 @@ public void EnqueueRented(byte[] rented, int length) { while (await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) { + // MaxOrigin=1 SoftPick: writeLock serializes EnqueueRented then Release; the drain + // often wakes after a single HEADERS. Brief spin lets the next producer enqueue so + // coalesce can batch SecureTransport writes (YARP/SHH contiguous outgoing buffer). + var spinner = new SpinWait(); + while (!spinner.NextSpinWillYield) + { + if (reader.TryPeek(out _)) + break; + spinner.SpinOnce(); + } + while (reader.TryRead(out var first)) { try { + // Second grace after taking first frame — next writeLock holder may enqueue. + if (!reader.TryPeek(out _)) + { + spinner = new SpinWait(); + while (!spinner.NextSpinWillYield) + { + if (reader.TryPeek(out _)) + break; + spinner.SpinOnce(); + } + } + if (!reader.TryPeek(out _)) { await WriteLockedAsync(first.AsMemory(), cancellationToken).ConfigureAwait(false); @@ -82,7 +109,7 @@ public void EnqueueRented(byte[] rented, int length) } var total = first.Count; - var frames = new ArraySegment[CoalesceMaxFrames]; + var frames = coalesceFrames; frames[0] = first; var count = 1; while (count < CoalesceMaxFrames @@ -172,14 +199,14 @@ public async ValueTask DisposeAsync() channel.Writer.TryComplete(); try { - await drainTask.WaitAsync(TimeSpan.FromSeconds(2), CancellationToken.None).ConfigureAwait(false); + await drainTask.WaitAsync(TimeSpan.FromSeconds(2), CancellationToken.None).ConfigureAwait(false); // NOSONAR S8949 -- drain queued frames; cts.Token would abort if already cancelled } catch (TimeoutException) { try { await cts.CancelAsync(); } catch { /* ignore */ } - try { await drainTask.WaitAsync(TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); } + try { await drainTask.WaitAsync(TimeSpan.FromSeconds(1), CancellationToken.None).ConfigureAwait(false); } // NOSONAR S8949 -- drain after Cancel; cts is already cancelled catch { /* drain may fault if socket already closed */ } } catch diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs new file mode 100644 index 000000000..bb14b4fc4 --- /dev/null +++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Headers.cs @@ -0,0 +1,1434 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Titanium.Web.Proxy.Compression; +using Titanium.Web.Proxy.Diagnostics; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Exceptions; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Http2.Hpack; +using Titanium.Web.Proxy.Logging; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network.Streams; +using Titanium.Web.Proxy.Network.Tcp; +using Titanium.Web.Proxy.Options; +using Decoder = Titanium.Web.Proxy.Http2.Hpack.Decoder; +using Encoder = Titanium.Web.Proxy.Http2.Hpack.Encoder; + +namespace Titanium.Web.Proxy.Http2 +{ + internal partial class Http2Helper + { + // Decodes one fully-assembled HEADERS(+CONTINUATION...) block (already stripped of padding/ + // priority bytes) and dispatches it. A HEADERS block on an already-established request/response + // (one that already carries pseudo-headers) is the *main* message; a further block without + // request/status pseudo-headers is trailers (RFC 7230 ?4.1.2 / RFC 7540 ?8.1.2.1); a response + // block whose :status is 1xx is an interim informational response (RFC 9110 ?15.2) and is + // relayed without invoking BeforeRequest/BeforeResponse and without ever touching/locking the + // final Request/Response. Returns true if this block was an interim (1xx) response, so the + // caller does not treat a (spec-invalid, but let's be defensive) END_STREAM flag on it as ending + // the stream. + private static async Task ProcessCompleteHeaderBlockAsync( // NOSONAR S107, S3776, S1172 -- Hoisted header-block dispatch; params stay explicit (no per-frame context class). localSettings retained for call-site IL match with CopyHttp2FrameAsync. + Http2ConnectionState connectionState, + Stream input, + Stream output, + SemaphoreSlim outputWriteLock, + SemaphoreSlim ownLegWriteLock, + Http2OriginRelayPool.OriginLeg? originReceiveLeg, + ByteString compressedRelaySchemeOverride, + bool isClient, + CancellationToken cancellationToken, + CopyDirectionHpack hpack, + Http2Settings remoteSettings, + int maxDecodedHeaderListBytes, + ILogger logger, + Action removeAndFinalizeStream, + Func, ValueTask> lockedOutputWrite, + bool forceStaticHpackTable, + Http2Settings localSettings, // NOSONAR S1172 -- retained for CopyHttp2FrameAsync call-site IL match. + HeaderCollection headerDecodeScratch, + MyHeaderListener headerDecodeListener, + ConcurrentDictionary syntheticStreams, + Http2PendingWork pendingSynthetics, + Http2FrameHeader frameHeader, + byte[] frameHeaderBuffer, + Func onBeforeRequestResponse, + Func onAfterResponse, + Action? prepareRequestHeaders, + bool enableRfc8441, + TcpServerConnection? originConnection, + bool httpInterceptionEnabled, + Func? shouldInterceptHttp, + int hbStreamId, SessionEventArgs sessionArgs, + RequestResponseBase headerRr, byte[] compressed, bool endStreamFlag, bool isPromise) + { + headerDecodeScratch.ResetForDecodeScratch(); + var collected = headerDecodeScratch; + headerDecodeListener.ResetForDecode(collected); + var headerListener = headerDecodeListener; + + try + { + // The header block being decoded here was encoded by the peer this task reads from + // (`localSettings`'s peer), but that peer's encoder is constrained by whatever *we* + // told it its dynamic-table budget is - which, since SETTINGS frames are relayed + // transparently between the two legs (see the Settings frame handling below), is the + // value recorded in `remoteSettings` (the settings of the *other* peer, forwarded + // verbatim to this one). Sizing the hpack.Decoder from `localSettings` instead is wrong: it + // uses the peer's own self-reported receive budget (irrelevant to what its encoder is + // actually bounded by) and, once a real peer advertises a non-default value, causes + // "invalid max dynamic table size" decode failures that permanently desync this + // connection's HPACK state. + // The dynamic table is connection-scoped (RFC 7541 §2.3.2), so the hpack.Decoder itself must be + // created exactly once per direction and kept for the connection's lifetime - never + // recreated. A previous version of this code recreated the Decoder outright whenever + // `remoteSettings.HeaderTableSize` grew, which silently discarded every entry the peer's + // encoder had already inserted (and which that encoder still believes is indexable). + // The very next indexed reference into one of those now-missing entries then either threw + // (decoded as garbage/out-of-range) or resolved to the wrong slot, permanently desyncing + // this connection's HPACK state - observable as intermittent net::ERR_HTTP2_COMPRESSION_ERROR + // failures in the browser once a real peer advertised a table-size change mid-connection. + // Resizing the *existing* hpack.Decoder's dynamic table (which evicts oldest entries only if the + // new size is smaller, per RFC 7541 §4.3) is the correct, entry-preserving way to react to + // a table-size change instead. + if (hpack.Decoder == null) + { + hpack.HeaderTableSize = remoteSettings.HeaderTableSize; + hpack.Decoder = new Decoder(maxDecodedHeaderListBytes, hpack.HeaderTableSize); + } + else if (hpack.HeaderTableSize != remoteSettings.HeaderTableSize) + { + hpack.HeaderTableSize = remoteSettings.HeaderTableSize; + hpack.Decoder.SetMaxHeaderTableSize(hpack.HeaderTableSize); + } + + hpack.Decoder.Decode(compressed.AsSpan(0, compressed.Length), headerListener); + var truncated = hpack.Decoder.EndHeaderBlock(); + if (truncated) + { + // The decoded header list exceeded the local policy limit. The HPACK hpack.Decoder + // state is still valid (EndHeaderBlock reset it), so future blocks on this + // connection remain safe. Reject only this stream with ENHANCE_YOUR_CALM (0xb) + // rather than a connection-level COMPRESSION_ERROR. + throw new Http2HeaderListTooLargeException( + "Decoded header list exceeded the configured limit; stream rejected."); + } + } + catch (Http2HeaderListTooLargeException ex) + { + // Policy rejection (not a structural HPACK error) - hpack.Decoder state is intact. + ReportException(logger, new ProxyHttpException( + "HTTP/2 header list too large: " + ex.Message, ex, sessionArgs)); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, + (Http2ErrorCode)0xb /* ENHANCE_YOUR_CALM */, input)); + return false; + } + catch (Exception ex) + { + // RFC 7541 §7: "A decoding error in a header block MUST be treated as a connection + // error of type COMPRESSION_ERROR." The dynamic table is connection-scoped, so once a + // block fails to decode this hpack.Decoder's state can no longer be trusted to stay in sync + // with the peer's encoder for any later stream either - swallowing this and continuing + // (as before) meant every subsequent header block on the connection failed too, each + // one silently dropped with no reply, hanging every affected stream. Tear the whole + // connection down instead so both sides observe a clean failure and can retry on a new + // connection. + ReportException(logger, new ProxyHttpException("Failed to decode HTTP/2 headers", ex, sessionArgs)); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], hbStreamId, + Http2ErrorCode.CompressionError, input)); + throw; + } + + if (headerListener.HasMalformedHeader) + { + // RFC 7540 ?8.1.2/?8.1.2.1: unknown pseudo-header fields, uppercase field names, and + // (checked just below) connection-specific header fields are malformed - a stream-level + // PROTOCOL_ERROR that must not tear down the rest of the connection, whose HPACK hpack.Decoder + // state has already been kept in sync by the decode above. + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: " + headerListener.MalformedReason, null, sessionArgs)); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, + Http2ErrorCode.ProtocolError, input)); + return false; + } + + var forbiddenConnectionHeader = collected.FirstOrDefault(header => + ForbiddenConnectionSpecificHeaders.Contains(header.Name)); + if (forbiddenConnectionHeader != null) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: connection-specific header field '" + forbiddenConnectionHeader.Name + + "' is forbidden.", null, sessionArgs)); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, + Http2ErrorCode.ProtocolError, input)); + return false; + } + + // RFC 9113 §8.5: once an extended CONNECT tunnel is established, no HEADERS or CONTINUATION + // frame is permitted on that stream. The HPACK decode above already ran to keep the + // connection-level dynamic table in sync; now reject the stream itself. + if (connectionState.Streams.TryGetValue(hbStreamId, out var estConnectState) + && estConnectState.ExtendedConnectEstablished) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: HEADERS received on an established extended CONNECT tunnel.", + null, sessionArgs)); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, + Http2ErrorCode.ProtocolError, input)); + return false; + } + + if (isClient) + { + var method = headerListener.Method; + var path = headerListener.Path; + // RFC 7540 §8.1.2.3: CONNECT requests have :method + :authority but no :path or :scheme. + // All other requests require :method, :path, and :scheme. + // RFC 8441 §5: extended CONNECT has :method=CONNECT + :protocol + :scheme + :path + :authority. + bool isConnect = method.Length > 0 && + method.Span.SequenceEqual(ConnectMethodBytes); + bool isExtendedConnect = isConnect && headerListener.Protocol.Length > 0; + bool isMainHeaders = (method.Length > 0 && path.Length > 0) || + (isConnect && headerListener.Authority.Length > 0); + + // RFC 8441 §5: :protocol is only valid on CONNECT requests. + if (!isConnect && headerListener.Protocol.Length > 0) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: :protocol pseudo-header is only allowed on CONNECT requests.", + null, sessionArgs)); + removeAndFinalizeStream(hbStreamId); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + hbStreamId, Http2ErrorCode.ProtocolError, input)); + return false; + } + + if (isMainHeaders) + { + // Validate required pseudo-fields for initial request HEADERS. + if (isExtendedConnect) + { + // RFC 8441 §5: extended CONNECT requires :scheme and :path (unlike plain CONNECT). + if (!enableRfc8441) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 extended CONNECT (RFC 8441) is not enabled on this proxy.", + null, sessionArgs)); + removeAndFinalizeStream(hbStreamId); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + hbStreamId, Http2ErrorCode.RefusedStream, input)); + return false; + } + + if (headerListener.Scheme == string.Empty || + headerListener.Path.Length == 0 || + headerListener.Authority.Length == 0) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: extended CONNECT HEADERS missing required " + + ":scheme, :path, or :authority pseudo-header.", + null, sessionArgs)); + removeAndFinalizeStream(hbStreamId); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + hbStreamId, Http2ErrorCode.ProtocolError, input)); + return false; + } + + // Mark the stream as extended CONNECT so the relay can handle DATA frames appropriately. + string? ecProtocol = Encoding.ASCII.GetString(headerListener.Protocol.Span); + if (connectionState.Streams.TryGetValue(hbStreamId, out var extStreamState)) + { + extStreamState.IsExtendedConnect = true; + extStreamState.ExtendedConnectProtocol = ecProtocol; + } + // Expose on the request so BeforeRequest handlers can identify the upgrade. + ((Request)headerRr).ExtendedConnectProtocol = ecProtocol; + } + else if (!isConnect && headerListener.Scheme == string.Empty) + { + // RFC 7540 §8.1.2.3: non-CONNECT requests must include :scheme. + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: request HEADERS missing required :scheme pseudo-header.", + null, sessionArgs)); + removeAndFinalizeStream(hbStreamId); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + hbStreamId, Http2ErrorCode.ProtocolError, input)); + return false; + } + + // RFC 7540 ?5.1.1: client-initiated stream ids must be odd and strictly increasing + // on a given connection. An even id (reserved for server-initiated streams, which + // this proxy never admits - see the PUSH_PROMISE rejection in the main frame loop) + // or an id that does not exceed one already seen (reuse, or the client's own + // ids arriving out of order) is a connection-level PROTOCOL_ERROR: continuing would + // risk colliding with flow-control/session state for a stream id already in use or + // already torn down. + if (hbStreamId % 2 == 0 || hbStreamId <= connectionState.LastClientStreamId) + { + ReportException(logger, new ProxyHttpException( + $"HTTP/2 protocol error: invalid client-initiated stream id {hbStreamId}.", null, + sessionArgs)); + removeAndFinalizeStream(hbStreamId); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], + connectionState.LastClientStreamId, Http2ErrorCode.ProtocolError, input)); + return false; + } + + connectionState.LastClientStreamId = hbStreamId; + } + + if (isMainHeaders && connectionState.ServerGoingAway && + hbStreamId > connectionState.ServerLastStreamId) + { + // the server has already told us (via GOAWAY) it will not process any new stream + // above its last-accepted id - refuse this one locally instead of forwarding a + // request we already know will never be answered. + removeAndFinalizeStream(hbStreamId); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, + Http2ErrorCode.RefusedStream, input)); + return false; + } + + if (isMainHeaders && connectionState.ClientResetBudgetExceeded && + hbStreamId > connectionState.ClientResetBudgetLastStreamId) + { + // The proxy already announced (via its own GOAWAY, sent when the Rapid Reset + // budget was exceeded) that it will not process any client-initiated stream above + // this id - refuse locally rather than doing further setup work for it. + removeAndFinalizeStream(hbStreamId); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, + Http2ErrorCode.RefusedStream, input)); + return false; + } + + if (isMainHeaders && connectionState.Streams.Count > remoteSettings.MaxConcurrentStreams) + { + // Streams.Count already includes this stream (registered by the caller before + // decoding, so HPACK state stays in sync regardless of admission) - so ">" (not + // ">=") here correctly means "admitting this one would exceed the limit the server + // (this stream's ultimate destination) advertised it will tolerate concurrently" + // (RFC 7540 ?6.5.2 SETTINGS_MAX_CONCURRENT_STREAMS). + ReportException(logger, new ProxyHttpException( + "HTTP/2 stream refused: maximum concurrent streams exceeded.", null, sessionArgs)); + removeAndFinalizeStream(hbStreamId); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, + Http2ErrorCode.RefusedStream, input)); + return false; + } + + if (!isMainHeaders) + { + // request trailers - never valid before any main request headers were seen. + if (headerRr.HttpVersion < HttpHeader.Version20) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: trailer HEADERS received before request headers.", null, + sessionArgs)); + return false; + } + + // RFC 7540 §8.1.2.1: trailer HEADERS MUST NOT contain pseudo-header fields. + if (headerListener.Method.Length > 0 || headerListener.Path.Length > 0 || + headerListener.Status.Length > 0 || headerListener.Authority.Length > 0 || + headerListener.Scheme != string.Empty || headerListener.Protocol.Length > 0) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: request trailer HEADERS contains pseudo-header fields.", + null, sessionArgs)); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + hbStreamId, Http2ErrorCode.ProtocolError, input)); + return false; + } + + // RFC 9110 §6.5.1: certain fields are forbidden in trailers. + var forbiddenTrailerHeader = collected.FirstOrDefault(header => + ForbiddenTrailerHeaders.Contains(header.Name)); + if (forbiddenTrailerHeader != null) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: request trailer HEADERS contains forbidden field '" + + forbiddenTrailerHeader.Name + "'.", null, sessionArgs)); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + hbStreamId, Http2ErrorCode.ProtocolError, input)); + return false; + } + + foreach (var header in collected) + { + headerRr.TrailingHeaders.AddHeader(header); + } + + // a request answered synthetically never reached the server - nothing to forward, + // but the block above still had to be decoded to keep this connection's HPACK + // hpack.Decoder state in sync with the peer's encoder. + await hpack.RequestDispatchChain; + + if (!syntheticStreams.ContainsKey(hbStreamId)) + { + // Drain queued HEADERS/DATA so trailers cannot overtake them on the wire. + if (isClient) + await connectionState.ServerWriteChain; + await lockedOutputWrite(() => AsValueTask(SendTrailer(remoteSettings, frameHeader, frameHeaderBuffer, + hbStreamId, headerRr.TrailingHeaders, endStreamFlag, output))); + } + + return false; + } + + var request = (Request)headerRr; + request.HttpVersion = HttpVersion.Version20; + // Intern common methods — probe / browser GETs avoid per-stream GetString alloc. + request.Method = InternCommonHttpMethod(method.Span, method); + request.IsHttps = headerListener.Scheme == ProxyServer.UriSchemeHttps; + request.Authority = headerListener.Authority; + request.RequestUriString8 = path; + request.Headers.TakeContentsFrom(collected); + + // Capture compressed block for intercept unchanged → relay (static-HPACK MITM only). + if (httpInterceptionEnabled && forceStaticHpackTable + && connectionState.Streams.TryGetValue(hbStreamId, out var captureState)) + { + captureState.CapturedCompressedHeaders = compressed; + request.Headers.ArmMitmRelayBaseline(); + captureState.CapturedMethod = request.Method; + captureState.CapturedPath = request.RequestUriString8; + captureState.CapturedAuthority = request.Authority; + } + + // Per-stream predicate: gate is on but this stream may still be passthrough. + if (httpInterceptionEnabled && shouldInterceptHttp != null && isMainHeaders) // NOSONAR S2589 -- Predicate is optional; interception-on still allows a null passthrough callback. + { + var authority = headerListener.Authority.GetString(); + var host = authority; + var port = request.IsHttps ? 443 : 80; + var colon = authority.LastIndexOf(':'); + if (colon > 0 && int.TryParse(authority.AsSpan(colon + 1), out var parsedPort)) + { + host = authority[..colon]; + port = parsedPort; + } + + var interceptionCtx = new HttpInterceptionContext + { + Hostname = host, + Port = port, + IsHttps = request.IsHttps, + Method = request.Method ?? string.Empty, + PathAndQuery = path.GetString(), + HttpVersion = HttpVersion.Version20, + ProxyEndPoint = sessionArgs.ProxyEndPoint, + ClientRemoteEndPoint = sessionArgs.ClientRemoteEndPoint, + ClientProcessId = null + }; + sessionArgs.IsFastPath = !shouldInterceptHttp(interceptionCtx); + } + + // END_STREAM on HEADERS ⇒ no request body; skip TCS used by GetRequestBody waiters. + TaskCompletionSource? tcs = endStreamFlag ? null : new TaskCompletionSource(); + request.ReadHttp2BeforeHandlerTaskCompletionSource = tcs; + + var streamContext = new Http2StreamContext(hbStreamId, connectionState, + isClient ? input : output, cancellationToken); + + // HPACK decode and Request population above must stay ordered on this frame loop. + // Everything from the user handler on is per-stream work, though, and running it + // here serializes unrelated streams on the same connection. Dispatch it independently; + // DATA/body completion awaits this task before SendBody, preserving HEADERS-before-DATA + // ordering for the stream without delaying subsequent HEADERS decode. + var dispatchFrameHeader = new Http2FrameHeader { StreamId = hbStreamId }; + byte[]? dispatchFrameHeaderBuffer = null; + var previousDispatch = hpack.RequestDispatchChain; + // Static-HPACK MITM unchanged-lite: handlers are usually sync CompletedTask and the + // forward path is compressed relay (same shape as gate-off). Task.Run per stream was + // a large Lite tax vs reverse; start the async state machine without a pool hop. + // Bridges / dynamic HPACK keep Task.Run so encode+checkout does not serialize the + // frame loop (~22k streams/s cap measured on h2-to-h1 when run inline). + // Prefer a non-async Lite finish (Task.CompletedTask) when handler + previousDispatch + // + RelayCompressedHeaderBlockAsync all complete inline — avoids per-stream async SM. + Task StartMitmStaticRequestDispatch() + { + var handler = onBeforeRequestResponse(sessionArgs, streamContext); + if (tcs == null + && handler.IsCompletedSuccessfully + && previousDispatch.IsCompletedSuccessfully + && !sessionArgs.HttpClient.Request.CancelRequest) + { + connectionState.Streams.TryGetValue(hbStreamId, out var relayState); + bool isExtendedConnectTunnel = relayState?.IsExtendedConnect == true + && relayState.InboundTunnelChannel != null; + bool isNativeExtendedConnect = relayState?.IsExtendedConnect == true + && relayState.InboundTunnelChannel == null; + bool isExternalBridge = relayState?.IsExternalBridge == true + || output is NullOriginStream; + + if (!isExtendedConnectTunnel && !isExternalBridge && !isNativeExtendedConnect) + { + var injectVia = !sessionArgs.IsFastPath && !sessionArgs.IsTransparent + && !sessionArgs.IsSocks + && !string.IsNullOrEmpty(sessionArgs.Server.ViaHeaderPseudonym); + if (!injectVia + && forceStaticHpackTable + && relayState?.CapturedCompressedHeaders != null + && !request.IsBodyRead + && !request.BodyAvailable + && string.Equals(request.Method, relayState.CapturedMethod, StringComparison.Ordinal) + && request.RequestUriString8.Equals(relayState.CapturedPath) + && request.Authority.Equals(relayState.CapturedAuthority)) + { + relayState.HeadersRelayBaseline = request.Headers.TakeMitmRelayBaseline(); + byte[]? blockToRelay = null; + byte[]? appendSuffix = null; + if (MitmCompressedRelayHelper.AllowsCompressedRelay( + relayState.HeadersRelayBaseline.MutationCount, + request.Headers, + MitmCompressedRelayHelper.DefaultMaxAppendHeaders, + out _)) + { + blockToRelay = relayState.CapturedCompressedHeaders; + } + else if (TryPrepareMitmStaticHpackRelay( + relayState.CapturedCompressedHeaders, + relayState.HeadersRelayBaseline, request.Headers, + injectVia: false, + viaValue: null, + out blockToRelay, out appendSuffix)) + { + // Full append-only / drop-rebuild static finish + } + + if (blockToRelay != null) + { + var relayTask = RelayCompressedHeaderBlockAsync( + connectionState, input, output, outputWriteLock, ownLegWriteLock, originReceiveLeg, + compressedRelaySchemeOverride, isClient, cancellationToken, hpack, remoteSettings, + maxDecodedHeaderListBytes, logger, removeAndFinalizeStream, + hbStreamId, + blockToRelay, endStreamFlag, appendSuffix); + if (relayTask.IsCompletedSuccessfully) + { + request.ReadHttp2BeforeHandlerTaskCompletionSource = null; + relayState.EnableRequestDataCompressedRelay(); + request.Locked = true; + return Task.CompletedTask; + } + + return CompleteMitmLiteRelayAsync(relayTask, relayState); + } + } + } + } + + return DispatchRequestAfterHeadersAsync(handler); + + async Task CompleteMitmLiteRelayAsync(Task relayTask, Http2StreamState relayState) + { + await relayTask; + request.ReadHttp2BeforeHandlerTaskCompletionSource = null; + relayState.EnableRequestDataCompressedRelay(); + request.Locked = true; + } + } + + async Task DispatchRequestAfterHeadersAsync(Task? prestartedHandler = null) + { + // DATA routing stays correct: client DATA frames await this dispatch task before + // being routed, so channels the handler registers are always visible in time. + var handler = prestartedHandler ?? onBeforeRequestResponse(sessionArgs, streamContext); + bool handlerCompleted; + if (tcs == null) + { + await handler; + handlerCompleted = true; + } + else + { + handlerCompleted = handler == await Task.WhenAny(tcs.Task, handler); + } + + // The origin must observe newly opened client streams in increasing stream-id order. + // Handlers run concurrently, but admit each completed decision after the prior stream's + // decision has queued (or suppressed) its HEADERS. + await previousDispatch; + + if (handlerCompleted) + { + request.ReadHttp2BeforeHandlerTaskCompletionSource = null; + tcs?.SetResult(true); + + // Apply the same outgoing-request normalization and Via policy as HTTP/1.x. + // External bridges (H2→H1 via NullOriginStream, H2→H3 via IsExternalBridge) + // apply Via themselves before launching their independent origin round trip. + // Re-applying here would see their Via entry and falsely return 508 Loop Detected, + // and would race a second synthetic response against the bridge task. + connectionState.Streams.TryGetValue(hbStreamId, out var viaOwnerState); + bool bridgeOwnsRequestPrep = output is NullOriginStream + || viaOwnerState?.IsExternalBridge == true; + + // Did the consumer answer this request synthetically during BeforeRequest (Ok, + // GenericResponse, Redirect, buffered Respond, or RespondStreaming - all funnel + // through Respond(), which is the single source of truth for "short-circuit this + // request" and is what HTTP/1.x's RequestHandler already keys off of)? + // PrepareRequestHeaders / Via run only on the re-encode forward path below — + // applying them before the unchanged-relay check would rewrite Accept-Encoding + // (MutationCount) and either block relay or diverge from the compressed block. + if (sessionArgs.HttpClient.Request.CancelRequest) + { + // do not forward the request upstream; answer the client directly. Run this in + // the background (rather than awaiting inline) so a slow synthetic body does not + // block reading/relaying frames for every other multiplexed stream on this + // connection; failures are reported centrally instead of tearing down the whole + // relay. + syntheticStreams.TryAdd(hbStreamId, 0); + connectionState.Streams.TryGetValue(hbStreamId, out var streamState); + var linkedCts694 = streamState != null + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, + streamState.Cancellation.Token) + : null; + var streamToken = linkedCts694?.Token ?? cancellationToken; + // we are inside the `if (isClient)` branch, so `input` is always the client + // stream here (see the isClient=true call in SendHttp2). + var synthTask = EmitSyntheticResponseAsync(sessionArgs, hbStreamId, connectionState, + input, streamToken, onAfterResponse, logger) + .ContinueWith(t => + { + linkedCts694?.Dispose(); + if (t.IsFaulted) + { + ReportException(logger, new ProxyHttpException( + SyntheticResponseFailedMessage, t.Exception.GetBaseException(), + sessionArgs)); + } + }, TaskScheduler.Default); + if (streamState != null) streamState.SyntheticTask = synthTask; + pendingSynthetics.Track(synthTask); + } + else + { + // RFC 8441: extended CONNECT tunnel streams are handled entirely by the + // bridge's tunnel task (which manages its own response). Do not forward + // the CONNECT HEADERS to the (null) origin - the tunnel task sends the + // actual WebSocket upgrade request and response independently. + connectionState.Streams.TryGetValue(hbStreamId, out var ecTunnelState); + bool isExtendedConnectTunnel = ecTunnelState?.IsExtendedConnect == true + && ecTunnelState.InboundTunnelChannel != null; + bool isNativeExtendedConnect = ecTunnelState?.IsExtendedConnect == true + && ecTunnelState.InboundTunnelChannel == null; + bool isExternalBridge = ecTunnelState?.IsExternalBridge == true + || output is NullOriginStream; + + if (isExtendedConnectTunnel) + { + // h2→h1 bridge: the tunnel task owns the origin connection; skip. + } + else if (isExternalBridge) + { + // An external bridge (e.g. H2→H3) registered its background task in + // SyntheticTask and owns this stream's origin round trip entirely. + // Suppress forwarding the request HEADERS to the native H2 origin; + // the bridge task emits the response via EmitSyntheticResponseAsync. + syntheticStreams.TryAdd(hbStreamId, 0); + } + else if (isNativeExtendedConnect && output is not NullOriginStream) + { + // Wait for the origin's initial SETTINGS to be processed before checking + // SETTINGS_ENABLE_CONNECT_PROTOCOL. The client may send its extended CONNECT + // request before the server→client relay has had a chance to relay the origin's + // SETTINGS frame; without this await the check below would always see false. + await connectionState.ServerSettingsRelayed.Task.WaitAsync(cancellationToken); + + // Native h2↔h2 extended CONNECT path. + string? ecProto = ecTunnelState?.ExtendedConnectProtocol; + if (!string.Equals(ecProto, "websocket", StringComparison.OrdinalIgnoreCase)) + { + // Only the 'websocket' protocol token is implemented. BeforeRequest ran + // but did not synthesize a response - return 501 so the client can retry. + sessionArgs.GenericResponse( + $"RFC 8441 extended CONNECT (protocol: {ecProto ?? "unknown"}) " + + "is not supported by this proxy. Only 'websocket' is implemented.", + HttpStatusCode.NotImplemented); + syntheticStreams.TryAdd(hbStreamId, 0); + connectionState.Streams.TryGetValue(hbStreamId, out var unknProtoState); + var linkedCts751 = unknProtoState != null + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, + unknProtoState.Cancellation.Token) + : null; + var unknProtoToken = linkedCts751?.Token ?? cancellationToken; + var synthTask501 = EmitSyntheticResponseAsync(sessionArgs, hbStreamId, + connectionState, input, unknProtoToken, onAfterResponse, logger) + .ContinueWith(t => + { + linkedCts751?.Dispose(); + if (t.IsFaulted) + ReportException(logger, new ProxyHttpException( + SyntheticResponseFailedMessage, + t.Exception.GetBaseException(), sessionArgs)); + }, TaskScheduler.Default); + if (unknProtoState != null) unknProtoState.SyntheticTask = synthTask501; + pendingSynthetics.Track(synthTask501); + } + else if (!connectionState.ServerSettings.EnableConnectProtocol) + { + // Origin did not advertise SETTINGS_ENABLE_CONNECT_PROTOCOL=1. + // Refuse deterministically so the client can retry or fall back; + // do NOT leak the extended-CONNECT HEADERS to an unsupporting origin. + ReportException(logger, new ProxyHttpException( + "HTTP/2 extended CONNECT refused: origin did not advertise " + + "SETTINGS_ENABLE_CONNECT_PROTOCOL=1.", + null, sessionArgs)); + removeAndFinalizeStream(hbStreamId); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + hbStreamId, Http2ErrorCode.RefusedStream, input)); + } + else + { + // Origin supports RFC 8441 - forward the extended CONNECT HEADERS. + if (originConnection != null) + BindOriginForHttp2Stream(sessionArgs, originConnection); + ApplyCleartextOriginScheme(request, originConnection, + sessionArgs.ClientConnection); + if (!bridgeOwnsRequestPrep) + prepareRequestHeaders?.Invoke(request.Headers); + // Encode HPACK under the ordered dispatch chain and queue copied wire + // bytes without awaiting origin socket I/O. + QueueSendHeaderTowardServer(connectionState, outputWriteLock, + remoteSettings, dispatchFrameHeader, + dispatchFrameHeaderBuffer ??= new byte[9], request, + endStreamFlag, output, isPromise); + } + } + else + { + // True MITM noop-safe: relay the original compressed HEADERS when handlers + // did not mutate method/path/authority/headers or buffer/replace the body + // (GetRequestBody sets IsBodyRead and would leave origin without DATA). + // Skip relay when Via would be injected (explicit MITM) — append as HPACK + // literal on the static block instead of full re-encode (matches H3). + // Bind origin / scheme patch only after we know we are not on the + // compressed-relay finish (avoids work on the Lite hot path). + var injectVia = !sessionArgs.IsFastPath && !sessionArgs.IsTransparent + && !sessionArgs.IsSocks + && !string.IsNullOrEmpty(sessionArgs.Server.ViaHeaderPseudonym); + var requestRelayed = false; + if (forceStaticHpackTable + && connectionState.Streams.TryGetValue(hbStreamId, out var relayState) + && relayState.CapturedCompressedHeaders != null + && !request.IsBodyRead + && !request.BodyAvailable + && string.Equals(request.Method, relayState.CapturedMethod, StringComparison.Ordinal) + && request.RequestUriString8.Equals(relayState.CapturedPath) + && request.Authority.Equals(relayState.CapturedAuthority)) + { + relayState.HeadersRelayBaseline = request.Headers.TakeMitmRelayBaseline(); + // Lite / unchanged: MutationCount match → verbatim relay (skip header diff walk). + if (!injectVia + && MitmCompressedRelayHelper.AllowsCompressedRelay( + relayState.HeadersRelayBaseline.MutationCount, + request.Headers, + MitmCompressedRelayHelper.DefaultMaxAppendHeaders, + out _)) + { + await RelayCompressedHeaderBlockAsync( + connectionState, input, output, outputWriteLock, ownLegWriteLock, originReceiveLeg, + compressedRelaySchemeOverride, isClient, cancellationToken, hpack, remoteSettings, + maxDecodedHeaderListBytes, logger, removeAndFinalizeStream, + hbStreamId, + relayState.CapturedCompressedHeaders, endStreamFlag); + relayState.EnableRequestDataCompressedRelay(); + requestRelayed = true; + } + else if (TryPrepareMitmStaticHpackRelay( + relayState.CapturedCompressedHeaders, + relayState.HeadersRelayBaseline, request.Headers, + injectVia, + injectVia + ? $"{request.HttpVersion.Major}.{request.HttpVersion.Minor} {sessionArgs.Server.ViaHeaderPseudonym}" + : null, + out var reqBlockToRelay, out var reqAppendSuffix)) + { + await RelayCompressedHeaderBlockAsync( + connectionState, input, output, outputWriteLock, ownLegWriteLock, originReceiveLeg, + compressedRelaySchemeOverride, isClient, cancellationToken, hpack, remoteSettings, + maxDecodedHeaderListBytes, logger, removeAndFinalizeStream, + hbStreamId, reqBlockToRelay, endStreamFlag, + reqAppendSuffix); + relayState.EnableRequestDataCompressedRelay(); + requestRelayed = true; + } + } + + if (!requestRelayed) + { + if (originConnection != null) + BindOriginForHttp2Stream(sessionArgs, originConnection); + ApplyCleartextOriginScheme(request, originConnection, + sessionArgs.ClientConnection); + if (!bridgeOwnsRequestPrep) + { + // The h2-to-h1 / h2-to-h3 bridges own request preparation before they + // start their background origin operation; doing it here afterward + // races with that operation and can mutate headers while they are sent. + prepareRequestHeaders?.Invoke(request.Headers); + if (injectVia) + { + var pseudonym = sessionArgs.Server.ViaHeaderPseudonym; + if (ProxyServer.HasLoopedVia(request.Headers, pseudonym)) + { + sessionArgs.GenericResponse(string.Empty, (HttpStatusCode)508); + } + else + { + ProxyServer.AddViaHeader(request.Headers, request.HttpVersion, + pseudonym); + } + } + } + + if (sessionArgs.HttpClient.Request.CancelRequest) + { + syntheticStreams.TryAdd(hbStreamId, 0); + connectionState.Streams.TryGetValue(hbStreamId, out var loopStreamState); + var linkedCts508 = loopStreamState != null + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, + loopStreamState.Cancellation.Token) + : null; + var loopToken = linkedCts508?.Token ?? cancellationToken; + var synthTask508 = EmitSyntheticResponseAsync(sessionArgs, hbStreamId, + connectionState, input, loopToken, onAfterResponse, logger) + .ContinueWith(t => + { + linkedCts508?.Dispose(); + if (t.IsFaulted) + { + ReportException(logger, new ProxyHttpException( + SyntheticResponseFailedMessage, + t.Exception.GetBaseException(), sessionArgs)); + } + }, TaskScheduler.Default); + if (loopStreamState != null) loopStreamState.SyntheticTask = synthTask508; + pendingSynthetics.Track(synthTask508); + } + else + { + if (connectionState.Streams.TryGetValue(hbStreamId, out var clearCapture)) + clearCapture.CapturedCompressedHeaders = null; + QueueSendHeaderTowardServer(connectionState, outputWriteLock, + remoteSettings, dispatchFrameHeader, + dispatchFrameHeaderBuffer ??= new byte[9], request, + endStreamFlag, output, isPromise); + } + } + } + } + } + else + { + request.Http2IgnoreBodyFrames = true; + } + + request.Locked = true; + } + + // Static-HPACK MITM: sync Lite finish when possible (see StartMitmStaticRequestDispatch). + // Dynamic HPACK / bridges: keep Task.Run so sync encode does not serialize the frame loop. + Task dispatchTask = forceStaticHpackTable && httpInterceptionEnabled + ? StartMitmStaticRequestDispatch() + : Task.Run(() => DispatchRequestAfterHeadersAsync(), cancellationToken); + hpack.RequestDispatchChain = dispatchTask; + request.Http2BeforeHandlerTask = dispatchTask; + pendingSynthetics.Track(dispatchTask); + return false; + } + else + { + bool hasStatus = headerListener.Status.Length > 0; + int statusCode = 0; + if (hasStatus) + { + // RFC 7540 §8.1.2.4 / RFC 9110: :status MUST be exactly three ASCII decimal + // digits in the range 100–999. Any other encoding is a stream-level protocol error. + var statusSpan = headerListener.Status.Span; + if (statusSpan.Length != 3 || + !IsAsciiDigit(statusSpan[0]) || + !IsAsciiDigit(statusSpan[1]) || + !IsAsciiDigit(statusSpan[2])) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: :status pseudo-header is not exactly three ASCII digits.", + null, sessionArgs)); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + hbStreamId, Http2ErrorCode.ProtocolError, input)); + return false; + } + + statusCode = (statusSpan[0] - '0') * 100 + + (statusSpan[1] - '0') * 10 + + (statusSpan[2] - '0'); + + if (statusCode < 100 || statusCode > 999) + { + ReportException(logger, new ProxyHttpException( + $"HTTP/2 protocol error: :status value {statusCode} is outside the valid range (100-999).", + null, sessionArgs)); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + hbStreamId, Http2ErrorCode.ProtocolError, input)); + return false; + } + } + + bool isInterim = hasStatus && statusCode is >= 100 and <= 199; + + if (hasStatus && !isInterim) + { + var response = (Response)headerRr; + response.HttpVersion = HttpVersion.Version20; + response.StatusCode = statusCode; + response.StatusDescription = string.Empty; + response.Headers.TakeContentsFrom(collected); + + if (httpInterceptionEnabled && forceStaticHpackTable + && connectionState.Streams.TryGetValue(hbStreamId, out var respCapture)) + { + respCapture.CapturedCompressedHeaders = compressed; + response.Headers.ArmMitmRelayBaseline(); + respCapture.CapturedStatusCode = statusCode; + } + + // Matches HTTP/1.x's ResponseHeadersReceivedAt timing mark (see + // ResponseHandler.HandleHttpSessionResponse), stamped here at the same logical point: + // right after the final (non-interim) response headers are parsed, before BeforeResponse runs. + sessionArgs.Timing?.MarkResponseHeadersReceived(); + + // END_STREAM on response HEADERS ⇒ no response body waiters for GetResponseBody. + TaskCompletionSource? tcs = endStreamFlag ? null : new TaskCompletionSource(); + response.ReadHttp2BeforeHandlerTaskCompletionSource = tcs; + + var streamContext = new Http2StreamContext(hbStreamId, connectionState, + isClient ? input : output, cancellationToken); + // Static-HPACK MITM: dispatch BeforeResponse+relay off the origin→client frame loop + // (mirrors request DispatchRequestAfterHeadersAsync). Awaiting on the loop serialized + // every stream's BeforeResponse under c=64 and was a large Lite÷Reverse tax. + // Dynamic HPACK / bridges keep the inline await so encode stays ordered with decode. + var dispatchFrameHeader = new Http2FrameHeader { StreamId = hbStreamId }; + byte[]? dispatchFrameHeaderBuffer = null; + + // Prefer Task.CompletedTask when BeforeResponse + compressed relay finish inline + // (same shape as StartMitmStaticRequestDispatch) — avoids per-stream async SM. + Task StartMitmStaticResponseDispatch() + { + var handler = onBeforeRequestResponse(sessionArgs, streamContext); + if (tcs == null && handler.IsCompletedSuccessfully) + { + var finalResponse = sessionArgs.HttpClient.Response; + if (!ReferenceEquals(finalResponse, response)) + return DispatchResponseAfterHeadersAsync(handler); + + var injectViaResp = !sessionArgs.IsFastPath && !sessionArgs.IsTransparent + && !sessionArgs.IsSocks + && !string.IsNullOrEmpty(sessionArgs.Server.ViaHeaderPseudonym); + + if (forceStaticHpackTable + && connectionState.Streams.TryGetValue(hbStreamId, out var respRelay) + && respRelay.CapturedCompressedHeaders != null + && !finalResponse.IsBodyRead + && finalResponse.StatusCode == respRelay.CapturedStatusCode) + { + respRelay.HeadersRelayBaseline = finalResponse.Headers.TakeMitmRelayBaseline(); + byte[]? blockToRelay = null; + byte[]? appendSuffix = null; + if (!injectViaResp + && MitmCompressedRelayHelper.AllowsCompressedRelay( + respRelay.HeadersRelayBaseline.MutationCount, + finalResponse.Headers, + MitmCompressedRelayHelper.DefaultMaxAppendHeaders, + out _)) + { + blockToRelay = respRelay.CapturedCompressedHeaders; + } + else if (TryPrepareMitmStaticHpackRelay( + respRelay.CapturedCompressedHeaders, + respRelay.HeadersRelayBaseline, finalResponse.Headers, + injectViaResp, + injectViaResp + ? $"{finalResponse.HttpVersion.Major}.{finalResponse.HttpVersion.Minor} {sessionArgs.Server.ViaHeaderPseudonym}" + : null, + out blockToRelay, out appendSuffix)) + { + // Full append-only / drop-rebuild static finish + } + + if (blockToRelay != null) + { + var relayTask = RelayCompressedHeaderBlockAsync( + connectionState, input, output, outputWriteLock, ownLegWriteLock, originReceiveLeg, + compressedRelaySchemeOverride, isClient, cancellationToken, hpack, remoteSettings, + maxDecodedHeaderListBytes, logger, removeAndFinalizeStream, + hbStreamId, + blockToRelay, endStreamFlag, appendSuffix); + if (relayTask.IsCompletedSuccessfully) + { + FinishMitmStaticResponseRelay(finalResponse, respRelay); + return Task.CompletedTask; + } + + return CompleteMitmLiteResponseRelayAsync(relayTask, finalResponse, respRelay); + } + } + + // Re-encode still sync when QueueSendHeader only enqueues. + if (injectViaResp) + { + ProxyServer.AddViaHeader(finalResponse.Headers, finalResponse.HttpVersion, + sessionArgs.Server.ViaHeaderPseudonym); + } + + if (connectionState.Streams.TryGetValue(hbStreamId, out var clearResp)) + clearResp.CapturedCompressedHeaders = null; + QueueSendHeader(connectionState, towardServer: false, outputWriteLock, + remoteSettings, dispatchFrameHeader, + dispatchFrameHeaderBuffer ??= new byte[9], finalResponse, + endStreamFlag, output, isPromise); + FinishMitmStaticResponseLocked(finalResponse); + return Task.CompletedTask; + } + + return DispatchResponseAfterHeadersAsync(handler); + + void FinishMitmStaticResponseRelay(Response finalResponse, Http2StreamState respRelay) + { + response.ReadHttp2BeforeHandlerTaskCompletionSource = null; + respRelay.EnableResponseDataCompressedRelay(); + FinishMitmStaticResponseLocked(finalResponse); + } + + void FinishMitmStaticResponseLocked(Response finalResponse) + { + if (finalResponse.StatusCode is >= 200 and < 300 + && connectionState.Streams.TryGetValue(hbStreamId, out var tunnelEstState) + && tunnelEstState.IsExtendedConnect + && tunnelEstState.InboundTunnelChannel == null) + { + tunnelEstState.ExtendedConnectEstablished = true; + } + + finalResponse.Locked = true; + } + + async Task CompleteMitmLiteResponseRelayAsync(Task relayTask, Response finalResponse, + Http2StreamState respRelay) + { + await relayTask; + FinishMitmStaticResponseRelay(finalResponse, respRelay); + } + } + + async Task DispatchResponseAfterHeadersAsync(Task? prestartedHandler = null) + { + var handler = prestartedHandler ?? onBeforeRequestResponse(sessionArgs, streamContext); + bool handlerCompleted; + if (tcs == null) + { + await handler; + handlerCompleted = true; + } + else + { + handlerCompleted = handler == await Task.WhenAny(tcs.Task, handler); + } + + if (handlerCompleted) + { + response.ReadHttp2BeforeHandlerTaskCompletionSource = null; + tcs?.SetResult(true); + + // BeforeResponse may have replaced HttpClient.Response outright - exactly what + // Respond()/Ok()/Redirect() do when called after the real response was already + // received. Note that this is the *one* Respond() call site that does not set + // Request.CancelRequest (see SessionEventArgs.Respond: that flag only means + // "never forward the request", which is meaningless once the request has already + // gone out) - so the only reliable signal that a replacement happened is whether + // HttpClient.Response is no longer the same object `response` above was captured + // from *before* the handler ran. Dispatching the stale `response` here would + // silently drop the replacement and send the original object instead. + var finalResponse = sessionArgs.HttpClient.Response; + + if (!ReferenceEquals(finalResponse, response)) + { + // the real response's own body (if the server is still sending one) must + // never reach the client now that a different response has been substituted; + // suppress it exactly like an in-flight GetBody() wait does. Flow-control + // credit for those bytes is still granted back to the server unconditionally + // by the generic DATA-frame handling below, regardless of this flag. + finalResponse.Http2IgnoreBodyFrames = true; + finalResponse.Locked = true; + + connectionState.Streams.TryGetValue(hbStreamId, out var streamState); + var linkedCts893 = streamState != null + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, + streamState.Cancellation.Token) + : null; + var streamToken = linkedCts893?.Token ?? cancellationToken; + // we are inside the isClient=false branch, so `output` is the client stream + // here (see the isClient=false call in SendHttp2). + var synthTask = EmitSyntheticResponseAsync(sessionArgs, hbStreamId, connectionState, + output, streamToken, onAfterResponse, logger) + .ContinueWith(t => + { + linkedCts893?.Dispose(); + if (t.IsFaulted) + { + ReportException(logger, new ProxyHttpException( + SyntheticResponseFailedMessage, t.Exception.GetBaseException(), + sessionArgs)); + } + }, TaskScheduler.Default); + if (streamState != null) streamState.SyntheticTask = synthTask; + pendingSynthetics.Track(synthTask); + + return; + } + + // Match H1/H3 fast-path: skip Via when no HTTP interception — append as HPACK + // literal on compressed relay instead of mutating before the relay gate. + var injectViaResp = !sessionArgs.IsFastPath && !sessionArgs.IsTransparent + && !sessionArgs.IsSocks + && !string.IsNullOrEmpty(sessionArgs.Server.ViaHeaderPseudonym); + + // True MITM noop-safe: relay original compressed response HEADERS when unchanged. + // GetResponseBody / SetResponseBody set IsBodyRead/BodyAvailable — must re-encode. + var responseRelayed = false; + if (forceStaticHpackTable + && ReferenceEquals(finalResponse, response) + && connectionState.Streams.TryGetValue(hbStreamId, out var respRelay) + && respRelay.CapturedCompressedHeaders != null + && !finalResponse.IsBodyRead + && finalResponse.StatusCode == respRelay.CapturedStatusCode) + { + respRelay.HeadersRelayBaseline = finalResponse.Headers.TakeMitmRelayBaseline(); + if (!injectViaResp + && MitmCompressedRelayHelper.AllowsCompressedRelay( + respRelay.HeadersRelayBaseline.MutationCount, + finalResponse.Headers, + MitmCompressedRelayHelper.DefaultMaxAppendHeaders, + out _)) + { + await RelayCompressedHeaderBlockAsync( + connectionState, input, output, outputWriteLock, ownLegWriteLock, originReceiveLeg, + compressedRelaySchemeOverride, isClient, cancellationToken, hpack, remoteSettings, + maxDecodedHeaderListBytes, logger, removeAndFinalizeStream, + hbStreamId, + respRelay.CapturedCompressedHeaders, endStreamFlag); + respRelay.EnableResponseDataCompressedRelay(); + responseRelayed = true; + } + else if (TryPrepareMitmStaticHpackRelay( + respRelay.CapturedCompressedHeaders, + respRelay.HeadersRelayBaseline, finalResponse.Headers, + injectViaResp, + injectViaResp + ? $"{finalResponse.HttpVersion.Major}.{finalResponse.HttpVersion.Minor} {sessionArgs.Server.ViaHeaderPseudonym}" + : null, + out var respBlockToRelay, out var respAppendSuffix)) + { + await RelayCompressedHeaderBlockAsync( + connectionState, input, output, outputWriteLock, ownLegWriteLock, originReceiveLeg, + compressedRelaySchemeOverride, isClient, cancellationToken, hpack, remoteSettings, + maxDecodedHeaderListBytes, logger, removeAndFinalizeStream, + hbStreamId, respBlockToRelay, endStreamFlag, + respAppendSuffix); + respRelay.EnableResponseDataCompressedRelay(); + responseRelayed = true; + } + } + + if (!responseRelayed) + { + if (injectViaResp) + { + ProxyServer.AddViaHeader(finalResponse.Headers, finalResponse.HttpVersion, + sessionArgs.Server.ViaHeaderPseudonym); + } + + if (connectionState.Streams.TryGetValue(hbStreamId, out var clearResp)) + clearResp.CapturedCompressedHeaders = null; + QueueSendHeader(connectionState, towardServer: false, outputWriteLock, + remoteSettings, dispatchFrameHeader, + dispatchFrameHeaderBuffer ??= new byte[9], finalResponse, + endStreamFlag, output, isPromise); + } + + // RFC 8441: once a final 2xx response to a native h2↔h2 extended CONNECT is + // forwarded to the client, the stream enters tunnel state. DATA frames from either + // direction are raw tunnel bytes; any subsequent HEADERS/CONTINUATION is rejected. + if (finalResponse.StatusCode is >= 200 and < 300 + && connectionState.Streams.TryGetValue(hbStreamId, out var tunnelEstState) + && tunnelEstState.IsExtendedConnect + && tunnelEstState.InboundTunnelChannel == null) + { + tunnelEstState.ExtendedConnectEstablished = true; + } + + finalResponse.Locked = true; + return; + } + + response.Http2IgnoreBodyFrames = true; + response.Locked = true; + } + + if (forceStaticHpackTable && httpInterceptionEnabled) + { + var dispatchTask = StartMitmStaticResponseDispatch(); + response.Http2BeforeHandlerTask = dispatchTask; + pendingSynthetics.Track(dispatchTask); + return false; + } + + var handler = onBeforeRequestResponse(sessionArgs, streamContext); + response.Http2BeforeHandlerTask = handler; + + bool handlerCompleted; + if (tcs == null) + { + await handler; + handlerCompleted = true; + } + else + { + handlerCompleted = handler == await Task.WhenAny(tcs.Task, handler); + } + + if (handlerCompleted) + { + response.ReadHttp2BeforeHandlerTaskCompletionSource = null; + tcs?.SetResult(true); + + // BeforeResponse may have replaced HttpClient.Response outright - exactly what + // Respond()/Ok()/Redirect() do when called after the real response was already + // received. Note that this is the *one* Respond() call site that does not set + // Request.CancelRequest (see SessionEventArgs.Respond: that flag only means + // "never forward the request", which is meaningless once the request has already + // gone out) - so the only reliable signal that a replacement happened is whether + // HttpClient.Response is no longer the same object `response` above was captured + // from *before* the handler ran. Dispatching the stale `response` here would + // silently drop the replacement and send the original object instead. + var finalResponse = sessionArgs.HttpClient.Response; + + if (!ReferenceEquals(finalResponse, response)) + { + // the real response's own body (if the server is still sending one) must + // never reach the client now that a different response has been substituted; + // suppress it exactly like an in-flight GetBody() wait does. Flow-control + // credit for those bytes is still granted back to the server unconditionally + // by the generic DATA-frame handling below, regardless of this flag. + finalResponse.Http2IgnoreBodyFrames = true; + finalResponse.Locked = true; + + connectionState.Streams.TryGetValue(hbStreamId, out var streamState); + var linkedCts893 = streamState != null + ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, + streamState.Cancellation.Token) + : null; + var streamToken = linkedCts893?.Token ?? cancellationToken; + // we are inside the isClient=false branch, so `output` is the client stream + // here (see the isClient=false call in SendHttp2). + var synthTask = EmitSyntheticResponseAsync(sessionArgs, hbStreamId, connectionState, + output, streamToken, onAfterResponse, logger) + .ContinueWith(t => + { + linkedCts893?.Dispose(); + if (t.IsFaulted) + { + ReportException(logger, new ProxyHttpException( + SyntheticResponseFailedMessage, t.Exception.GetBaseException(), + sessionArgs)); + } + }, TaskScheduler.Default); + if (streamState != null) streamState.SyntheticTask = synthTask; + pendingSynthetics.Track(synthTask); + + return false; + } + + // Match H1/H3 fast-path: skip Via when no HTTP interception — append as HPACK + // literal on compressed relay instead of mutating before the relay gate. + var injectViaResp = !sessionArgs.IsFastPath && !sessionArgs.IsTransparent + && !sessionArgs.IsSocks + && !string.IsNullOrEmpty(sessionArgs.Server.ViaHeaderPseudonym); + + // True MITM noop-safe: relay original compressed response HEADERS when unchanged. + // GetResponseBody / SetResponseBody set IsBodyRead/BodyAvailable — must re-encode. + var responseRelayed = false; + if (forceStaticHpackTable + && ReferenceEquals(finalResponse, response) + && connectionState.Streams.TryGetValue(hbStreamId, out var respRelay) + && respRelay.CapturedCompressedHeaders != null + && !finalResponse.IsBodyRead + && finalResponse.StatusCode == respRelay.CapturedStatusCode) + { + respRelay.HeadersRelayBaseline = finalResponse.Headers.TakeMitmRelayBaseline(); + if (!injectViaResp + && MitmCompressedRelayHelper.AllowsCompressedRelay( + respRelay.HeadersRelayBaseline.MutationCount, + finalResponse.Headers, + MitmCompressedRelayHelper.DefaultMaxAppendHeaders, + out _)) + { + await RelayCompressedHeaderBlockAsync( + connectionState, input, output, outputWriteLock, ownLegWriteLock, originReceiveLeg, + compressedRelaySchemeOverride, isClient, cancellationToken, hpack, remoteSettings, + maxDecodedHeaderListBytes, logger, removeAndFinalizeStream, + hbStreamId, + respRelay.CapturedCompressedHeaders, endStreamFlag); + respRelay.EnableResponseDataCompressedRelay(); + responseRelayed = true; + } + else if (TryPrepareMitmStaticHpackRelay( + respRelay.CapturedCompressedHeaders, + respRelay.HeadersRelayBaseline, finalResponse.Headers, + injectViaResp, + injectViaResp + ? $"{finalResponse.HttpVersion.Major}.{finalResponse.HttpVersion.Minor} {sessionArgs.Server.ViaHeaderPseudonym}" + : null, + out var respBlockToRelay, out var respAppendSuffix)) + { + await RelayCompressedHeaderBlockAsync( + connectionState, input, output, outputWriteLock, ownLegWriteLock, originReceiveLeg, + compressedRelaySchemeOverride, isClient, cancellationToken, hpack, remoteSettings, + maxDecodedHeaderListBytes, logger, removeAndFinalizeStream, + hbStreamId, respBlockToRelay, endStreamFlag, + respAppendSuffix); + respRelay.EnableResponseDataCompressedRelay(); + responseRelayed = true; + } + } + + if (!responseRelayed) + { + if (injectViaResp) + { + ProxyServer.AddViaHeader(finalResponse.Headers, finalResponse.HttpVersion, + sessionArgs.Server.ViaHeaderPseudonym); + } + + if (connectionState.Streams.TryGetValue(hbStreamId, out var clearResp)) + clearResp.CapturedCompressedHeaders = null; + QueueSendHeader(connectionState, towardServer: false, outputWriteLock, + remoteSettings, frameHeader, frameHeaderBuffer, finalResponse, + endStreamFlag, output, isPromise); + } + + // RFC 8441: once a final 2xx response to a native h2↔h2 extended CONNECT is + // forwarded to the client, the stream enters tunnel state. DATA frames from either + // direction are raw tunnel bytes; any subsequent HEADERS/CONTINUATION is rejected. + if (finalResponse.StatusCode is >= 200 and < 300 + && connectionState.Streams.TryGetValue(hbStreamId, out var tunnelEstState) + && tunnelEstState.IsExtendedConnect + && tunnelEstState.InboundTunnelChannel == null) + { + tunnelEstState.ExtendedConnectEstablished = true; + } + + finalResponse.Locked = true; + return false; + } + else + { + response.Http2IgnoreBodyFrames = true; + } + + response.Locked = true; + return false; + } + + if (isInterim) + { + // interim (1xx) response: relay verbatim on its own HEADERS frame, do not fire + // BeforeResponse and do not touch the final Response object - mirrors how HTTP/1.x + // interim responses are handled (see ResponseHandler.HandleHttpSessionResponse). + var synthetic = new Response { StatusCode = statusCode, StatusDescription = string.Empty }; + foreach (var header in collected) + { + synthetic.Headers.AddHeader(header); + } + + QueueSendHeader(connectionState, towardServer: false, outputWriteLock, + remoteSettings, frameHeader, frameHeaderBuffer, synthetic, false, output, false); + return true; + } + + // response trailers - never valid before any final response headers were seen. + // Also catches the case where a response HEADERS block is missing the required :status + // pseudo-field (RFC 7540 §8.1.2.4). + if (headerRr.HttpVersion < HttpHeader.Version20) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: response HEADERS missing required :status pseudo-header.", + null, sessionArgs)); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + hbStreamId, Http2ErrorCode.ProtocolError, input)); + return false; + } + + // RFC 7540 §8.1.2.1: trailer HEADERS MUST NOT contain pseudo-header fields. + if (headerListener.Method.Length > 0 || headerListener.Path.Length > 0 || + headerListener.Status.Length > 0 || headerListener.Authority.Length > 0 || + headerListener.Scheme != string.Empty) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: response trailer HEADERS contains pseudo-header fields.", + null, sessionArgs)); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + hbStreamId, Http2ErrorCode.ProtocolError, input)); + return false; + } + + // RFC 9110 §6.5.1: certain fields are forbidden in trailers. + var forbiddenTrailerHeader = collected.FirstOrDefault(header => + ForbiddenTrailerHeaders.Contains(header.Name)); + if (forbiddenTrailerHeader != null) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: response trailer HEADERS contains forbidden field '" + + forbiddenTrailerHeader.Name + "'.", null, sessionArgs)); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + hbStreamId, Http2ErrorCode.ProtocolError, input)); + return false; + } + + foreach (var header in collected) + { + headerRr.TrailingHeaders.AddHeader(header); + } + + // Drain queued response HEADERS/DATA so trailers cannot overtake them. + await connectionState.ClientWriteChain; + await lockedOutputWrite(() => AsValueTask(SendTrailer(remoteSettings, frameHeader, frameHeaderBuffer, + hbStreamId, headerRr.TrailingHeaders, endStreamFlag, output))); + return false; + } + } + + private static string InternCommonHttpMethod(ReadOnlySpan methodSpan, ByteString method) + { + if (methodSpan.SequenceEqual("GET"u8)) return "GET"; + if (methodSpan.SequenceEqual("HEAD"u8)) return "HEAD"; + if (methodSpan.SequenceEqual("POST"u8)) return "POST"; + if (methodSpan.SequenceEqual("PUT"u8)) return "PUT"; + if (methodSpan.SequenceEqual("DELETE"u8)) return "DELETE"; + if (methodSpan.SequenceEqual("OPTIONS"u8)) return "OPTIONS"; + return method.GetString(); + } + } +} diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Parse.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Parse.cs new file mode 100644 index 000000000..018d687a6 --- /dev/null +++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Parse.cs @@ -0,0 +1,83 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Titanium.Web.Proxy.Compression; +using Titanium.Web.Proxy.Diagnostics; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Exceptions; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Http2.Hpack; +using Titanium.Web.Proxy.Logging; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network.Streams; +using Titanium.Web.Proxy.Network.Tcp; +using Titanium.Web.Proxy.Options; +using Decoder = Titanium.Web.Proxy.Http2.Hpack.Decoder; +using Encoder = Titanium.Web.Proxy.Http2.Hpack.Encoder; + +namespace Titanium.Web.Proxy.Http2 +{ + internal partial class Http2Helper + { + /// + /// Per-direction HPACK + request-dispatch slot for . + /// Allocated once per relay task (not per frame). Async methods cannot take ref, + /// so decoder/table-size/dispatch-chain live here instead of as locals on the frame loop. + /// + private sealed class CopyDirectionHpack + { + public Decoder? Decoder; + public int HeaderTableSize; + public Task RequestDispatchChain = Task.CompletedTask; + } + + /// Serialize a write onto a connection-direction lock. Same shape as the Copy local helpers. + private static async ValueTask LockedWriteAsync(SemaphoreSlim gate, CancellationToken cancellationToken, + Func writeAction) + { + await gate.WaitAsync(cancellationToken); + try + { + await writeAction(); + } + finally + { + gate.Release(); + } + } + + private static int ReadHttp2FrameLength(byte[] frameHeaderBuffer) => + (frameHeaderBuffer[0] << 16) + (frameHeaderBuffer[1] << 8) + frameHeaderBuffer[2]; + + private static int ReadHttp2StreamId(byte[] frameHeaderBuffer) => + ((frameHeaderBuffer[5] & 0x7f) << 24) + (frameHeaderBuffer[6] << 16) + + (frameHeaderBuffer[7] << 8) + frameHeaderBuffer[8]; + + private static int ReadHttp2UInt31(byte[] buffer) => + ((buffer[0] & 0x7f) << 24) + (buffer[1] << 16) + (buffer[2] << 8) + buffer[3]; + + private static int ReadHttp2ErrorCode(byte[] buffer) => + (buffer[0] << 24) + (buffer[1] << 16) + (buffer[2] << 8) + buffer[3]; + + private static void GetHttp2PaddedDataRange(byte[] buffer, int length, bool padded, + out int dataOff, out int dataLen) + { + dataOff = padded ? 1 : 0; + dataLen = padded ? length - 1 - buffer[0] : length; + if (dataLen < 0) + dataLen = 0; + } + } +} diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Relay.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Relay.cs new file mode 100644 index 000000000..f8c84d5ee --- /dev/null +++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.Relay.cs @@ -0,0 +1,224 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Titanium.Web.Proxy.Compression; +using Titanium.Web.Proxy.Diagnostics; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Exceptions; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Http2.Hpack; +using Titanium.Web.Proxy.Logging; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network.Streams; +using Titanium.Web.Proxy.Network.Tcp; +using Titanium.Web.Proxy.Options; +using Decoder = Titanium.Web.Proxy.Http2.Hpack.Decoder; +using Encoder = Titanium.Web.Proxy.Http2.Hpack.Encoder; + +namespace Titanium.Web.Proxy.Http2 +{ + internal partial class Http2Helper + { + // Gate-off same-protocol path: keep HPACK hpack.Decoder in sync with a no-op listener, then + // forward the compressed block unchanged (valid when both legs negotiated table size 0). + // Same-transport / patched scheme + no origin pool: sync enqueue (no async SM). + // Async only for multi-origin AssignStreamAsync or rare scheme-decode RST/GOAWAY. + private static void EnqueueRelayedHeaderBlock( // NOSONAR S107 -- Relay frame fields stay explicit; no options bag on the compressed-relay path. + Http2ConnectionState connectionState, + bool isClient, + Http2Settings remoteSettings, + int wireStreamId, ReadOnlyMemory blockToRelay, + bool endStreamFlag, byte[]? appendSuffix, Http2FrameWriter? dedicatedWriter, + SemaphoreSlim writeLock, Stream writeStream) + { + var relayFrameHeader = new Http2FrameHeader { StreamId = wireStreamId }; + var appendMemory = appendSuffix == null ? ReadOnlyMemory.Empty : appendSuffix.AsMemory(); + // Header bytes are written straight into the rented frame; no shared 9-byte scratch. + var framed = RentFramedHeaderBlock(relayFrameHeader, Array.Empty(), wireStreamId, + Http2FrameType.Headers, endStreamFlag, hasPriority: false, blockToRelay, appendMemory, + remoteSettings.MaxFrameSize); + if (dedicatedWriter != null) + dedicatedWriter.EnqueueRented(framed.Array!, framed.Count); + else + connectionState.EnqueueWriteRented(isClient, writeLock, writeStream, + framed.Array!, framed.Count); + } + + private static Task RelayCompressedHeaderBlockAsync( // NOSONAR S107 -- Relay collaborators stay explicit to avoid allocating a context on the compressed-relay path. + Http2ConnectionState connectionState, + Stream input, + Stream output, + SemaphoreSlim outputWriteLock, + SemaphoreSlim ownLegWriteLock, + Http2OriginRelayPool.OriginLeg? originReceiveLeg, + ByteString compressedRelaySchemeOverride, + bool isClient, + CancellationToken cancellationToken, + CopyDirectionHpack hpack, + Http2Settings remoteSettings, + int maxDecodedHeaderListBytes, + ILogger logger, + Action removeAndFinalizeStream, + int hbStreamId, byte[] compressed, bool endStreamFlag, + byte[]? appendSuffix = null) + { + // Mixed-transport: prefer a structural HPACK walk that only rewrites Indexed + // :scheme (0x86↔0x87) — no Decoder, no HeaderCollection. .NET HttpClient and most + // browsers emit static-indexed :scheme; decode+re-encode is the rare fallback. + // Same-transport relay stays verbatim (no override). + ReadOnlyMemory blockToRelay = compressed; + if (compressedRelaySchemeOverride.Length > 0) + { + switch (TryApplyStaticIndexedSchemeOverride(compressed, compressedRelaySchemeOverride, + out var patchedFast)) + { + case StaticSchemeOverrideResult.Patched: + blockToRelay = patchedFast; + break; + case StaticSchemeOverrideResult.AlreadyMatching: + break; + default: + return RelayCompressedWithSchemeDecodeAsync( + connectionState, input, output, outputWriteLock, ownLegWriteLock, originReceiveLeg, + compressedRelaySchemeOverride, isClient, cancellationToken, hpack, remoteSettings, + maxDecodedHeaderListBytes, logger, removeAndFinalizeStream, + hbStreamId, compressed, endStreamFlag, appendSuffix); + } + } + + if (isClient && connectionState.OriginRelayPool != null) + return RelayCompressedWithOriginPoolAsync( + connectionState, isClient, remoteSettings, cancellationToken, + hbStreamId, blockToRelay, endStreamFlag, appendSuffix); + + Http2FrameWriter? dedicatedWriter = null; + var writeLock = outputWriteLock; + var writeStream = output; + if (!isClient && originReceiveLeg != null) + { + // Origin → client: hbStreamId is already remapped to the client stream id by the caller. + dedicatedWriter = connectionState.ClientFrameWriter; + } + + EnqueueRelayedHeaderBlock(connectionState, isClient, remoteSettings, + hbStreamId, blockToRelay, endStreamFlag, appendSuffix, + dedicatedWriter, writeLock, writeStream); + return Task.CompletedTask; + } + + private static async Task RelayCompressedWithOriginPoolAsync( // NOSONAR S107 -- Origin-pool assignment args stay explicit. + Http2ConnectionState connectionState, + bool isClient, + Http2Settings remoteSettings, + CancellationToken cancellationToken, + int hbStreamId, ReadOnlyMemory blockToRelay, + bool endStreamFlag, byte[]? appendSuffix) + { + var assignment = await connectionState.OriginRelayPool! // NOSONAR S8969 -- Caller already gated OriginRelayPool != null. + .AssignStreamAsync(hbStreamId, cancellationToken).ConfigureAwait(false); + EnqueueRelayedHeaderBlock(connectionState, isClient, remoteSettings, + assignment.OriginStreamId, blockToRelay, endStreamFlag, appendSuffix, + assignment.Leg.Writer, assignment.Leg.WriteLock, assignment.Leg.Stream); + } + + private static async Task RelayCompressedWithSchemeDecodeAsync( // NOSONAR S107 -- Scheme-decode fallback keeps the same explicit relay signature as the fast path. + Http2ConnectionState connectionState, + Stream input, + Stream output, + SemaphoreSlim outputWriteLock, + SemaphoreSlim ownLegWriteLock, + Http2OriginRelayPool.OriginLeg? originReceiveLeg, + ByteString compressedRelaySchemeOverride, + bool isClient, + CancellationToken cancellationToken, + CopyDirectionHpack hpack, + Http2Settings remoteSettings, + int maxDecodedHeaderListBytes, + ILogger logger, + Action removeAndFinalizeStream, + int hbStreamId, byte[] compressed, bool endStreamFlag, + byte[]? appendSuffix) + { + var overrideHeaders = new HeaderCollection(); + var overrideListener = new MyHeaderListener( + (name, value) => overrideHeaders.AddHeader(new HttpHeader(name, value)), + isRequest: true); + try + { + if (hpack.Decoder == null) + { + hpack.HeaderTableSize = remoteSettings.HeaderTableSize; + hpack.Decoder = new Decoder(maxDecodedHeaderListBytes, hpack.HeaderTableSize); + } + else if (hpack.HeaderTableSize != remoteSettings.HeaderTableSize) + { + hpack.HeaderTableSize = remoteSettings.HeaderTableSize; + hpack.Decoder.SetMaxHeaderTableSize(hpack.HeaderTableSize); + } + + hpack.Decoder.Decode(compressed.AsSpan(0, compressed.Length), overrideListener); + if (hpack.Decoder.EndHeaderBlock()) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 header list too large on compressed-relay stream.", null, null)); + removeAndFinalizeStream(hbStreamId); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendRstStreamAsync(new Http2FrameHeader(), + new byte[9], hbStreamId, (Http2ErrorCode)0xb /* ENHANCE_YOUR_CALM */, + input)); + return; + } + } + catch (Exception ex) + { + ReportException(logger, new ProxyHttpException( + "Failed to decode HTTP/2 headers on compressed-relay stream", ex, null)); + await LockedWriteAsync(ownLegWriteLock, cancellationToken, () => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], + hbStreamId, Http2ErrorCode.CompressionError, input)); + throw; + } + + ReadOnlyMemory blockToRelay = compressed; + // Trailers / CONNECT (no :scheme) and already-matching schemes stay verbatim. + if (!overrideListener.HasMalformedHeader + && overrideListener.RawScheme.Length > 0 + && !overrideListener.RawScheme.Equals(compressedRelaySchemeOverride)) + { + if (TryPatchStaticIndexedScheme(compressed, overrideListener.RawScheme, + compressedRelaySchemeOverride, out var patched)) + blockToRelay = patched; + else + blockToRelay = ReencodeCompressedRequestBlock(remoteSettings, + overrideListener, overrideHeaders, compressedRelaySchemeOverride); + } + + if (isClient && connectionState.OriginRelayPool != null) + { + await RelayCompressedWithOriginPoolAsync( + connectionState, isClient, remoteSettings, cancellationToken, + hbStreamId, blockToRelay, endStreamFlag, appendSuffix) + .ConfigureAwait(false); + return; + } + + Http2FrameWriter? dedicatedWriter = null; + if (!isClient && originReceiveLeg != null) + dedicatedWriter = connectionState.ClientFrameWriter; + + EnqueueRelayedHeaderBlock(connectionState, isClient, remoteSettings, + hbStreamId, blockToRelay, endStreamFlag, appendSuffix, + dedicatedWriter, outputWriteLock, output); + } + } +} diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs new file mode 100644 index 000000000..46942a70e --- /dev/null +++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Copy.cs @@ -0,0 +1,2201 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Titanium.Web.Proxy.Compression; +using Titanium.Web.Proxy.Diagnostics; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Exceptions; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Http2.Hpack; +using Titanium.Web.Proxy.Logging; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network.Streams; +using Titanium.Web.Proxy.Network.Tcp; +using Titanium.Web.Proxy.Options; +using Decoder = Titanium.Web.Proxy.Http2.Hpack.Decoder; +using Encoder = Titanium.Web.Proxy.Http2.Hpack.Encoder; + +namespace Titanium.Web.Proxy.Http2 +{ + internal partial class Http2Helper + { + private const int MaxHeaderBlockBytes = 256 * 1024; + + private static readonly HashSet ForbiddenConnectionSpecificHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade" + }; + + /// + /// Header fields that RFC 7540 §8.1.2.2 / RFC 9110 §6.5.1 forbid in HTTP/2 trailer sections. + /// + private static readonly HashSet ForbiddenTrailerHeaders = new(StringComparer.OrdinalIgnoreCase) + { + "transfer-encoding", "content-length", "host", "trailer" + }; + + private static async Task CopyHttp2FrameAsync(Stream input, Stream output, // NOSONAR S3776, CA1068 -- Protocol flow and established token position are retained. + Http2ConnectionState connectionState, + Func sessionFactory, + Func onBeforeRequestResponse, + Func onAfterResponse, + Action? prepareRequestHeaders, + bool isClient, + CancellationToken cancellationToken, + ILogger logger, + int maxDecodedHeaderListBytes = 64 * 1024, + bool enableRfc8441 = false, + ProxyResourceLimits? resourceLimits = null, + TcpServerConnection? originConnection = null, + bool httpInterceptionEnabled = true, + Func? shouldInterceptHttp = null, + Http2OriginRelayPool.OriginLeg? originReceiveLeg = null, + bool forceStaticHpackForMitmUnchangedRelay = false) + { + resourceLimits ??= ProxyResourceLimits.Default; + var cancellationTokenSource = connectionState.CancellationTokenSource; + + // Same-protocol H2↔H2 (not NullOrigin / RFC 8441): compressed-relay topology. + // Gate-off: full compressed-relay (no SessionEventArgs). Gate-on (transparent/socks): + // decode + handlers, then relay compressed bytes when unchanged — requires static HPACK. + // Explicit MITM re-encodes (Via) and must not force HEADER_TABLE_SIZE=0. + bool canCompressedRelayTopology = !enableRfc8441 + && output is not NullOriginStream + && input is not NullOriginStream; + bool useCompressedRelay = canCompressedRelayTopology && !httpInterceptionEnabled; + bool forceStaticHpackTable = useCompressedRelay + || (canCompressedRelayTopology && httpInterceptionEnabled + && forceStaticHpackForMitmUnchangedRelay); + if (forceStaticHpackTable) + { + // Static-table-only on both legs so compressed blocks are interchangeable. + connectionState.ClientSettings.UpdateHeaderTableSize(0); + connectionState.ServerSettings.UpdateHeaderTableSize(0); + } + + // Mixed-transport passthrough (inbound h2c client → TLS origin, or TLS-terminated client → + // cleartext h2 origin): the verbatim compressed block still carries the client's ':scheme', + // and strict ASP.NET Core origins reset every stream whose :scheme does not match the + // origin transport with RST_STREAM(PROTOCOL_ERROR). Detect the mismatch once here; request + // blocks are then patched/re-encoded with the origin-transport scheme in + // RelayCompressedHeaderBlockAsync. Same-transport connections keep the zero-work verbatim + // relay (decode stays NoOp). + // Apply whenever compressed blocks may be relayed: gate-off (useCompressedRelay) *and* + // MITM unchanged-lite (forceStaticHpackTable) — the latter also calls + // RelayCompressedHeaderBlockAsync with the captured client block. + ByteString compressedRelaySchemeOverride = default; + if (forceStaticHpackTable && isClient && originConnection != null + && input is HttpClientStream { Connection: { } relayClientConnection } + && originConnection.IsHttps == relayClientConnection.Http2CleartextClient) + { + compressedRelaySchemeOverride = originConnection.IsHttps + ? ProxyServer.UriSchemeHttps8 + : ProxyServer.UriSchemeHttp8; + } + + // "Settings describing the peer this task reads from" - used both to size the HPACK decoder for + // header blocks read from that peer, and (SETTINGS handling below) updated directly from that + // peer's own SETTINGS frames, since both describe properties *of that same peer*. + var localSettings = isClient ? connectionState.ClientSettings : connectionState.ServerSettings; + + // "Settings describing the peer this task writes to" - used to size outbound HEADERS/ + // CONTINUATION/DATA framing so it never exceeds what that peer advertised it will accept. + var remoteSettings = isClient ? connectionState.ServerSettings : connectionState.ClientSettings; + + // One decode scratch + listener per connection direction: HEADERS decode is serialized on + // this frame loop, so ConcurrentBag contention is unnecessary. Clears MutationCount/COW + // without the live-bag Clear() side effects. Listener is reused (no per-block Action alloc). + var headerDecodeScratch = new HeaderCollection(); + var headerDecodeListener = new MyHeaderListener(headerDecodeScratch, isRequest: isClient); + + // Flow control governing DATA this task writes toward `output`; replenished by WINDOW_UPDATE/ + // SETTINGS_INITIAL_WINDOW_SIZE frames read from that same peer - necessarily by the *other* + // relay task, since both directions of one leg are read/written by different tasks here. Also + // used by SendBody/SendData for this same output. + var outboundFlow = isClient ? connectionState.ServerSendFlow : connectionState.ClientSendFlow; + + // The lock protecting every write onto `input` itself (same-leg replies: PING ACK, WINDOW_UPDATE + // receive-credit grants, RST_STREAM for a malformed block). + SemaphoreSlim ownLegWriteLock; + if (originReceiveLeg != null) + ownLegWriteLock = originReceiveLeg.WriteLock; + else if (isClient) + ownLegWriteLock = connectionState.ClientWriteLock; + else + ownLegWriteLock = connectionState.ServerWriteLock; + + // The lock protecting every write onto `output` (shared with the other task, which reads from + // `output`'s peer and may itself need to reply directly on it). + var outputWriteLock = isClient ? connectionState.ServerWriteLock : connectionState.ClientWriteLock; + + // Multi-origin overflow legs must not forward connection-level frames to the client. + var suppressConnectionFrameRelay = originReceiveLeg != null + && connectionState.OriginRelayPool != null + && !ReferenceEquals(originReceiveLeg, connectionState.OriginRelayPool.PrimaryLeg); + + var hpack = new CopyDirectionHpack(); + + // stream ids that were answered with a synthetic (proxy-generated) response and therefore must not + // be forwarded to the server. Only relevant on the client=>server relay. + // Must be cleared when streams leave the registry — otherwise keep-alive H2→H1 multiplex + // retains one entry per historical stream id for the connection lifetime (saturation dump: + // ~225k ConcurrentDictionary nodes / ~14 MiB managed on a single client connection). + var syntheticStreams = new ConcurrentDictionary(); + if (isClient) + connectionState.ClientSyntheticStreams = syntheticStreams; + + // Synthetic responses (Ok/Respond/RespondStreaming during BeforeRequest) are no longer awaited + // inline in the frame loop below (see the HEADERS dispatch) so that a slow synthetic body does + // not stall every other multiplexed stream on the connection. Track them here so we can still + // observe/report failures and make sure they are fully drained before this relay direction's + // task completes. + var pendingSynthetics = connectionState.PendingSynthetics; + + var frameHeader = new Http2FrameHeader(); + var frameHeaderBuffer = new byte[9]; + + // Writes toward `output` must be serialized against every other writer of that same stream: the + // other relay task's own-leg control-frame replies (WINDOW_UPDATE receive-credit grants, + // RST_STREAM, GOAWAY, PING ACK - all written directly onto this task's `output`, since it is + // that other task's `input`), and any synthetic response task writing toward the client. Every + // write onto `output`, including this task's own main relay/dispatch path, must go through this + // helper - a writer that bypasses it can still interleave bytes with one that does not. + async ValueTask lockedOutputWrite(Func writeAction) + { + await outputWriteLock.WaitAsync(cancellationToken); + try + { + await writeAction(); + } + finally + { + outputWriteLock.Release(); + } + } + + // Writes directly back onto `input` (same leg this task reads from) - PING ACK, receive-credit + // WINDOW_UPDATE, or a stream-level RST_STREAM for a malformed header block. + async ValueTask lockedOwnLegWrite(Func writeAction) + { + await ownLegWriteLock.WaitAsync(cancellationToken); + try + { + await writeAction(); + } + finally + { + ownLegWriteLock.Release(); + } + } + + // Grants back flow-control credit consumed by reading DATA frames. Batched at + // ReceiveCreditBatchThreshold (half of the 768 KiB stream window) so every DATA frame + // does not take the write lock for two WINDOW_UPDATE frames. Flushed on END_STREAM / stream + // removal and when the threshold is crossed. + int pendingConnectionReceiveCredit = 0; + var pendingStreamReceiveCredit = new Dictionary(); + + ValueTask GrantReceiveCreditAsync(int streamId, int bytes, bool forceFlush = false) + { + if (bytes <= 0 && !forceFlush) return default; + + if (bytes > 0) + { + pendingConnectionReceiveCredit += bytes; + if (pendingStreamReceiveCredit.TryGetValue(streamId, out var streamPending)) + pendingStreamReceiveCredit[streamId] = streamPending + bytes; + else + pendingStreamReceiveCredit[streamId] = bytes; + } + + var flushConnection = forceFlush || pendingConnectionReceiveCredit >= ReceiveCreditBatchThreshold; + var flushStream = forceFlush + || (pendingStreamReceiveCredit.TryGetValue(streamId, out var streamCredit) + && streamCredit >= ReceiveCreditBatchThreshold); + + if (!flushConnection && !flushStream) + return default; + + var connectionBytes = flushConnection ? pendingConnectionReceiveCredit : 0; + var streamBytes = 0; + if (flushStream && pendingStreamReceiveCredit.TryGetValue(streamId, out streamBytes)) + pendingStreamReceiveCredit.Remove(streamId); + if (flushConnection) + pendingConnectionReceiveCredit = 0; + + var streamStillTracked = streamBytes > 0 && connectionState.Streams.ContainsKey(streamId); + return GrantReceiveCreditLockedAsync( + streamStillTracked ? streamId : 0, + connectionBytes, + streamStillTracked ? streamBytes : 0); + } + + async ValueTask GrantReceiveCreditLockedAsync(int streamId, int connectionBytes, int streamBytes) + { + if (connectionBytes <= 0 && streamBytes <= 0) return; + + await ownLegWriteLock.WaitAsync(cancellationToken); + try + { + var controlFrameHeader = new Http2FrameHeader(); + var controlFrameHeaderBuffer = new byte[9]; + if (connectionBytes > 0) + await SendWindowUpdateAsync(controlFrameHeader, controlFrameHeaderBuffer, 0, connectionBytes, + input); + if (streamBytes > 0 && streamId != 0) + await SendWindowUpdateAsync(controlFrameHeader, controlFrameHeaderBuffer, streamId, streamBytes, + input); + } + finally + { + ownLegWriteLock.Release(); + } + } + + async ValueTask FlushAllPendingReceiveCreditAsync() + { + if (pendingConnectionReceiveCredit <= 0 && pendingStreamReceiveCredit.Count == 0) + return; + + await ownLegWriteLock.WaitAsync(CancellationToken.None); + try + { + var controlFrameHeader = new Http2FrameHeader(); + var controlFrameHeaderBuffer = new byte[9]; + if (pendingConnectionReceiveCredit > 0) + { + await SendWindowUpdateAsync(controlFrameHeader, controlFrameHeaderBuffer, 0, + pendingConnectionReceiveCredit, input); + pendingConnectionReceiveCredit = 0; + } + + foreach (var kvp in pendingStreamReceiveCredit) + { + if (kvp.Value > 0 && connectionState.Streams.ContainsKey(kvp.Key)) + await SendWindowUpdateAsync(controlFrameHeader, controlFrameHeaderBuffer, kvp.Key, + kvp.Value, input); + } + + pendingStreamReceiveCredit.Clear(); + } + finally + { + ownLegWriteLock.Release(); + } + } + + // Removes a stream's bookkeeping (registry + both flow-control windows) and schedules its + // AfterResponse + Dispose (see FinalizeStreamAsync) without blocking the caller - used wherever + // a stream is refused/closed and will never receive a normal end-stream or RST_STREAM of its + // own to trigger that cleanup through the main loop below. + void RemoveAndFinalizeStream(int removeStreamId) + { + // Flush any batched receive credit for this stream before removing it. + if (pendingStreamReceiveCredit.TryGetValue(removeStreamId, out var leftover) && leftover > 0) + { + pendingStreamReceiveCredit.Remove(removeStreamId); + // Fire-and-forget under the loop; connection credit stays batched. + _ = GrantReceiveCreditLockedAsync(removeStreamId, 0, leftover).AsTask(); + } + + connectionState.OriginRelayPool?.ReleaseStream(removeStreamId); + + if (connectionState.TryTakeStream(removeStreamId, out var removedState)) + { + removedState.InboundTunnelChannel?.Writer.TryComplete( + new IOException("HTTP/2 stream removed due to protocol error.")); + removedState.Cancellation.Cancel(); + // Compressed-relay CTS is TryReset in PrepareForPool; disposing here forces a new CTS. + if (!removedState.IsCompressedRelay) + removedState.Cancellation.Dispose(); + connectionState.ClientSendFlow.RemoveStream(removeStreamId); + connectionState.ServerSendFlow.RemoveStream(removeStreamId); + ScheduleFinalize(removedState, onAfterResponse, logger, connectionState); + } + } + + Action removeAndFinalizeStream = RemoveAndFinalizeStream; + Func, ValueTask> lockedOutputWriteFn = lockedOutputWrite; + + byte[] buffer = new byte[MaxAcceptableFrameSize]; + // Typical HTTP/2 server stacks read a large Pipe buffer then peel frames with + // Http2FrameReader.TryReadFrame. Mirror that without a ReadOnlySequence retrofit: + // one socket ReadAsync fills up to 64 KiB; subsequent frames reuse leftover bytes. + var intake = new Http2FrameIntake(input); + + // Metadata for a HEADERS/PUSH_PROMISE block that has not yet been terminated by END_HEADERS and + // is being assembled from subsequent CONTINUATION frames (RFC 7540 ?6.10). Only one such block + // may be in flight per connection direction at a time - a HEADERS/PUSH_PROMISE frame arriving + // while another block is still open, or a CONTINUATION frame for a different stream, is a + // connection-level PROTOCOL_ERROR. + MemoryStream? pendingHeaderBlock = null; + int pendingHeaderStreamId = -1; + SessionEventArgs? pendingHeaderArgs = null; + RequestResponseBase? pendingHeaderRr = null; + bool pendingHeaderEndStream = false; + bool pendingHeaderIsPromise = false; + bool pendingCompressedRelay = false; + + // Companion bounds for the open header block above: a byte cap alone never trips on + // zero-length CONTINUATION frames, and only one header block may be open per connection + // direction, so an attacker sending an endless sequence of empty CONTINUATION frames would + // otherwise head-of-line block every other multiplexed stream on this leg forever. Both are + // reset whenever a block opens and checked on every CONTINUATION frame for it. + int pendingHeaderBlockFrameCount = 0; + long pendingHeaderBlockOpenedAt = 0; + + // RFC 7540 ?3.5: "each endpoint is required to send a connection preface... this sequence MUST + // be followed by a SETTINGS frame". The connection preface itself (the literal + // "PRI * HTTP/2.0..." bytes) is already validated before this relay starts (see the explicit + // handler's preface check); this tracks the second half of that requirement, that the first + // frame this task ever reads from `input` is SETTINGS, for both directions (a server's first + // frame is required to be SETTINGS too, even though it has no separate textual preface). + bool isFirstFrame = true; + + try + { + // Best-effort graceful shutdown notice sent to `output` (the *other* leg) when this task's own + // `input` peer disconnects or the connection is otherwise ending on this side - so that peer + // learns the connection is going away (and which streams were actually seen) via GOAWAY instead + // of only ever observing an abrupt socket close. Exceptions are swallowed: by the time this + // fires, `output` may already be broken too (e.g. both legs disconnecting around the same + // time), and a failed shutdown notice must never turn a clean teardown into a fault. + async Task TrySendGracefulGoAwayAsync() + { + try + { + await lockedOutputWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], + connectionState.LastClientStreamId, Http2ErrorCode.NoError, output)); + } + catch + { + // best-effort only - see remarks above. + } + } + + while (true) + { + if (!await intake.ReadExactAsync(frameHeaderBuffer, 0, 9, cancellationToken)) + { + await TrySendGracefulGoAwayAsync(); + return; + } + + int length = ReadHttp2FrameLength(frameHeaderBuffer); + var type = (Http2FrameType)frameHeaderBuffer[3]; + var flags = (Http2FrameFlag)frameHeaderBuffer[4]; + int streamId = ReadHttp2StreamId(frameHeaderBuffer); + + // Wire id on `input` (origin stream id when reading an origin leg). + int peerStreamId = streamId; + + frameHeader.Length = length; + frameHeader.Type = type; + frameHeader.Flags = flags; + frameHeader.StreamId = streamId; + + if (isFirstFrame) + { + isFirstFrame = false; + + // RFC 7540 §6.8: an endpoint may send GOAWAY at any time, including immediately + // after the connection preface and before ever sending SETTINGS - e.g. a browser + // gracefully tearing down a freshly-opened (often speculative/pooled) HTTP/2 + // connection it decided it no longer needs. That is normal, expected behavior, not + // a protocol violation, so let it fall through to the ordinary GOAWAY handling + // below (which relays it and records the going-away state) instead of treating + // "first frame wasn't SETTINGS" as fatal. + if (type != Http2FrameType.Settings && type != Http2FrameType.GoAway) + { + ReportException(logger, new ProxyHttpException( + $"HTTP/2 protocol error: expected a SETTINGS frame immediately after the connection preface, got {type}.", + null, null)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], 0, + Http2ErrorCode.ProtocolError, input)); + return; + } + } + + if (length > MaxAcceptableFrameSize) + { + // RFC 7540 ?4.2: a frame larger than what we (implicitly, by never advertising anything + // else) declared we would accept is a connection-level FRAME_SIZE_ERROR. Reject before + // attempting to buffer/read the (potentially huge, up to 2^24-1 byte) payload. + ReportException(logger, new ProxyHttpException( + $"HTTP/2 protocol error: frame of type {type} exceeded the maximum accepted frame size.", + null, null)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, + Http2ErrorCode.FrameSizeError, input)); + // Unlike every other rejection path here, this one fires before the frame's payload is + // ever read (see the ForceRead call right below this block) - drain it now so the GOAWAY + // just flushed above is not itself lost to an abortive close; see + // DiscardRejectedFramePayloadAsync's remarks. + await intake.DiscardAsync(length, cancellationToken); + return; + } + + if ((type == Http2FrameType.Data || type == Http2FrameType.Headers || + type == Http2FrameType.RstStream || type == Http2FrameType.Priority) && streamId == 0) + { + // RFC 7540 ?5.1.1 / relevant frame definitions: these frame types are always + // stream-specific; stream id 0 on any of them is a connection-level PROTOCOL_ERROR. + ReportException(logger, new ProxyHttpException( + $"HTTP/2 protocol error: frame of type {type} received with stream id 0.", null, null)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], 0, + Http2ErrorCode.ProtocolError, input)); + return; + } + + // Compressed-relay DATA: resolve stream remap + state before reading payload so we can + // ReadExact straight into the rented wire buffer (skip the shared frame `buffer` copy). + if (type == Http2FrameType.Data) + { + var dataStreamId = streamId; + if (originReceiveLeg != null && peerStreamId != 0) + { + if (!originReceiveLeg.OriginToClient.TryGetValue(peerStreamId, out dataStreamId)) + { + await GrantReceiveCreditAsync(peerStreamId, length, forceFlush: true); + await intake.DiscardAsync(length, cancellationToken); + await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + peerStreamId, Http2ErrorCode.StreamClosed, input)); + continue; + } + + frameHeader.StreamId = dataStreamId; + frameHeaderBuffer[5] = (byte)((dataStreamId >> 24) & 0x7f); + frameHeaderBuffer[6] = (byte)((dataStreamId >> 16) & 0xff); + frameHeaderBuffer[7] = (byte)((dataStreamId >> 8) & 0xff); + frameHeaderBuffer[8] = (byte)(dataStreamId & 0xff); + } + + if (connectionState.Streams.TryGetValue(dataStreamId, out var compressedDataState) + && (compressedDataState.IsCompressedRelay + || (isClient && compressedDataState.RequestDataCompressedRelay) + || (!isClient && compressedDataState.ResponseDataCompressedRelay))) + { + bool dataEndStream = (flags & Http2FrameFlag.EndStream) != 0; + var creditStreamId = originReceiveLeg != null ? peerStreamId : dataStreamId; + if (dataEndStream) + { + // Tiny-GET hot path: END_STREAM closes the stream — skip stream WINDOW_UPDATE + // and do not force-flush connection credit (was one WINDOW_UPDATE pair per + // ~56 B response; profiled ~6% in GrantReceiveCredit). + if (length > 0) + pendingConnectionReceiveCredit += length; + pendingStreamReceiveCredit.Remove(creditStreamId); + if (pendingConnectionReceiveCredit >= ReceiveCreditBatchThreshold) + { + var connBytes = pendingConnectionReceiveCredit; + pendingConnectionReceiveCredit = 0; + await GrantReceiveCreditLockedAsync(0, connBytes, 0); + } + } + else + { + await GrantReceiveCreditAsync(creditStreamId, length, forceFlush: false); + } + + Http2FrameWriter? dedicatedWriter = null; + if (isClient && connectionState.OriginRelayPool != null + && connectionState.OriginRelayPool.TryGetAssignment(dataStreamId, out var assignment)) + { + var wireStreamId = assignment.OriginStreamId; + dedicatedWriter = assignment.Leg.Writer; + await assignment.Leg.SendFlow + .ReserveAsync(wireStreamId, length, cancellationToken) + .ConfigureAwait(false); + frameHeader.StreamId = wireStreamId; + frameHeaderBuffer[5] = (byte)((wireStreamId >> 24) & 0x7f); + frameHeaderBuffer[6] = (byte)((wireStreamId >> 16) & 0xff); + frameHeaderBuffer[7] = (byte)((wireStreamId >> 8) & 0xff); + frameHeaderBuffer[8] = (byte)(wireStreamId & 0xff); + } + else if (!isClient && originReceiveLeg != null) + { + dedicatedWriter = connectionState.ClientFrameWriter; + await outboundFlow.ReserveAsync(dataStreamId, length, cancellationToken); + } + else + { + await outboundFlow.ReserveAsync(dataStreamId, length, cancellationToken); + } + + var wireLen = 9 + length; + var rented = ArrayPool.Shared.Rent(wireLen); + frameHeader.CopyToBuffer(rented); + if (length > 0 && !await intake.ReadExactAsync(rented, 9, length, cancellationToken)) + { + ArrayPool.Shared.Return(rented); + await TrySendGracefulGoAwayAsync(); + return; + } + + if (dedicatedWriter != null) + dedicatedWriter.EnqueueRented(rented, wireLen); + else + connectionState.EnqueueWriteRented(towardServer: isClient, outputWriteLock, output, + rented, wireLen); + + if (dataEndStream + && connectionState.Streams.TryGetValue(dataStreamId, out var closingCompressed)) + { + if (isClient) + closingCompressed.RequestClosed = true; + else + closingCompressed.ResponseClosed = true; + + if (closingCompressed.IsClosed) + { + connectionState.OriginRelayPool?.ReleaseStream(dataStreamId); + connectionState.RemoveStream(dataStreamId); + ScheduleFinalize(closingCompressed, onAfterResponse, logger, connectionState); + } + } + + continue; + } + + // Not compressed-relay DATA: restore peer stream id so the shared remap below + // can apply OriginToClient after the payload is read into `buffer`. + if (originReceiveLeg != null && peerStreamId != 0) + streamId = peerStreamId; + } + + if (length > 0 && !await intake.ReadExactAsync(buffer, 0, length, cancellationToken)) + { + await TrySendGracefulGoAwayAsync(); + return; + } + + if (originReceiveLeg != null && peerStreamId != 0) + { + if (!originReceiveLeg.OriginToClient.TryGetValue(peerStreamId, out var clientStreamId)) + { + if (type == Http2FrameType.Data) + await GrantReceiveCreditAsync(peerStreamId, length, forceFlush: true); + + if (type is Http2FrameType.Data or Http2FrameType.Headers or Http2FrameType.RstStream + or Http2FrameType.Continuation or Http2FrameType.Priority) + { + await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + peerStreamId, Http2ErrorCode.StreamClosed, input)); + } + + continue; + } + + streamId = clientStreamId; + frameHeader.StreamId = streamId; + frameHeaderBuffer[5] = (byte)((streamId >> 24) & 0x7f); + frameHeaderBuffer[6] = (byte)((streamId >> 16) & 0xff); + frameHeaderBuffer[7] = (byte)((streamId >> 8) & 0xff); + frameHeaderBuffer[8] = (byte)(streamId & 0xff); + } + if (type == Http2FrameType.PushPromise) + { + // This proxy always advertises SETTINGS_ENABLE_PUSH=0 toward the server (see the + // SETTINGS handling below), so a PUSH_PROMISE is never valid in either direction: from + // the client it is always meaningless (clients don't push), and from the server it is a + // direct violation of the value we declared (RFC 7540 ?6.6: "PUSH_PROMISE MUST NOT be + // sent if SETTINGS_ENABLE_PUSH... is 0"). Reject as a connection-level PROTOCOL_ERROR + // rather than attempting to decode/relay it: this relay's decoder for this direction + // never observes the encode event a forwarded-but-undecoded push header block would + // represent, which would otherwise permanently desync HPACK for every later header + // block from the same peer. Tearing down the whole connection avoids that risk entirely. + ReportException(logger, new ProxyHttpException( + $"HTTP/2 protocol error: unexpected PUSH_PROMISE frame from the {(isClient ? "client" : "server")}.", + null, null)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, + Http2ErrorCode.ProtocolError, input)); + return; + } + + bool sendPacket = true; + bool endStream = false; + + SessionEventArgs? args = null; + RequestResponseBase? rr = null; + Http2StreamState? existingStreamState = null; + if ((type == Http2FrameType.Data || type == Http2FrameType.Headers) && + connectionState.Streams.TryGetValue(streamId, out existingStreamState)) + { + args = existingStreamState.SessionArgs; + } + + // Request DATA must not be routed before the stream's BeforeRequest dispatch has finished: + // the dispatch task (thread-pool since the HEADERS decode was decoupled from handler + // execution) is what marks bridge/synthetic streams (syntheticStreams, Http2IgnoreBodyFrames). + // DATA racing past it falls through to the default relay and reserves send-window credit + // toward the origin leg - which for bridge connections is a NullOriginStream that never + // grants WINDOW_UPDATE, permanently leaking the 64 KiB connection window and deadlocking the + // whole frame loop in ReserveAsync (uploads and every response writer stall together). The + // The dispatch completes even when the user handler is still waiting on the request body + // (ReadHttp2BeforeHandlerTaskCompletionSource unblocks it), so awaiting here cannot deadlock. + // The END_STREAM/SendBody path below already relies on the same contract. + if (isClient && type == Http2FrameType.Data + && args?.HttpClient.Request.Http2BeforeHandlerTask is { IsCompleted: false } dataDispatch) + { + await dataDispatch; + } + + if (type == Http2FrameType.Data && existingStreamState == null) + { + // DATA is flow-controlled at the connection level even when it arrives + // for an already-closed stream. Return that connection credit, then reject + // the frame locally instead of relaying it to the other leg. + await GrantReceiveCreditAsync(streamId, length, forceFlush: true); + + bool isIdleStream = streamId > connectionState.LastClientStreamId || (streamId & 1) == 0; + if (isIdleStream) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: DATA frame received for an idle stream.", null, null)); + await lockedOwnLegWrite(() => SendGoAwayAsync( + new Http2FrameHeader(), new byte[9], connectionState.LastClientStreamId, + Http2ErrorCode.ProtocolError, input)); + return; + } + + await lockedOwnLegWrite(() => SendRstStreamAsync( + new Http2FrameHeader(), new byte[9], streamId, Http2ErrorCode.StreamClosed, input)); + continue; + } + + // HEADERS/CONTINUATION must always be decoded - even for a stream already answered + // synthetically - because HPACK's dynamic table is connection-scoped: skipping the decode + // of any header block silently desyncs this connection's decoder from the peer's encoder + // for every subsequent stream. Suppressing the *forward* of a synthetic stream's trailers + // is handled inside ProcessCompleteHeaderBlockAsync instead of the blanket synthetic-stream + // gate used for other frame types below, so both are checked ahead of that gate. + if (type == Http2FrameType.Headers) + { + bool endHeaders = (flags & Http2FrameFlag.EndHeaders) != 0; + bool padded = (flags & Http2FrameFlag.Padded) != 0; + bool priority = (flags & Http2FrameFlag.Priority) != 0; + bool endStreamFlag = (flags & Http2FrameFlag.EndStream) != 0; + + int offset = 0; + int padLength = 0; + if (padded) + { + padLength = buffer[0]; + offset = 1; + } + + bool compressedRelayHeaders = useCompressedRelay + && (existingStreamState == null || existingStreamState.IsCompressedRelay); + + if (compressedRelayHeaders) + { + if (existingStreamState == null) + { + if (isClient) + { + if (streamId % 2 == 0 || streamId <= connectionState.LastClientStreamId) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: invalid client stream id on compressed-relay HEADERS.", + null, null)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], + connectionState.LastClientStreamId, Http2ErrorCode.ProtocolError, input)); + return; + } + + connectionState.LastClientStreamId = streamId; + } + + if (connectionState.ServerGoingAway && + streamId > connectionState.ServerLastStreamId) + { + await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + streamId, Http2ErrorCode.RefusedStream, input)); + continue; + } + + if (isClient && connectionState.ClientResetBudgetExceeded) + { + await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + streamId, Http2ErrorCode.RefusedStream, input)); + continue; + } + + existingStreamState = connectionState.RegisterCompressedRelayStream(streamId); + if (connectionState.Streams.Count > remoteSettings.MaxConcurrentStreams) + { + RemoveAndFinalizeStream(streamId); + await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + streamId, Http2ErrorCode.RefusedStream, input)); + continue; + } + } + + if (priority) + offset += 5; + + int fragmentLength = length - offset - padLength; + if (fragmentLength < 0) + fragmentLength = 0; + + if (pendingHeaderBlock != null) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: HEADERS frame received while a previous header block on this connection was still open.", + null, null)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], + pendingHeaderStreamId, Http2ErrorCode.ProtocolError, input)); + return; + } + + if (endHeaders) + { + var fragment = new byte[fragmentLength]; + Buffer.BlockCopy(buffer, offset, fragment, 0, fragmentLength); + await RelayCompressedHeaderBlockAsync( + connectionState, input, output, outputWriteLock, ownLegWriteLock, originReceiveLeg, + compressedRelaySchemeOverride, isClient, cancellationToken, hpack, remoteSettings, + maxDecodedHeaderListBytes, logger, removeAndFinalizeStream, + streamId, fragment, endStreamFlag); + if (endStreamFlag) + endStream = true; + } + else + { + pendingHeaderBlock = new MemoryStream(); + await pendingHeaderBlock.WriteAsync(buffer.AsMemory(offset, fragmentLength), + cancellationToken); + pendingHeaderStreamId = streamId; + pendingHeaderArgs = null; + pendingHeaderRr = null; + pendingHeaderEndStream = endStreamFlag; + pendingHeaderIsPromise = false; + pendingCompressedRelay = true; + pendingHeaderBlockFrameCount = 1; + pendingHeaderBlockOpenedAt = Environment.TickCount64; + } + + sendPacket = false; + } + else + { + if (args == null) + { + args = sessionFactory(); + // Gate off: every stream on this connection uses the fast-forward path. + // When the gate is on, IsFastPath may still be set per-stream after HEADERS decode + // once :authority / method / path are known (predicate evaluation below). + if (!httpInterceptionEnabled) + args.IsFastPath = true; + connectionState.RegisterStream(streamId, args); + } + + rr = isClient ? (RequestResponseBase)args.HttpClient.Request : args.HttpClient.Response; + if (priority) + { + var priorityData = ((long)buffer[offset++] << 32) + ((long)buffer[offset++] << 24) + + (buffer[offset++] << 16) + (buffer[offset++] << 8) + buffer[offset++]; + rr.Priority = priorityData; + } + + int fragmentLength = length - offset - padLength; + if (fragmentLength < 0) + { + fragmentLength = 0; + } + + if (pendingHeaderBlock != null) + { + // RFC 7540 ?6.10: only a CONTINUATION frame for the same stream may follow a + // HEADERS frame sent without END_HEADERS. Anything else while a block is still + // open (including a new HEADERS frame) is a connection-level PROTOCOL_ERROR. + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: HEADERS frame received while a previous header block on this connection was still open.", + null, args)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], + pendingHeaderStreamId, Http2ErrorCode.ProtocolError, input)); + return; + } + + if (endHeaders) + { + var fragment = new byte[fragmentLength]; + Buffer.BlockCopy(buffer, offset, fragment, 0, fragmentLength); + bool isInterim = await ProcessCompleteHeaderBlockAsync( + connectionState, input, output, outputWriteLock, ownLegWriteLock, originReceiveLeg, + compressedRelaySchemeOverride, isClient, cancellationToken, hpack, remoteSettings, + maxDecodedHeaderListBytes, logger, removeAndFinalizeStream, + lockedOutputWriteFn, forceStaticHpackTable, localSettings, headerDecodeScratch, + headerDecodeListener, syntheticStreams, pendingSynthetics, frameHeader, frameHeaderBuffer, + onBeforeRequestResponse, onAfterResponse, prepareRequestHeaders, enableRfc8441, + originConnection, httpInterceptionEnabled, shouldInterceptHttp, + streamId, args, rr, fragment, endStreamFlag, args.IsPromise); + if (endStreamFlag && !isInterim) + { + endStream = true; + + // Matches HTTP/1.x's RequestSentAt timing mark for the client leg, and finalizes + // timing for the response leg (see MarkComplete's remarks on OnAfterResponse - + // this is normally called again there too, but the guard there makes that a + // no-op, so CompletedAt reflects this earlier, more precise instant instead) for + // the common single-frame (no CONTINUATION needed) no-body/trailer-terminated case. + if (isClient) args.Timing?.MarkRequestSent(); + else args.Timing?.MarkComplete(); + } + } + else + { + // start of a multi-frame header block; buffer this fragment and wait for the + // CONTINUATION frame(s) that must immediately follow on the same stream. + pendingHeaderBlock = new MemoryStream(); + await pendingHeaderBlock.WriteAsync(buffer.AsMemory(offset, fragmentLength), cancellationToken); + pendingHeaderStreamId = streamId; + pendingHeaderArgs = args; + pendingHeaderRr = rr; + pendingHeaderEndStream = endStreamFlag; + pendingHeaderIsPromise = args.IsPromise; + pendingCompressedRelay = false; + pendingHeaderBlockFrameCount = 1; + pendingHeaderBlockOpenedAt = Environment.TickCount64; + } + + sendPacket = false; + } + } + else if (type == Http2FrameType.Continuation) + { + if (pendingHeaderBlock == null || pendingHeaderStreamId != streamId) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: unexpected CONTINUATION frame.", null, args)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, + Http2ErrorCode.ProtocolError, input)); + return; + } + + if (pendingHeaderBlock.Length + length > MaxHeaderBlockBytes) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 header block exceeded the maximum allowed compressed size.", null, + pendingHeaderArgs)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, + Http2ErrorCode.EnhanceYourCalm, input)); + return; + } + + // Frame-count and wall-clock bound: a zero-length CONTINUATION never advances + // pendingHeaderBlock.Length above, so the byte cap alone cannot bound an attacker + // that never sets END_HEADERS and sends an endless sequence of empty CONTINUATION + // frames (or paces non-empty ones just slowly enough to never look byte-abusive). + pendingHeaderBlockFrameCount++; + var openMillis = Environment.TickCount64 - pendingHeaderBlockOpenedAt; + var http2AbuseMode = pendingHeaderArgs?.Server.PolicyModes[PolicyFamily.Http2AbuseBudget] + ?? PolicyMode.Enforce; + var continuationBudgetBreached = http2AbuseMode != PolicyMode.Disabled && + (pendingHeaderBlockFrameCount > resourceLimits.MaxOpenHeaderBlockFrames || + openMillis > resourceLimits.MaxOpenHeaderBlockDuration.TotalMilliseconds); + + if (continuationBudgetBreached) + { + ProxyMetrics.PolicyBreach(PolicyFamily.Http2AbuseBudget, http2AbuseMode); + + // Enforce-only reaction: Observe records the breach (above) but must not tear + // down the connection, since the whole point of Observe is measuring what a + // stricter mode would have caught without acting on it yet. + if (http2AbuseMode == PolicyMode.Enforce) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 header block exceeded the maximum allowed CONTINUATION frame count or " + + "stayed open too long - possible CONTINUATION flood.", null, pendingHeaderArgs)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], + streamId, Http2ErrorCode.EnhanceYourCalm, input)); + return; + } + } + + await pendingHeaderBlock.WriteAsync(buffer.AsMemory(0, length), cancellationToken); + + if ((flags & Http2FrameFlag.EndHeaders) != 0) + { + var completeBlock = pendingHeaderBlock.ToArray(); + var pStreamId = pendingHeaderStreamId; + var pArgs = pendingHeaderArgs; + var pRr = pendingHeaderRr; + var pEndStream = pendingHeaderEndStream; + var pIsPromise = pendingHeaderIsPromise; + var pCompressedRelay = pendingCompressedRelay; + + pendingHeaderBlock = null; + pendingHeaderArgs = null; + pendingHeaderRr = null; + pendingHeaderStreamId = -1; + pendingHeaderBlockFrameCount = 0; + pendingHeaderBlockOpenedAt = 0; + pendingCompressedRelay = false; + + args = pArgs; + rr = pRr; + + if (pCompressedRelay) + { + await RelayCompressedHeaderBlockAsync( + connectionState, input, output, outputWriteLock, ownLegWriteLock, originReceiveLeg, + compressedRelaySchemeOverride, isClient, cancellationToken, hpack, remoteSettings, + maxDecodedHeaderListBytes, logger, removeAndFinalizeStream, + pStreamId, completeBlock, pEndStream); + if (pEndStream) + endStream = true; + } + else + { + bool isInterim = await ProcessCompleteHeaderBlockAsync( + connectionState, input, output, outputWriteLock, ownLegWriteLock, originReceiveLeg, + compressedRelaySchemeOverride, isClient, cancellationToken, hpack, remoteSettings, + maxDecodedHeaderListBytes, logger, removeAndFinalizeStream, + lockedOutputWriteFn, forceStaticHpackTable, localSettings, headerDecodeScratch, + headerDecodeListener, syntheticStreams, pendingSynthetics, frameHeader, frameHeaderBuffer, + onBeforeRequestResponse, onAfterResponse, prepareRequestHeaders, enableRfc8441, + originConnection, httpInterceptionEnabled, shouldInterceptHttp, + pStreamId, pArgs!, pRr!, completeBlock, pEndStream, pIsPromise); + if (pEndStream && !isInterim) + { + endStream = true; + + // Matches HTTP/1.x's RequestSentAt/MarkComplete timing marks (see + // RequestHandler.HandleHttpSessionRequest / ResponseHandler.OnAfterResponse) + // for the no-body (headers-only END_STREAM, or trailer-terminated) case; the + // with-body case is stamped where the terminating DATA frame is handled below. + if (isClient) pArgs!.Timing?.MarkRequestSent(); + else pArgs!.Timing?.MarkComplete(); + } + } + } + + sendPacket = false; + } + else if (type == Http2FrameType.Data && existingStreamState?.IsCompressedRelay == true) + { + // Passthrough: grant receive credit and forward the frame unchanged (no body API). + bool dataEndStream = (flags & Http2FrameFlag.EndStream) != 0; + var creditStreamId = originReceiveLeg != null ? peerStreamId : streamId; + await GrantReceiveCreditAsync(creditStreamId, length, forceFlush: dataEndStream); + if (dataEndStream) + endStream = true; + // sendPacket remains true + } + else if (isClient && syntheticStreams.ContainsKey(streamId) + && type != Http2FrameType.WindowUpdate + && type != Http2FrameType.RstStream) + { + // This stream was answered with a synthetic / external-bridge response; never forward + // its request frames upstream. WINDOW_UPDATE and RST_STREAM must still fall through: + // EmitSyntheticResponseAsync / RespondStreaming write DATA toward the client under + // ClientSendFlow, which is replenished only by stream-level WINDOW_UPDATE from the + // client. Swallowing those frames stalls every synthetic body larger than the default + // 64 KiB stream window (.NET HttpClient, browsers, etc.). + sendPacket = false; + + if (type == Http2FrameType.Data) + { + bool dataEndStream = (flags & Http2FrameFlag.EndStream) != 0; + await GrantReceiveCreditAsync(streamId, length, forceFlush: dataEndStream); + + // External-bridge streaming: pump DATA into InboundRequestBodyChannel instead of + // discarding it. Create the channel in onBeforeRequest before returning. + if (connectionState.Streams.TryGetValue(streamId, out var synthState) + && synthState.InboundRequestBodyChannel != null + && args != null + && !args.HttpClient.Request.Http2IgnoreBodyFrames) + { + GetHttp2PaddedDataRange(buffer, length, (flags & Http2FrameFlag.Padded) != 0, + out int dataOff, out int dataLen); + if (dataLen > 0) + { + var rented = ArrayPool.Shared.Rent(dataLen); + Buffer.BlockCopy(buffer, dataOff, rented, 0, dataLen); + // TryWrite only — never await on the frame loop (HOL for every stream + // on this connection). Bound is large; full means the origin pump stalled. + if (!synthState.InboundRequestBodyChannel.Writer.TryWrite((rented, dataLen))) + { + ArrayPool.Shared.Return(rented); + ReportException(logger, new ProxyHttpException( + "HTTP/2 bridge stream exceeded its bounded request-body buffer.", + null, args)); + RemoveAndFinalizeStream(streamId); + await lockedOwnLegWrite(() => SendRstStreamAsync( + new Http2FrameHeader(), new byte[9], streamId, + Http2ErrorCode.EnhanceYourCalm, input)); + } + } + + if (dataEndStream) + { + endStream = true; + synthState.InboundRequestBodyChannel.Writer.TryComplete(); + } + + rr = args.HttpClient.Request; + } + } + } + else if (type == Http2FrameType.Data && args != null) + { + // Grant back the credit consumed by reading this frame's on-wire payload before doing + // anything else with it. Batched at ReceiveCreditBatchThreshold; flushed on END_STREAM. + bool dataEndStream = (flags & Http2FrameFlag.EndStream) != 0; + await GrantReceiveCreditAsync(streamId, length, forceFlush: dataEndStream); + + connectionState.Streams.TryGetValue(streamId, out var dataStreamState); + + // RFC 8441 h2→h1 bridge: route frame payload directly to the per-stream channel + // rather than the normal body-buffering path. The channel is created by + // BridgeOnBeforeRequest before the tunnel task starts, so it is always populated + // before the first DATA frame for the stream can be processed here. + if (isClient + && dataStreamState?.IsExtendedConnect == true + && dataStreamState.InboundTunnelChannel != null) + { + bool endStreamFlag = (flags & Http2FrameFlag.EndStream) != 0; + GetHttp2PaddedDataRange(buffer, length, (flags & Http2FrameFlag.Padded) != 0, + out int dataOff, out int dataLen); + if (dataLen > 0) + { + var chunk = new byte[dataLen]; + Buffer.BlockCopy(buffer, dataOff, chunk, 0, dataLen); + if (!dataStreamState.InboundTunnelChannel.Writer.TryWrite( + new ReadOnlyMemory(chunk))) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 extended CONNECT stream exceeded its bounded relay buffer.", + null, args)); + RemoveAndFinalizeStream(streamId); + await lockedOwnLegWrite(() => SendRstStreamAsync( + new Http2FrameHeader(), new byte[9], streamId, + Http2ErrorCode.EnhanceYourCalm, input)); + } + } + if (endStreamFlag) + { + endStream = true; + dataStreamState.InboundTunnelChannel.Writer.TryComplete(); + } + + rr = args.HttpClient.Request; // required for the endStream cleanup block below + sendPacket = false; + } + else if (dataStreamState?.IsExtendedConnect == true + && dataStreamState.InboundTunnelChannel == null + && (isClient || dataStreamState.ExtendedConnectEstablished)) + { + // RFC 8441 native h2↔h2 tunnel: relay DATA unchanged, fire events with the + // unpadded payload bytes only, and bypass HTTP body buffering and mutation hooks. + bool endStreamFlag = (flags & Http2FrameFlag.EndStream) != 0; + bool padded = (flags & Http2FrameFlag.Padded) != 0; + int payloadOff = padded ? 1 : 0; + int padLen = padded ? buffer[0] : 0; + int payloadLen = length - payloadOff - padLen; + if (payloadLen < 0) payloadLen = 0; + + // Reject DATA from a direction whose half is already closed (RFC 9113 §6.9). + bool halfClosed = isClient + ? dataStreamState.RequestClosed + : dataStreamState.ResponseClosed; + if (halfClosed) + { + ReportException(logger, new ProxyHttpException( + $"HTTP/2 protocol error: DATA received on a half-closed ({(isClient ? "local" : "remote")}) stream.", + null, args)); + await lockedOwnLegWrite(() => SendRstStreamAsync( + new Http2FrameHeader(), new byte[9], streamId, + Http2ErrorCode.StreamClosed, input)); + sendPacket = false; + } + else + { + if (isClient) + args.OnDataSent(buffer, payloadOff, payloadLen); + else + args.OnDataReceived(buffer, payloadOff, payloadLen); + + if (endStreamFlag) + { + endStream = true; + if (isClient) args.Timing?.MarkRequestSent(); + else args.Timing?.MarkComplete(); + } + } + + rr = isClient ? (RequestResponseBase)args.HttpClient.Request : args.HttpClient.Response; + // sendPacket remains true: forward the raw frame unchanged. + } + else + { + if (isClient) + args.OnDataSent(buffer, 0, length); + else + args.OnDataReceived(buffer, 0, length); + + rr = isClient ? (RequestResponseBase)args.HttpClient.Request : args.HttpClient.Response; + + bool padded = (flags & Http2FrameFlag.Padded) != 0; + bool endStreamFlag = (flags & Http2FrameFlag.EndStream) != 0; + if (endStreamFlag) + { + endStream = true; + + // Matches HTTP/1.x's RequestSentAt/MarkComplete timing marks for the with-body case + // (the headers-only/trailer-terminated case is stamped above). + if (isClient) args.Timing?.MarkRequestSent(); + else args.Timing?.MarkComplete(); + } + + // HTTP/2 multipart/form-data boundary-aware streaming observation (purely observational). + if (isClient && args.HasMulipartEventSubscribers && + args.HttpClient.Request.IsMultipartFormData) + { + var mpContentType = args.HttpClient.Request.ContentType; + if (mpContentType != null) + { + if (!connectionState.MultipartObservers.TryGetValue(streamId, out var mpObserver)) + { + var mpBoundaryMemory = HttpHelper.GetBoundaryFromContentType(mpContentType); + var mpBoundary = mpBoundaryMemory.IsEmpty + ? string.Empty + : mpBoundaryMemory.ToString(); + var newObserver = MultipartStreamObserver.TryCreate( + mpContentType, + headers => args.OnMultipartRequestPartSent(mpBoundary.AsSpan(), headers), + null); + if (newObserver != null) + { + connectionState.MultipartObservers.TryAdd(streamId, newObserver); + mpObserver = newObserver; + } + } + + if (mpObserver != null) + { + int mpOffset = padded ? 1 : 0; + int mpLength = padded ? length - 1 - buffer[0] : length; + if (mpLength < 0) mpLength = 0; + if (mpLength > 0) + mpObserver.Observe(new ReadOnlySpan(buffer, mpOffset, mpLength)); + } + } + } + + if (rr.Http2IgnoreBodyFrames) + { + sendPacket = false; + } + + if (rr.ReadHttp2BodyTaskCompletionSource != null) + { + // Get body method was called in the "before" event handler + + var data = rr.Http2BodyData; + int offset = 0; + if (padded) + { + offset++; + length--; + length -= buffer[0]; + } + + if (data == null) + throw new InvalidOperationException("HTTP/2 body buffering was requested without a buffer."); + + // Native H2 whole-body buffering (BeforeRequest/BeforeResponse called + // GetRequestBody/GetResponseBody) has no cumulative cap of its own: each DATA + // frame is already bounded by SETTINGS_MAX_FRAME_SIZE, but per-frame limits are + // not cumulative limits, so a peer sending enough frames could otherwise grow + // this MemoryStream unbounded. Mirrors the extended-CONNECT relay-buffer-exceeded + // handling just above: abort only this stream (not the whole connection), and + // fault the waiting body-read task so ReadRequestBodyAsync/ReadResponseBodyAsync + // surfaces BodySizeLimitExceededException instead of hanging forever. + var maxBufferedBodyBytes = args.MaxBufferedBodyBytes ?? args.Server.MaxBufferedBodyBytes; + var bodyBudgetMode = args.Server.PolicyModes[PolicyFamily.BodyBudget]; + var bodyBudgetBreached = bodyBudgetMode != PolicyMode.Disabled && + maxBufferedBodyBytes > 0 && + data.Length + length > maxBufferedBodyBytes; + + if (bodyBudgetBreached) ProxyMetrics.PolicyBreach(PolicyFamily.BodyBudget, bodyBudgetMode); + + if (bodyBudgetBreached && bodyBudgetMode == PolicyMode.Enforce) + { + // Intentional policy enforcement, not a proxy defect — Debug only. + ProxyDiagnostics.ReportBenign(logger, + $"HTTP/2 {(isClient ? "request" : "response")} body exceeded the configured " + + $"buffering limit of {maxBufferedBodyBytes:N0} bytes.", + new ProxyHttpException( + $"HTTP/2 {(isClient ? "request" : "response")} body exceeded the configured " + + $"buffering limit of {maxBufferedBodyBytes:N0} bytes.", null, args)); + + var sizeLimitException = new BodySizeLimitExceededException( + $"HTTP/2 body byte count {data.Length + length:N0} exceeds the limit of {maxBufferedBodyBytes:N0}."); + + var pendingTcs = rr.ReadHttp2BodyTaskCompletionSource; + rr.ReadHttp2BodyTaskCompletionSource = null; + pendingTcs.TrySetException(sizeLimitException); + + if (rr.Http2BodyData != null) await rr.Http2BodyData.DisposeAsync(); + rr.Http2BodyData = null; + + RemoveAndFinalizeStream(streamId); + await lockedOwnLegWrite(() => SendRstStreamAsync( + new Http2FrameHeader(), new byte[9], streamId, + Http2ErrorCode.EnhanceYourCalm, input)); + sendPacket = false; + } + else + { + // Disabled, or Observe: the breach (if any) was already recorded above, but + // the stream is not reset and the caller's whole-body read is not faulted - + // per the plan, Observe detects without acting. + await data.WriteAsync(buffer.AsMemory(offset, length), cancellationToken); + } + } + else if (!args.IsFastPath && !rr.Http2IgnoreBodyFrames && !rr.IsBodyRead && + (isClient + ? args.Server.ShouldCallBeforeRequestBodyWrite() + : args.Server.ShouldCallBeforeResponseBodyWrite())) + { + // per-DATA-frame inspection/modification hook (streams without buffering the whole body) + int dataOffset = 0; + int dataLength = length; + if (padded) + { + var padLength = buffer[0]; + dataOffset = 1; + dataLength = length - 1 - padLength; + if (dataLength < 0) dataLength = 0; + } + + var dataBytes = new byte[dataLength]; + Buffer.BlockCopy(buffer, dataOffset, dataBytes, 0, dataLength); + + var bodyWriteArgs = new BeforeBodyWriteEventArgs(args, dataBytes, true, endStreamFlag); + if (isClient) + await args.Server.OnBeforeRequestBodyWrite(bodyWriteArgs); + else + await args.Server.OnBeforeResponseBodyWrite(bodyWriteArgs); + + var outBytes = bodyWriteArgs.BodyBytes ?? Array.Empty(); + + // Reserve outside outputWriteLock — same ordering as the default DATA relay above. + await SendData(frameHeader, frameHeaderBuffer, streamId, outBytes, + endStreamFlag, remoteSettings.MaxFrameSize, outboundFlow, output, cancellationToken, + outputWriteLock); + + // we have emitted our own (possibly re-sized) DATA frame(s); suppress the default relay + sendPacket = false; + } + } + } + else if (type == Http2FrameType.WindowUpdate) + { + sendPacket = false; + + if (length != 4) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: WINDOW_UPDATE frame with invalid length.", null, args)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, + Http2ErrorCode.FrameSizeError, input)); + return; + } + + int increment = ReadHttp2UInt31(buffer); + if (increment == 0) + { + // RFC 7540 ?6.9.1: a zero increment is a stream error (or connection error if + // stream id 0) of type PROTOCOL_ERROR. + if (streamId == 0) + { + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], 0, + Http2ErrorCode.ProtocolError, input)); + return; + } + + await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + peerStreamId, Http2ErrorCode.ProtocolError, input)); + } + else + { + // Multi-origin: each origin leg has its own send window (peer ids). + Http2FlowController flow; + if (originReceiveLeg != null) + flow = originReceiveLeg.SendFlow; + else if (isClient) + flow = connectionState.ClientSendFlow; + else + flow = connectionState.ServerSendFlow; + var flowStreamId = originReceiveLeg != null ? peerStreamId : streamId; + bool overflow = flow.OnWindowUpdate(flowStreamId, increment); + if (overflow) + { + // RFC 7540 ?6.9.1: a WINDOW_UPDATE that drives a flow-control window above + // 2^31-1 is a FLOW_CONTROL_ERROR - stream-level (RST_STREAM) for a stream + // window, connection-level (GOAWAY) for the connection window. + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: WINDOW_UPDATE increment overflowed the flow-control window.", + null, args)); + if (flowStreamId == 0) + { + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], 0, + Http2ErrorCode.FlowControlError, input)); + return; + } + + await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], + peerStreamId, Http2ErrorCode.FlowControlError, input)); + } + } + } + else if (type == Http2FrameType.Ping) + { + sendPacket = false; + + if (length != 8) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: PING frame with invalid length.", null, args)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, + Http2ErrorCode.FrameSizeError, input)); + return; + } + + if ((flags & Http2FrameFlag.Ack) == 0) + { + // terminate PING/PONG locally on the leg it arrived on rather than relaying it + // through to the other leg, which has no bearing on this leg's round trip. + var ackPayload = new byte[8]; + Buffer.BlockCopy(buffer, 0, ackPayload, 0, 8); + await lockedOwnLegWrite(async () => + { + // dedicated header/buffer - never the outer `frameHeader`/`frameHeaderBuffer`, + // which still holds this same PING frame's own metadata that the main loop below + // (harmlessly, since PING always suppresses the default relay) still references. + var pingFrameHeader = new Http2FrameHeader + { + StreamId = 0, Type = Http2FrameType.Ping, Flags = Http2FrameFlag.Ack, Length = 8 + }; + var pingFrameHeaderBuffer = new byte[9]; + pingFrameHeader.CopyToBuffer(pingFrameHeaderBuffer); + await input.WriteAsync(pingFrameHeaderBuffer.AsMemory(), cancellationToken); + await input.WriteAsync(ackPayload.AsMemory(0, 8), cancellationToken); + }); + } + // an ACK for a PING this proxy never sends today - nothing to do. + } + else if (type == Http2FrameType.GoAway) + { + // Overflow origin GOAWAY must not tear down the client session. + sendPacket = !suppressConnectionFrameRelay; + + if (length >= 8) + { + int lastStreamId = ReadHttp2UInt31(buffer); + if (isClient) + { + connectionState.ClientGoingAway = true; + connectionState.ClientLastStreamId = lastStreamId; + } + else if (!suppressConnectionFrameRelay) + { + connectionState.ServerGoingAway = true; + connectionState.ServerLastStreamId = lastStreamId; + } + + // unblock any stream-scoped waiter (synthetic response task, etc.) for streams the + // sender has already said it will not process, without tearing down the streams + // that are still permitted to drain. + if (!suppressConnectionFrameRelay) + { + foreach (var kvp in connectionState.Streams) + { + if (kvp.Key > lastStreamId) + { + connectionState.MultipartObservers.TryRemove(kvp.Key, out _); + // RFC 8441: unblock any tunnel relay waiting on the inbound channel + // so it can shut down promptly without waiting for more DATA frames + // that the peer has already said it will not send. + kvp.Value.InboundTunnelChannel?.Writer.TryComplete( + new IOException("Connection received GOAWAY.")); + await kvp.Value.Cancellation.CancelAsync(); + kvp.Value.Cancellation.Dispose(); + } + } + } + } + } + else if (type == Http2FrameType.Settings) + { + if (length % 6 != 0) + { + // https://httpwg.org/specs/rfc7540.html#SETTINGS + // 6.5. SETTINGS + // A SETTINGS frame with a length other than a multiple of 6 octets MUST be treated as a connection error (Section 5.4.1) of type FRAME_SIZE_ERROR + ReportException(logger, new ProxyHttpException("Invalid settings length", null, null)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, + Http2ErrorCode.FrameSizeError, input)); + return; + } + + if ((flags & Http2FrameFlag.Ack) != 0 && length != 0) + { + // RFC 7540 ?6.5: "Receipt of a SETTINGS frame with the ACK flag set and a length + // field value other than 0 MUST be treated as a connection error of type + // FRAME_SIZE_ERROR." + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: SETTINGS ACK frame with non-zero length.", null, null)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, + Http2ErrorCode.FrameSizeError, input)); + return; + } + + bool invalidSettings = false; + Http2ErrorCode invalidSettingsError = Http2ErrorCode.ProtocolError; + bool sawEnablePush = false; + bool sawEnableConnectProtocol = false; + bool sawMaxConcurrentStreams = false; + bool sawInitialWindowSize = false; + bool sawHeaderTableSize = false; + + int pos = 0; + while (pos < length) + { + int identifier = (buffer[pos] << 8) + buffer[pos + 1]; + int valueOffset = pos + 2; + long value = ((long)buffer[valueOffset] << 24) + (buffer[valueOffset + 1] << 16) + + (buffer[valueOffset + 2] << 8) + buffer[valueOffset + 3]; + pos += 6; + + if (identifier == (int)Http2SettingsId.HeaderTableSize) + { + sawHeaderTableSize = true; + if (forceStaticHpackTable) + { + // Force static-table-only so compressed HEADERS are interchangeable across legs. + localSettings.UpdateHeaderTableSize(0); + buffer[valueOffset] = 0; + buffer[valueOffset + 1] = 0; + buffer[valueOffset + 2] = 0; + buffer[valueOffset + 3] = 0; + if (logger.IsEnabled(LogLevel.Trace)) + logger.LogTrace( + "[h2 settings] SETTINGS_HEADER_TABLE_SIZE forced to 0 (compressed relay) from {Direction} (peer sent {Value})", + isClient ? "browser" : "origin", value); + } + else + { + localSettings.UpdateHeaderTableSize((int)value); + if (logger.IsEnabled(LogLevel.Trace)) + logger.LogTrace("[h2 settings] SETTINGS_HEADER_TABLE_SIZE={Value} from {Direction}", + value, isClient ? "browser" : "origin"); + } + } + else if (identifier == (int)Http2SettingsId.MaxFrameSize) + { + // RFC 7540 ?6.5.2: valid range is [2^14, 2^24-1]; below the minimum every + // implementation must support is a PROTOCOL_ERROR. + if (value < 16384 || value > 16777215) + { + invalidSettings = true; + invalidSettingsError = Http2ErrorCode.ProtocolError; + } + else + { + localSettings.MaxFrameSize = (int)value; + } + } + else if (identifier == (int)Http2SettingsId.InitialWindowSize) + { + // RFC 7540 ?6.5.2: valid range is [0, 2^31-1]; above that is a FLOW_CONTROL_ERROR. + if (value > Http2FlowController.MaxWindow) + { + invalidSettings = true; + invalidSettingsError = Http2ErrorCode.FlowControlError; + } + else + { + sawInitialWindowSize = true; + // this peer is telling us the initial send-window it grants us for streams + // we open toward it - i.e. it feeds the SEND flow controller for writes + // toward *this* peer, symmetrically with WINDOW_UPDATE above. + Http2FlowController flow; + if (originReceiveLeg != null) + flow = originReceiveLeg.SendFlow; + else if (isClient) + flow = connectionState.ClientSendFlow; + else + flow = connectionState.ServerSendFlow; + flow.OnInitialWindowSizeChanged((int)value); + + if (!suppressConnectionFrameRelay && value < ClientInitialStreamWindowSize) + { + // Raise only the stream window *advertised to the other leg* (wire rewrite), + // in both directions. Toward the client this lifts upload throughput; toward + // the origin it is required for liveness: receive credit is batched at + // ReceiveCreditBatchThreshold (384 KiB), so an origin left at the RFC-default + // 65,535 window (e.g. relayed from an HttpClient) stalls a >64 KiB response + // body waiting for a WINDOW_UPDATE the batching will never flush. + // Do not change the send flow controller above — that must reflect the + // peer's real grant for writes toward it. + var advertised = ClientInitialStreamWindowSize; + buffer[valueOffset] = (byte)((advertised >> 24) & 0xff); + buffer[valueOffset + 1] = (byte)((advertised >> 16) & 0xff); + buffer[valueOffset + 2] = (byte)((advertised >> 8) & 0xff); + buffer[valueOffset + 3] = (byte)(advertised & 0xff); + } + } + } + else if (identifier == (int)Http2SettingsId.MaxConcurrentStreams) + { + sawMaxConcurrentStreams = true; + var advertised = value > int.MaxValue ? int.MaxValue : (int)value; + + if (!isClient) + { + // This is the server's own SETTINGS frame, about to be relayed on toward + // the real client below. Consolidate what were previously two independent + // mechanisms (this origin-advertised value, admitted against verbatim at + // the isMainHeaders check, and Http2OriginConnection's separate + // proxy-owned concurrencyGate for the H1-to-H2 bridge) into one: clamp to + // the proxy-owned cap and rewrite the wire value so what the client is + // told matches what will actually be enforced. Not clamping the advertised + // value while still enforcing a lower one would let the client legitimately + // open a stream believing it is within budget, only for the proxy to refuse + // it - the PROTOCOL_ERROR-vs-REFUSED_STREAM ambiguity RFC 9113 §5.1.2 warns + // against. + var effective = Math.Min(advertised, resourceLimits.MaxConcurrentStreamsPerConnection); + localSettings.MaxConcurrentStreams = effective; + + buffer[valueOffset] = (byte)((effective >> 24) & 0xff); + buffer[valueOffset + 1] = (byte)((effective >> 16) & 0xff); + buffer[valueOffset + 2] = (byte)((effective >> 8) & 0xff); + buffer[valueOffset + 3] = (byte)(effective & 0xff); + } + else + { + // The client's own SETTINGS value governs server-initiated (push) stream + // admission, which this proxy always advertises as disabled (see the + // ENABLE_PUSH override below) - nothing to consolidate on this leg. + localSettings.MaxConcurrentStreams = advertised; + } + } + else if (identifier == (int)Http2SettingsId.EnablePush) + { + sawEnablePush = true; + if (isClient) + { + // This relay never implements server push translation, so the proxy must + // never let the server believe push is welcome on this connection - + // regardless of what the real client declared (most modern clients already + // send 0 here, but this must not depend on that). Overwrite in place before + // this frame is forwarded to the server below. + buffer[valueOffset] = 0; + buffer[valueOffset + 1] = 0; + buffer[valueOffset + 2] = 0; + buffer[valueOffset + 3] = 0; + } + } + else if (identifier == (int)Http2SettingsId.MaxHeaderListSize) + { + // RFC 7540 §6.5.2: advisory limit on the header list size this peer is willing + // to receive. Store it so outbound header encoding can respect the peer's limit. + localSettings.MaxHeaderListSize = value > int.MaxValue ? int.MaxValue : (int)value; + } + else if (identifier == (int)Http2SettingsId.EnableConnectProtocol) + { + // RFC 8441 §3: the proxy manages ENABLE_CONNECT_PROTOCOL independently per leg. + sawEnableConnectProtocol = true; + + // RFC 8441 §3: value MUST be 0 or 1; any other value is a connection error. + if ((value != 0 && value != 1) || + (!isClient && value == 0 && localSettings.EnableConnectProtocolEverSet)) + { + invalidSettings = true; + invalidSettingsError = Http2ErrorCode.ProtocolError; + } + else if (isClient) + { + // Suppress the client's ENABLE_CONNECT_PROTOCOL preference - do not relay + // it to the server; the proxy negotiates RFC 8441 with each leg independently. + buffer[valueOffset] = 0; + buffer[valueOffset + 1] = 0; + buffer[valueOffset + 2] = 0; + buffer[valueOffset + 3] = 0; + } + else + { + localSettings.EnableConnectProtocol = (value == 1); + if (value == 1) localSettings.EnableConnectProtocolEverSet = true; + + // Overwrite with what the proxy chooses to advertise to the client. + int wireValue = enableRfc8441 ? 1 : 0; + buffer[valueOffset] = 0; + buffer[valueOffset + 1] = 0; + buffer[valueOffset + 2] = 0; + buffer[valueOffset + 3] = (byte)wireValue; + if (wireValue == 1) + connectionState.DownstreamAdvertisedEnableConnect = true; + } + } + } + + if (invalidSettings) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: SETTINGS frame contained an out-of-range value.", null, null)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, + invalidSettingsError, input)); + return; + } + + if (isClient && !sawEnablePush && (flags & Http2FrameFlag.Ack) == 0 && + length + 6 <= buffer.Length) + { + // The client's SETTINGS frame did not declare SETTINGS_ENABLE_PUSH at all (its RFC + // default, 1, would otherwise apply) - append an explicit "disabled" entry before + // relaying this frame to the server, for the same reason as the override above. + buffer[length] = (byte)(((int)Http2SettingsId.EnablePush >> 8) & 0xff); + buffer[length + 1] = (byte)((int)Http2SettingsId.EnablePush & 0xff); + buffer[length + 2] = 0; + buffer[length + 3] = 0; + buffer[length + 4] = 0; + buffer[length + 5] = 0; + length += 6; + frameHeader.Length = length; + } + + if (forceStaticHpackTable && !sawHeaderTableSize && (flags & Http2FrameFlag.Ack) == 0 && + length + 6 <= buffer.Length) + { + // Peer omitted SETTINGS_HEADER_TABLE_SIZE (RFC default 4096). Inject 0 so the + // other leg encodes static-table-only and compressed blocks stay interchangeable. + localSettings.UpdateHeaderTableSize(0); + buffer[length] = (byte)(((int)Http2SettingsId.HeaderTableSize >> 8) & 0xff); + buffer[length + 1] = (byte)((int)Http2SettingsId.HeaderTableSize & 0xff); + buffer[length + 2] = 0; + buffer[length + 3] = 0; + buffer[length + 4] = 0; + buffer[length + 5] = 0; + length += 6; + frameHeader.Length = length; + } + + if (!isClient && !suppressConnectionFrameRelay && enableRfc8441 && !sawEnableConnectProtocol && + (flags & Http2FrameFlag.Ack) == 0 && length + 6 <= buffer.Length) + { + // The server's SETTINGS frame did not include ENABLE_CONNECT_PROTOCOL but the proxy + // is configured to accept RFC 8441 extended CONNECT from clients - inject + // SETTINGS_ENABLE_CONNECT_PROTOCOL=1 so the client knows extended CONNECT is available. + buffer[length] = (byte)(((int)Http2SettingsId.EnableConnectProtocol >> 8) & 0xff); + buffer[length + 1] = (byte)((int)Http2SettingsId.EnableConnectProtocol & 0xff); + buffer[length + 2] = 0; + buffer[length + 3] = 0; + buffer[length + 4] = 0; + buffer[length + 5] = 1; + length += 6; + frameHeader.Length = length; + connectionState.DownstreamAdvertisedEnableConnect = true; + } + + if (!suppressConnectionFrameRelay && !sawInitialWindowSize && (flags & Http2FrameFlag.Ack) == 0 && + length + 6 <= buffer.Length) + { + // Peer omitted SETTINGS_INITIAL_WINDOW_SIZE (RFC default 65535). Inject the + // 768 KiB stream window onto the wire toward the other leg — required toward + // the origin for the same batched-receive-credit liveness reason as the in-place + // rewrite above. + var window = ClientInitialStreamWindowSize; + buffer[length] = (byte)(((int)Http2SettingsId.InitialWindowSize >> 8) & 0xff); + buffer[length + 1] = (byte)((int)Http2SettingsId.InitialWindowSize & 0xff); + buffer[length + 2] = (byte)((window >> 24) & 0xff); + buffer[length + 3] = (byte)((window >> 16) & 0xff); + buffer[length + 4] = (byte)((window >> 8) & 0xff); + buffer[length + 5] = (byte)(window & 0xff); + length += 6; + frameHeader.Length = length; + } + + if (!isClient && !suppressConnectionFrameRelay && !sawMaxConcurrentStreams && + resourceLimits.MaxConcurrentStreamsPerConnection < int.MaxValue && + (flags & Http2FrameFlag.Ack) == 0 && length + 6 <= buffer.Length) + { + // The server's SETTINGS frame did not declare SETTINGS_MAX_CONCURRENT_STREAMS at + // all (its RFC default, unbounded, would otherwise apply) - append an explicit + // entry advertising the proxy-owned cap before relaying this frame to the client, + // for the same "advertised must equal enforced" reason as the in-place overwrite + // above. + var effective = resourceLimits.MaxConcurrentStreamsPerConnection; + localSettings.MaxConcurrentStreams = effective; + + buffer[length] = (byte)(((int)Http2SettingsId.MaxConcurrentStreams >> 8) & 0xff); + buffer[length + 1] = (byte)((int)Http2SettingsId.MaxConcurrentStreams & 0xff); + buffer[length + 2] = (byte)((effective >> 24) & 0xff); + buffer[length + 3] = (byte)((effective >> 16) & 0xff); + buffer[length + 4] = (byte)((effective >> 8) & 0xff); + buffer[length + 5] = (byte)(effective & 0xff); + length += 6; + frameHeader.Length = length; + } + + if (suppressConnectionFrameRelay) + sendPacket = false; + } + + if (type == Http2FrameType.RstStream) + { + if (length != 4) + { + ReportException(logger, new ProxyHttpException( + "HTTP/2 protocol error: RST_STREAM frame with invalid length.", null, args)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, + Http2ErrorCode.FrameSizeError, input)); + return; + } + + int errorCode = ReadHttp2ErrorCode(buffer); + + // stream error: cancel any waiter/synthetic task scoped to this stream and stop tracking + // its flow-control windows and session mapping - regardless of the error code, the + // stream is now closed. + // Only remove the multipart observer when the RST came from the client: the observer + // is scoped to the client-side DATA stream and must survive an origin RST_STREAM so + // that any already-received client DATA frames can still finish firing their events. + // (An origin RST_STREAM with NO_ERROR is a normal post-response cleanup by servers + // by strict server stacks; removing the observer here would silently drop multipart events on + // slower hosts where the RST races the client DATA processing.) + if (isClient) + connectionState.MultipartObservers.TryRemove(streamId, out _); + connectionState.OriginRelayPool?.ReleaseStream(streamId); + if (connectionState.TryTakeStream(streamId, out var resetStream)) + { + // RFC 8441: if the reset stream is an extended CONNECT tunnel, unblock the relay + // that is reading from the inbound channel so it can shut down promptly. + resetStream.InboundTunnelChannel?.Writer.TryComplete(); + await resetStream.Cancellation.CancelAsync(); + if (!resetStream.IsCompressedRelay) + resetStream.Cancellation.Dispose(); + connectionState.ClientSendFlow.RemoveStream(streamId); + connectionState.ServerSendFlow.RemoveStream(streamId); + + // A stream reset before it ever reached a response leaves SessionArgs.Response at + // its default (StatusCode 0, HttpVersion null). Setting Exception here - matching + // every other forwarding path's convention of recording even OperationCanceledException + // on the session (see RequestHandler/Http11ToHttp2BridgeHandler/Http2ToHttp3BridgeHandler) - + // lets AfterResponse consumers tell "client reset this incomplete stream" apart from + // an actual proxy failure, instead of seeing an unexplained zero-status entry. + if (resetStream.SessionArgs is { } resetArgs + && resetArgs.Exception == null + && !resetArgs.HttpClient.Response.Locked) + { + resetArgs.Exception = new OperationCanceledException( + isClient + ? "Stream was reset by the client before it received a response." + : "Stream was reset by the origin before it received a response."); + } + + ScheduleFinalize(resetStream, onAfterResponse, logger, connectionState); + + // Wire up args so the RST_STREAM error log below can include the request URL + // (args is only populated for DATA/HEADERS frames in the outer scope, so it is + // always null here without this assignment). + args = resetStream.SessionArgs; + + if (args != null) + { + var resetRr = isClient + ? (RequestResponseBase)args.HttpClient.Request + : args.HttpClient.Response; + + // unblock a pending GetBody()-style waiter rather than hanging forever now that no + // further DATA/END_STREAM will ever arrive for this stream. + var bodyTcs = resetRr.ReadHttp2BodyTaskCompletionSource; + if (bodyTcs != null && !bodyTcs.Task.IsCompleted) + { + resetRr.ReadHttp2BodyTaskCompletionSource = null; + resetRr.IsBodyRead = true; + resetRr.IsBodyReceived = true; + bodyTcs.TrySetResult(true); + } + } + + // Rapid Reset (CVE-2023-44487) abuse budget: this branch only runs for a stream + // that was still tracked (i.e. never reached a normal end-stream) at the moment + // the RST_STREAM arrived, and only the client->server relay task ever reads an + // RST_STREAM frame directly off the client's own wire, so `isClient` here means + // exactly "the client reset a stream it never let complete" - never a + // proxy-initiated reset, which this task never reads back from its own writes. + if (isClient && resourceLimits.MaxPeerInitiatedIncompleteStreamResets.HasValue && + !connectionState.ClientResetBudgetExceeded) + { + var resetBudgetMode = args?.Server.PolicyModes[PolicyFamily.Http2AbuseBudget] + ?? PolicyMode.Enforce; + var resetCount = Interlocked.Increment(ref connectionState.ClientIncompleteStreamResetCount); + if (resetBudgetMode != PolicyMode.Disabled && + resetCount > resourceLimits.MaxPeerInitiatedIncompleteStreamResets.Value) + { + ProxyMetrics.PolicyBreach(PolicyFamily.Http2AbuseBudget, resetBudgetMode); + + // Enforce-only reaction, matching the CONTINUATION-flood budget above: + // Observe records every breach but must not GOAWAY the connection. + if (resetBudgetMode == PolicyMode.Enforce) + { + connectionState.ClientResetBudgetExceeded = true; + connectionState.ClientResetBudgetLastStreamId = connectionState.LastClientStreamId; + ReportException(logger, new ProxyHttpException( + "HTTP/2 abuse budget exceeded: too many client-initiated resets of " + + "incomplete streams (possible Rapid Reset / CVE-2023-44487).", null, null)); + await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], + connectionState.ClientResetBudgetLastStreamId, Http2ErrorCode.EnhanceYourCalm, + input)); + // Do not return: already-admitted streams (id <= the last-stream-id just + // announced) must still be allowed to drain per RFC 9113 §6.8. Only new + // stream admission is refused, at the isMainHeaders check below. + } + } + } + } + + // NO_ERROR (0) from the origin is a normal post-response cleanup; CANCEL is the usual + // client abort. REFUSED_STREAM is also expected under origin load-shedding / GOAWAY + // races (observed live from github.com/Fastly both direct and via this proxy). + // STREAM_CLOSED is the peer saying the stream is already done (half-close races). + // PROTOCOL_ERROR on a received RST is the peer's assessment — our own framing + // defects are already ReportException'd at the detection site before we send RST. + // INTERNAL_ERROR is commonly used by long-lived peer streams (e.g. LaunchDarkly / + // SonarCloud ld-stream) when they tear down; not a proxy defect. + // Forward the RST either way; do not flood Error logs for peer-initiated codes. + if (errorCode != (int)Http2ErrorCode.NoError && + errorCode != (int)Http2ErrorCode.Cancel && + errorCode != (int)Http2ErrorCode.RefusedStream && + errorCode != (int)Http2ErrorCode.StreamClosed && + errorCode != (int)Http2ErrorCode.ProtocolError && + errorCode != (int)Http2ErrorCode.InternalError) + { + var direction = isClient ? "client→proxy" : "origin→proxy"; + var requestUrl = args?.HttpClient.Request.Url ?? "(unknown)"; + ReportException(logger, new ProxyHttpException( + $"HTTP/2 stream error. Error code: {errorCode}; direction: {direction}; " + + $"stream: {streamId}; request: {requestUrl}", null, args)); + } + else if (logger.IsEnabled(LogLevel.Debug) && + errorCode != (int)Http2ErrorCode.NoError && + errorCode != (int)Http2ErrorCode.Cancel) + { + var direction = isClient ? "client→proxy" : "origin→proxy"; + var requestUrl = args?.HttpClient.Request.Url ?? "(unknown)"; + ProxyDiagnostics.ReportBenign(logger, + $"HTTP/2 peer RST_STREAM. Error code: {errorCode}; direction: {direction}; " + + $"stream: {streamId}; request: {requestUrl}", + new ProxyHttpException( + $"HTTP/2 peer stream reset code {errorCode}", null, args)); + } + } + + if (endStream && rr == null) + { + var compressedEndStream = existingStreamState?.IsCompressedRelay == true + || (connectionState.Streams.TryGetValue(streamId, out var endStreamState) + && endStreamState.IsCompressedRelay); + if (!compressedEndStream) + throw new InvalidOperationException( + "An HTTP/2 end-stream frame has no request or response."); + } + + if (endStream && rr != null && rr.ReadHttp2BodyTaskCompletionSource != null) + { + if (!rr.BodyAvailable) + { + var data = rr.Http2BodyData; + if (data == null) + throw new InvalidOperationException("HTTP/2 body completion was signaled without a buffer."); + + var body = data.ToArray(); + var leftAsWireEncoded = false; + + if (rr.ContentEncoding != null) + { + var (decompressStream, owned) = + CompressionUtil.CreateDecompressionChain(new MemoryStream(body), rr.ContentEncoding); + try + { + if (owned.Count > 0) + { + using var ms = new MemoryStream(); + await decompressStream.CopyToAsync(ms, cancellationToken); + body = ms.ToArray(); + } + else + { + // Unsupported encoding (dcb/dcz/zstd…): keep wire bytes. + leftAsWireEncoded = true; + } + } + finally + { + for (var i = owned.Count - 1; i >= 0; i--) + await owned[i].DisposeAsync(); + } + } + + if (!rr.BodyAvailable) + { + rr.Body = body; + rr.BodyIsWireEncoded = leftAsWireEncoded; + } + } + + rr.IsBodyRead = true; + rr.IsBodyReceived = true; + + var tcs = rr.ReadHttp2BodyTaskCompletionSource; + rr.ReadHttp2BodyTaskCompletionSource = null; + + if (!tcs.Task.IsCompleted) + { + tcs.SetResult(true); + } + + if (rr.Http2BodyData != null) await rr.Http2BodyData.DisposeAsync(); + rr.Http2BodyData = null; + + if (rr.Http2BeforeHandlerTask != null) + { + await rr.Http2BeforeHandlerTask; + } + + if (args == null) + throw new InvalidOperationException("HTTP/2 body completion has no session."); + + if (args.IsPromise) + { + Breakpoint(); + } + + // If the before-handler claimed exclusive bridge ownership (e.g. H2→H3 bridge), skip + // SendBody: the bridge already forwarded the complete request (headers + body) on its own + // transport (QUIC or TCP-fallback). Sending it again here over the H2 TCP origin would + // double-submit the request and cause a PROTOCOL_ERROR on the H2 origin connection. + // + // By the time Http2BeforeHandlerTask has completed the handler has already set + // IsExternalBridge = true on the stream state and fired the background bridge task. The + // background task cannot have removed the stream from the dictionary yet (it hasn't + // started executing on the thread pool), so TryGetValue is guaranteed to return the + // already-mutated state object. + connectionState.Streams.TryGetValue(streamId, out var bodyStreamState); + if (bodyStreamState?.IsExternalBridge != true) + { + // Drain queued HEADERS/DATA so this SendBody cannot overtake them on the wire. + if (isClient) + await connectionState.ServerWriteChain; + else + await connectionState.ClientWriteChain; + await lockedOutputWrite(() => + AsValueTask(SendBody(remoteSettings, rr, frameHeader, frameHeaderBuffer, buffer, outboundFlow, + output, cancellationToken))); + } + } + + if (endStream) + { + if (isClient) + connectionState.MultipartObservers.TryRemove(streamId, out _); + + if (connectionState.Streams.TryGetValue(streamId, out var closingStream)) + { + if (isClient) + { + closingStream.RequestClosed = true; + if (closingStream.IsExtendedConnect) + closingStream.InboundTunnelChannel?.Writer.TryComplete(); + } + else + closingStream.ResponseClosed = true; + + if (closingStream.IsClosed) + { + connectionState.OriginRelayPool?.ReleaseStream(streamId); + connectionState.RemoveStream(streamId); + ScheduleFinalize(closingStream, onAfterResponse, logger, connectionState); + } + } + } + + if (sendPacket) + { + var frameLength = length; + + if (type == Http2FrameType.Data) + { + if (isClient && connectionState.OriginRelayPool != null + && connectionState.OriginRelayPool.TryGetAssignment(streamId, out var dataAssignment)) + { + await dataAssignment.Leg.SendFlow + .ReserveAsync(dataAssignment.OriginStreamId, frameLength, cancellationToken) + .ConfigureAwait(false); + } + else + { + await outboundFlow.ReserveAsync(streamId, frameLength, cancellationToken); + } + } + + if (type == Http2FrameType.Data) + { + // Copy and queue so DATA cannot overtake a queued HEADERS write on this direction + // (and so the frame loop does not await peer socket I/O on the hot path). + Http2FrameWriter? dedicatedWriter = null; + if (isClient && connectionState.OriginRelayPool != null + && connectionState.OriginRelayPool.TryGetAssignment(streamId, out var assignment)) + { + frameHeader.StreamId = assignment.OriginStreamId; + dedicatedWriter = assignment.Leg.Writer; + } + else if (!isClient && originReceiveLeg != null) + { + dedicatedWriter = connectionState.ClientFrameWriter; + } + + frameHeader.CopyToBuffer(frameHeaderBuffer); + var wireLen = 9 + frameLength; + var rented = ArrayPool.Shared.Rent(wireLen); + frameHeaderBuffer.AsSpan(0, 9).CopyTo(rented); + if (frameLength > 0) + buffer.AsSpan(0, frameLength).CopyTo(rented.AsSpan(9)); + if (dedicatedWriter != null) + dedicatedWriter.EnqueueRented(rented, wireLen); + else + connectionState.EnqueueWriteRented(towardServer: isClient, outputWriteLock, output, rented, + wireLen); + } + else + { + // Control frames (SETTINGS/WINDOW_UPDATE/PING/HEADERS/…): stream-scoped frames + // (HEADERS etc.) go through the dedicated writer for coalesced writes. Connection- + // level SETTINGS/WINDOW_UPDATE/PING/GOAWAY stay awaited under the write lock so + // the post-SETTINGS connection WINDOW_UPDATE below cannot overtake SETTINGS. + if (isClient && streamId != 0 && connectionState.OriginRelayPool != null + && connectionState.OriginRelayPool.TryGetAssignment(streamId, out var ctrlAssignment)) + { + frameHeader.StreamId = ctrlAssignment.OriginStreamId; + frameHeader.CopyToBuffer(frameHeaderBuffer); + var wireLen = 9 + frameLength; + var rented = ArrayPool.Shared.Rent(wireLen); + frameHeaderBuffer.AsSpan(0, 9).CopyTo(rented); + if (frameLength > 0) + buffer.AsSpan(0, frameLength).CopyTo(rented.AsSpan(9)); + ctrlAssignment.Leg.Writer.EnqueueRented(rented, wireLen); + } + else + { + frameHeader.CopyToBuffer(frameHeaderBuffer); + var wireLen = 9 + frameLength; + var streamScoped = type is Http2FrameType.Headers or Http2FrameType.Continuation + or Http2FrameType.RstStream or Http2FrameType.Priority; + Http2FrameWriter? dedicatedWriter = null; + if (streamScoped) + dedicatedWriter = isClient + ? connectionState.ServerFrameWriter + : connectionState.ClientFrameWriter; + if (dedicatedWriter != null) + { + var rented = ArrayPool.Shared.Rent(wireLen); + frameHeaderBuffer.AsSpan(0, 9).CopyTo(rented); + if (frameLength > 0) + buffer.AsSpan(0, frameLength).CopyTo(rented.AsSpan(9)); + dedicatedWriter.EnqueueRented(rented, wireLen); + } + else + { + async ValueTask writeFrame() + { + await output.WriteAsync(frameHeaderBuffer.AsMemory(0, 9), CancellationToken.None); + if (frameLength > 0) + await output.WriteAsync(buffer.AsMemory(0, frameLength), CancellationToken.None); + } + + await lockedOutputWrite(writeFrame); + } + } + } + + // signal once the server's SETTINGS frame has actually reached the client, so a synthetic + // response on the other relay can safely send HEADERS afterwards. + if (!isClient && type == Http2FrameType.Settings && (flags & Http2FrameFlag.Ack) == 0) + { + connectionState.ServerSettingsRelayed.TrySetResult(true); + + // 1 MiB connection window toward the client — must follow SETTINGS on the + // wire (see SendHttp2 remarks). Same CompareExchange guard as the origin path. + if (Interlocked.CompareExchange(ref connectionState.InitialClientWindowUpdateSent, 1, 0) == 0) + { + await lockedOutputWrite(() => SendWindowUpdateAsync(frameHeader, frameHeaderBuffer, 0, + ClientConnectionWindowIncrement, output)); + } + } + + // H2↔H2 MITM: after the browser's first non-ACK SETTINGS reaches the origin (RFC 7540 + // §3.5: SETTINGS must immediately follow the preface), enlarge the origin's connection + // send window to match Chrome. Emitting WINDOW_UPDATE before SETTINGS made strict origins + // (e.g. MSN, Wikipedia) close with PROTOCOL_ERROR; emitting a proxy SETTINGS instead + // produced an unexpected SETTINGS ACK when relayed to Chrome. + if (isClient && type == Http2FrameType.Settings && (flags & Http2FrameFlag.Ack) == 0 && + Interlocked.CompareExchange(ref connectionState.InitialOriginWindowUpdateSent, 1, 0) == 0) + { + await lockedOutputWrite(() => SendWindowUpdateAsync(frameHeader, frameHeaderBuffer, 0, + InitialConnectionWindowIncrement, output)); + } + } + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + } + } + finally + { + // Flush any batched receive credit before tearing down so the peer is not left + // with a permanently shrunk window on a half-closed connection. + try + { + await FlushAllPendingReceiveCreditAsync(); + } + catch + { + // best-effort — the peer may already be gone + } + + // Ensure the other relay direction (and any synthetic task below still waiting on a + // cross-direction signal such as ServerSettingsRelayed) is unblocked before this method + // awaits tracked synthetic tasks. SendHttp2 only cancels the shared token once one of the + // two CopyHttp2FrameAsync tasks has *already completed*; without cancelling here first, a + // synthetic task on this direction that is still waiting on a signal only the other, + // still-running relay task can deliver would never observe cancellation, and this method + // would never complete for SendHttp2 to observe in the first place - a deadlock. + await cancellationTokenSource.CancelAsync(); + + if (!pendingSynthetics.IsEmpty) + { + await pendingSynthetics.WhenAllAsync(); + } + } + } + } +} diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Finalize.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Finalize.cs new file mode 100644 index 000000000..f18736237 --- /dev/null +++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Finalize.cs @@ -0,0 +1,160 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Titanium.Web.Proxy.Compression; +using Titanium.Web.Proxy.Diagnostics; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Exceptions; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Http2.Hpack; +using Titanium.Web.Proxy.Logging; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network.Streams; +using Titanium.Web.Proxy.Network.Tcp; +using Titanium.Web.Proxy.Options; +using Decoder = Titanium.Web.Proxy.Http2.Hpack.Decoder; +using Encoder = Titanium.Web.Proxy.Http2.Hpack.Encoder; + +namespace Titanium.Web.Proxy.Http2 +{ + internal partial class Http2Helper + { + private const string AfterResponseFailedMessage = "HTTP/2 AfterResponse handler failed"; + + internal static async Task FinalizeStreamAsync(Http2StreamState state, + Func onAfterResponse, ILogger logger, + Http2ConnectionState? connectionState = null) + { + if (Interlocked.CompareExchange(ref state.FinalizedFlag, 1, 0) != 0) + { + return; + } + + // Compressed-relay streams never allocate SessionEventArgs. CTS is TryReset in PrepareForPool. + if (state.SessionArgs == null) + { + connectionState?.ReturnStreamState(state); + return; + } + + try + { + var requestDispatch = state.SessionArgs.HttpClient.Request.Http2BeforeHandlerTask; + var responseDispatch = state.SessionArgs.HttpClient.Response.Http2BeforeHandlerTask; + if (requestDispatch != null) + await requestDispatch; + if (responseDispatch != null && !ReferenceEquals(responseDispatch, requestDispatch)) + await responseDispatch; + + await onAfterResponse(state.SessionArgs); + } + catch (Exception ex) + { + ReportException(logger, new ProxyHttpException(AfterResponseFailedMessage, ex, + state.SessionArgs)); + } + finally + { + state.SessionArgs.Dispose(); + connectionState?.ReturnStreamState(state); + } + } + + /// + /// Schedules finalize. Compressed-relay finalize is synchronous when AfterResponse completes + /// inline (gate-off has no SessionEventArgs; MITM unchanged-lite usually Completes Task) — + /// avoid allocating a into . + /// + private static void ScheduleFinalize(Http2StreamState state, + Func onAfterResponse, ILogger logger, + Http2ConnectionState connectionState) + { + if (state.IsCompressedRelay) + { + // Inline hot path: FinalizedFlag + optional sync AfterResponse/Dispose + pool return. + if (Interlocked.CompareExchange(ref state.FinalizedFlag, 1, 0) != 0) + return; + + var args = state.SessionArgs; + if (args == null) + { + connectionState.ReturnStreamState(state); + return; + } + + // MITM unchanged-lite: both Before* dispatches finished before END_STREAM closed the + // stream. Prefer sync AfterResponse+Dispose; async AfterResponse falls back to Task track. + try + { + var after = onAfterResponse(args); + if (after.IsCompletedSuccessfully) + { + args.Dispose(); + connectionState.ReturnStreamState(state); + return; + } + + if (after.IsCompleted) + { + // Faulted/canceled CompletedTask — still dispose; report like FinalizeStreamAsync. + if (after.IsFaulted) + { + ReportException(logger, new ProxyHttpException(AfterResponseFailedMessage, + after.Exception?.GetBaseException(), args)); + } + + args.Dispose(); + connectionState.ReturnStreamState(state); + return; + } + + // Rare async AfterResponse: finish on the pending bag (FinalizedFlag already set). + connectionState.PendingFinalizations.Track(CompleteMitmCompressedFinalizeAsync( + after, args, state, logger, connectionState)); + return; + } + catch (Exception ex) + { + ReportException(logger, new ProxyHttpException(AfterResponseFailedMessage, ex, args)); + args.Dispose(); // NOSONAR S3966 -- Catch path after onAfterResponse threw; try returns before Dispose. + connectionState.ReturnStreamState(state); + return; + } + } + + connectionState.PendingFinalizations.Track( + FinalizeStreamAsync(state, onAfterResponse, logger, connectionState)); + } + + private static async Task CompleteMitmCompressedFinalizeAsync(Task afterResponse, + SessionEventArgs args, Http2StreamState state, ILogger logger, + Http2ConnectionState connectionState) + { + try + { + await afterResponse.ConfigureAwait(false); + } + catch (Exception ex) + { + ReportException(logger, new ProxyHttpException(AfterResponseFailedMessage, ex, args)); + } + finally + { + args.Dispose(); + connectionState.ReturnStreamState(state); + } + } + } +} diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Hpack.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Hpack.cs new file mode 100644 index 000000000..371ec023d --- /dev/null +++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Hpack.cs @@ -0,0 +1,694 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Titanium.Web.Proxy.Compression; +using Titanium.Web.Proxy.Diagnostics; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Exceptions; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Http2.Hpack; +using Titanium.Web.Proxy.Logging; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network.Streams; +using Titanium.Web.Proxy.Network.Tcp; +using Titanium.Web.Proxy.Options; +using Decoder = Titanium.Web.Proxy.Http2.Hpack.Decoder; +using Encoder = Titanium.Web.Proxy.Http2.Hpack.Encoder; + +namespace Titanium.Web.Proxy.Http2 +{ + internal partial class Http2Helper + { + [Conditional("DEBUG")] + private static void Breakpoint() + { + // when this method is called something received which is not yet implemented + } + + /// Cheap check avoiding a ToLowerInvariant() allocation for the common already-lowercase case. + private static bool HasUpperCaseAscii(ByteString name) + { + var span = name.Span; + for (var i = 0; i < span.Length; i++) + { + if (span[i] is >= (byte)'A' and <= (byte)'Z') + return true; + } + + return false; + } + + /// + /// ASCII lowercase copy for HPACK wire names — no / + /// encoding round-trip (those allocated under origin writeLock on H1→H2). + /// + private static ByteString AsciiToLowerByteString(ByteString name) + { + var span = name.Span; + var buf = new byte[span.Length]; + for (var i = 0; i < span.Length; i++) + { + var c = span[i]; + buf[i] = c is >= (byte)'A' and <= (byte)'Z' ? (byte)(c + 32) : c; + } + + return new ByteString(buf); + } + + private static readonly ByteString ViaHeaderLower = "via".GetByteString(); + + /// + /// Hop-by-hop / connection-specific names RFC 7540 §8.1.2.2 forbids on HTTP/2 (plus Host, which + /// becomes :authority). Compared on so EncodeHeaderBlock does not + /// force header.Name GetString under writeLock. + /// + private static bool ShouldOmitHttp2Header(ByteString name) + { + var span = name.Span; + return span.Length switch + { + 2 => EqualsAsciiIgnoreCase(span, "te"u8), + 4 => EqualsAsciiIgnoreCase(span, "host"u8), + 7 => EqualsAsciiIgnoreCase(span, "upgrade"u8), + 10 => EqualsAsciiIgnoreCase(span, "connection"u8) + || EqualsAsciiIgnoreCase(span, "keep-alive"u8), + 16 => EqualsAsciiIgnoreCase(span, "proxy-connection"u8), + 17 => EqualsAsciiIgnoreCase(span, "transfer-encoding"u8), + _ => false + }; + } + + private static bool EqualsAsciiIgnoreCase(ReadOnlySpan a, ReadOnlySpan b) + { + if (a.Length != b.Length) return false; + for (var i = 0; i < a.Length; i++) + { + var x = a[i]; + var y = b[i]; + if (x is >= (byte)'A' and <= (byte)'Z') x = (byte)(x + 32); + if (y is >= (byte)'A' and <= (byte)'Z') y = (byte)(y + 32); + if (x != y) return false; + } + + return true; + } + + // Common :status values (StaticTable also indexes several of these). + private static readonly ByteString Status200 = "200".GetByteString(); + private static readonly ByteString Status204 = "204".GetByteString(); + private static readonly ByteString Status206 = "206".GetByteString(); + private static readonly ByteString Status301 = "301".GetByteString(); + private static readonly ByteString Status302 = "302".GetByteString(); + private static readonly ByteString Status304 = "304".GetByteString(); + private static readonly ByteString Status400 = "400".GetByteString(); + private static readonly ByteString Status404 = "404".GetByteString(); + private static readonly ByteString Status500 = "500".GetByteString(); + private static readonly ByteString Status502 = "502".GetByteString(); + + private static ByteString StatusCodeBytes(int statusCode) => statusCode switch + { + 200 => Status200, + 204 => Status204, + 206 => Status206, + 301 => Status301, + 302 => Status302, + 304 => Status304, + 400 => Status400, + 404 => Status404, + 500 => Status500, + 502 => Status502, + _ => statusCode.ToString().GetByteString() + }; + + // Hot-path caches: avoid allocating ByteString for common :method / :scheme under writeLock. + private static readonly ByteString MethodGet = "GET".GetByteString(); + private static readonly ByteString MethodHead = "HEAD".GetByteString(); + private static readonly ByteString MethodPost = "POST".GetByteString(); + private static readonly ByteString MethodPut = "PUT".GetByteString(); + private static readonly ByteString MethodDelete = "DELETE".GetByteString(); + private static readonly ByteString MethodOptions = "OPTIONS".GetByteString(); + private static readonly ByteString MethodConnect = "CONNECT".GetByteString(); + private static readonly ByteString SchemeHttps = "https".GetByteString(); + private static readonly ByteString SchemeHttp = "http".GetByteString(); + + private static ByteString MethodBytes(string method) => method switch + { + "GET" => MethodGet, + "HEAD" => MethodHead, + "POST" => MethodPost, + "PUT" => MethodPut, + "DELETE" => MethodDelete, + "OPTIONS" => MethodOptions, + "CONNECT" => MethodConnect, + _ => method.GetByteString() + }; + + /// + /// HPACK-encodes into the direction's scratch stream. Must run on the + /// frame-read loop (or otherwise be serialized) so the dynamic table stays ordered. + /// + private static ReadOnlyMemory EncodeHeaderBlock(Http2Settings settings, RequestResponseBase rr) // NOSONAR S3776 -- Same encode path as SendHeader; keep logic together. + { + // Reuse one Encoder (and its HPACK dynamic table) per direction for the lifetime of the connection, + // mirroring how the Decoder is persisted below - the dynamic table is connection-scoped, not + // per-message, so recreating it on every call (as before) meant every header was encoded as a + // literal and repeated headers across streams/messages were never indexed. `settings` is one of + // the two Http2Settings instances created once in SendHttp2 and shared by both relay directions, + // so storing the encoder on it here gives every SendHeader call for this direction (including the + // one used for synthetic responses) the same encoder/table instance. + var encoder = settings.Encoder; + if (encoder == null) + { + encoder = new Encoder(RfcDefaultHeaderTableSize); + settings.Encoder = encoder; + } + + // Encode scratch is connection-direction scoped and only used under the write lock / frame loop. + var ms = settings.GetEncodeStream(); + var writer = settings.GetEncodeWriter(); + + // RFC 7540 ?6.2: the HEADERS frame payload is [Pad Length?] [E + Stream Dependency + Weight, if + // PRIORITY] [Header Block Fragment] [Padding?] - the priority fields (when present) are a + // frame-level prefix that comes strictly *before* the header block fragment, which is the HPACK + // byte sequence built below (dynamic table size update, if any, followed by the encoded + // pseudo-headers/headers). Writing the priority bytes after the size-update instruction (as a + // previous version of this code did) shifted every subsequent byte by 5, so the peer tried to + // HPACK-decode a header block that actually started with garbage priority bytes - corrupting + // this connection's HPACK state and manifesting as an intermittent, hard-to-reproduce + // net::ERR_HTTP2_COMPRESSION_ERROR in the browser whenever a priority-bearing request happened + // to coincide with a table-size change. + if (rr.Priority.HasValue) + { + long p = rr.Priority.Value; + writer.Write((byte)((p >> 32) & 0xff)); + writer.Write((byte)((p >> 24) & 0xff)); + writer.Write((byte)((p >> 16) & 0xff)); + writer.Write((byte)((p >> 8) & 0xff)); + writer.Write((byte)(p & 0xff)); + } + + // RFC 7541 §6.3: Dynamic Table Size Update(s) must appear at the beginning of the first + // header block following any change to the peer's advertised ceiling. + // + // When multiple SETTINGS_HEADER_TABLE_SIZE updates arrive between two header blocks the spec + // requires signalling the smallest value that occurred first so the peer's decoder can evict + // entries it could no longer keep, before the encoder expands back to the final size. + // (Example: Google sends size=0 then size=65536 during connection setup; omitting the + // intermediate 0 leaves the encoder with live table entries the decoder already evicted, + // causing indexed references to resolve to stale/wrong slots — manifesting as a + // RST_STREAM(PROTOCOL_ERROR) from strict origins on the very next H2-native-relay stream.) + var minSize = settings.MinHeaderTableSizeSinceLastEncode; + var curSize = settings.HeaderTableSize; + if (encoder.MaxHeaderTableSize != minSize) + encoder.SetMaxHeaderTableSize(writer, minSize); + if (encoder.MaxHeaderTableSize != curSize) + encoder.SetMaxHeaderTableSize(writer, curSize); + // Reset so only updates arriving *after* this encode are rolled into the next header block. + settings.NotifyHeaderBlockEncoded(); + + if (rr is Request request) + { + // Do NOT touch RequestUri/Url here: those allocate a Uri + string under writeLock + // (H1→H2 / H3→H2 origin SendAsync critical section). Prefer already-materialized + // Authority / IsHttps / RequestUriString8 from the bridge or HPACK decode. + encoder.EncodeHeader(writer, StaticTable.KnownHeaderMethod, MethodBytes(request.Method)); + var authorityValue = request.Authority.Length > 0 + ? request.Authority + : (request.Host ?? string.Empty).GetByteString(); + encoder.EncodeHeader(writer, StaticTable.KnownHeaderAuhtority, authorityValue); + encoder.EncodeHeader(writer, StaticTable.KnownHeaderScheme, + request.IsHttps ? SchemeHttps : SchemeHttp); + // Index :path (static "/" / repeated paths). IndexType.None forced a literal on every + // stream and lengthened writeLock under Mac dual-TLS H1→H2 / H3→H2 multiplex. + encoder.EncodeHeader(writer, StaticTable.KnownHeaderPath, request.RequestUriString8); + // RFC 8441 §5: :protocol must appear after the other pseudo-headers. + if (request.ExtendedConnectProtocol != null) + encoder.EncodeHeader(writer, StaticTable.KnownHeaderProtocol, + request.ExtendedConnectProtocol.GetByteString()); + } + else + { + var response = (Response)rr; + encoder.EncodeHeader(writer, StaticTable.KnownHeaderStatus, StatusCodeBytes(response.StatusCode)); + } + + foreach (var header in rr.Headers) + { + // RFC 7540 §8.1.2: header field names MUST be lowercase on the wire. Bridge handlers + // normalize this up front (see LowercaseHeaderNames in Http2ToHttp11BridgeHandler / + // Http2ToHttp3BridgeHandler), but that pass can be silently undone by anything that + // re-adds a header afterwards using its canonical mixed-case name - e.g. + // RequestResponseBase.ContentLength's setter picks "Content-Length" whenever + // HttpVersion is below 2.0, which is exactly the state an H1/H3-origin-bridged + // response is still in when CompressBodyAndUpdateContentLength() re-sets it right + // before this loop runs. Enforcing lowercase here, at the single point where every + // header actually gets HPACK-encoded onto an h2 wire, closes that gap regardless of + // which upstream code path is responsible - a mixed-case name here reaches the peer + // verbatim and manifests as a client RST_STREAM(PROTOCOL_ERROR). + var nameData = header.NameData; + if (!rr.HeaderNamesAreHttp2Normalized && HasUpperCaseAscii(nameData)) + nameData = AsciiToLowerByteString(nameData); + + // Strip hop-by-hop / Host here so PrepareRequestForOrigin need not RemoveHeader seven + // times under the H1→H2 path (still strips Host for Authority capture separately). + if (ShouldOmitHttp2Header(nameData)) + continue; + + // Via is added by the proxy itself on every request and varies across hops; it must + // not enter the HPACK dynamic table. If it did, stream N would encode it as a + // single-byte dynamic-table reference, and strict H2 origins (Google's play.google.com + // included) respond with RST_STREAM(PROTOCOL_ERROR) on any stream that carries a + // Via header via an indexed reference rather than an explicit literal field. + // IndexType.None means "literal without indexing" — the encoder skips Add() so + // the entry never lands in the dynamic table, and every subsequent stream gets a + // fresh literal representation instead of a back-reference. + if (nameData.Equals(ViaHeaderLower) || nameData.EqualsIgnoreCaseAscii(ViaHeaderLower)) + encoder.EncodeHeader(writer, nameData, header.ValueData, false, + HpackUtil.IndexType.None); + else + encoder.EncodeHeader(writer, nameData, header.ValueData); + } + + writer.Flush(); + return GetMemoryStreamMemory(ms); + } + + // HPACK static table: :scheme http = 6, :scheme https = 7 → Indexed Header Field bytes. + private const byte IndexedSchemeHttp = 0x86; + private const byte IndexedSchemeHttps = 0x87; + + internal enum StaticSchemeOverrideResult + { + NeedFallback, + Patched, + AlreadyMatching, + } + + private static byte StaticIndexedSchemeByte(ByteString scheme) + { + if (scheme.Equals(ProxyServer.UriSchemeHttp8) || scheme.Equals(SchemeHttp)) + return IndexedSchemeHttp; + if (scheme.Equals(ProxyServer.UriSchemeHttps8) || scheme.Equals(SchemeHttps)) + return IndexedSchemeHttps; + return 0; + } + + /// + /// Fast mixed-transport path: walk HEADER_TABLE_SIZE=0 HPACK and rewrite Indexed + /// :scheme (0x86↔0x87) without a Decoder. Returns + /// when the block already carries the origin-transport scheme as a static index. + /// + internal static StaticSchemeOverrideResult TryApplyStaticIndexedSchemeOverride(byte[] block, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + ByteString toScheme, out byte[] patched) + { + patched = null!; + var to = StaticIndexedSchemeByte(toScheme); + if (to == 0) + return StaticSchemeOverrideResult.NeedFallback; + var from = to == IndexedSchemeHttps ? IndexedSchemeHttp : IndexedSchemeHttps; + + var i = 0; + var fromAt = -1; + var toCount = 0; + while (i < block.Length) + { + var b = block[i]; + if ((b & 0x80) != 0) + { + if ((b & 0x7f) == 0) + return StaticSchemeOverrideResult.NeedFallback; + if (b == from) + { + if (fromAt >= 0) + return StaticSchemeOverrideResult.NeedFallback; + fromAt = i; + } + else if (b == to) + { + toCount++; + } + + i++; + continue; + } + + if ((b & 0xe0) == 0x20) + { + if ((b & 0x1f) == 0x1f) + return StaticSchemeOverrideResult.NeedFallback; + i++; + continue; + } + + if (!TrySkipHpackLiteral(block, ref i)) + return StaticSchemeOverrideResult.NeedFallback; + } + + if (fromAt >= 0 && toCount == 0) + { + // Owned fragment / CapturedCompressedHeaders / TryPrepare rebuild — patch in place + // (TLS↔h2c). Avoids per-stream alloc+copy on the mixed-transport hot path. + block[fromAt] = to; + patched = block; + return StaticSchemeOverrideResult.Patched; + } + + if (fromAt < 0 && toCount == 1) + return StaticSchemeOverrideResult.AlreadyMatching; + + return StaticSchemeOverrideResult.NeedFallback; + } + + /// + /// Walk a HEADER_TABLE_SIZE=0 HPACK block and rewrite a single Indexed Header Field for + /// :scheme (static indices 6/7). Returns false when scheme is not indexed that way + /// (literal encoding, absent, or ambiguous) so the caller can fall back to full re-encode. + /// + internal static bool TryPatchStaticIndexedScheme(byte[] block, ByteString fromScheme, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + ByteString toScheme, out byte[] patched) + { + patched = null!; + var from = StaticIndexedSchemeByte(fromScheme); + var to = StaticIndexedSchemeByte(toScheme); + if (from == 0 || to == 0 || from == to) + return false; + + var i = 0; + var foundAt = -1; + while (i < block.Length) + { + var b = block[i]; + if ((b & 0x80) != 0) + { + // Indexed Header Field — 7-bit index fits in one byte for static table (1–61). + if ((b & 0x7f) == 0) + return false; // 7-bit integer continuation; not used for indices 6/7 + if (b == from) + { + if (foundAt >= 0) + return false; + foundAt = i; + } + + i++; + continue; + } + + if ((b & 0xe0) == 0x20) + { + // Dynamic Table Size Update — skip 5-bit integer (always single-byte when size=0). + if ((b & 0x1f) == 0x1f) + return false; + i++; + continue; + } + + // Literal Header Field (with/without indexing / never indexed): skip name + value. + if (!TrySkipHpackLiteral(block, ref i)) + return false; + } + + if (foundAt < 0) + return false; + + patched = new byte[block.Length]; + Buffer.BlockCopy(block, 0, patched, 0, block.Length); + patched[foundAt] = to; + return true; + } + + private static bool TrySkipHpackLiteral(byte[] block, ref int i) + { + if (i >= block.Length) + return false; + var b = block[i]; + int nameIndex; + if ((b & 0xc0) == 0x40) + { + // Literal with incremental indexing — 6-bit name index + nameIndex = b & 0x3f; + i++; + if (nameIndex == 0x3f && !TrySkipHpackIntegerContinuation(block, ref i)) + return false; + } + else if ((b & 0xf0) == 0x00 || (b & 0xf0) == 0x10) + { + // Literal without indexing / never indexed — 4-bit name index + nameIndex = b & 0x0f; + i++; + if (nameIndex == 0x0f && !TrySkipHpackIntegerContinuation(block, ref i)) + return false; + } + else + { + return false; + } + + if (nameIndex == 0 && !TrySkipHpackString(block, ref i)) + return false; + + return TrySkipHpackString(block, ref i); + } + + private static bool TrySkipHpackString(byte[] block, ref int i) + { + if (i >= block.Length) + return false; + var len = block[i] & 0x7f; + i++; + if (len == 0x7f) + { + // RFC 7541 §5.1: value = (2^N - 1) + continuation + if (!TrySkipHpackIntegerContinuation(block, ref i, out var extra)) + return false; + len = 127 + extra; + } + + if (i + len > block.Length) + return false; + i += len; + return true; + } + + private static bool TrySkipHpackIntegerContinuation(byte[] block, ref int i) + => TrySkipHpackIntegerContinuation(block, ref i, out _); + + private static bool TrySkipHpackIntegerContinuation(byte[] block, ref int i, out int value) + { + value = 0; + var m = 0; + while (i < block.Length) + { + var b = block[i++]; + value += (b & 0x7f) << m; + if ((b & 0x80) == 0) + return true; + m += 7; + if (m > 28) + return false; + } + + return false; + } + + /// + /// Re-encodes a compressed-relay request header block with replacing + /// the client's ':scheme' - used on mixed-transport passthrough connections (inbound h2c client + /// with a TLS origin, or TLS-terminated client with a cleartext h2 origin) where relaying the + /// block verbatim makes strict origins reset every stream with PROTOCOL_ERROR because the scheme + /// does not match the origin transport. Compressed relay forces HEADER_TABLE_SIZE=0 on both + /// legs, so re-encoded blocks stay context-free and remain safe for any origin leg. + /// + private static byte[] ReencodeCompressedRequestBlock(Http2Settings settings, MyHeaderListener pseudo, + HeaderCollection headers, ByteString scheme) + { + // Same serialization contract as QueueSendHeader: encoder + scratch are direction-scoped. + lock (settings.Sync) + { + var encoder = settings.Encoder; + if (encoder == null) + { + encoder = new Encoder(RfcDefaultHeaderTableSize); + settings.Encoder = encoder; + } + + var ms = settings.GetEncodeStream(); + var writer = settings.GetEncodeWriter(); + + // Same RFC 7541 §6.3 dual-DTSU logic as EncodeHeaderBlock (see the detailed comment there). + var minSize = settings.MinHeaderTableSizeSinceLastEncode; + var curSize = settings.HeaderTableSize; + if (encoder.MaxHeaderTableSize != minSize) + encoder.SetMaxHeaderTableSize(writer, minSize); + if (encoder.MaxHeaderTableSize != curSize) + encoder.SetMaxHeaderTableSize(writer, curSize); + settings.NotifyHeaderBlockEncoded(); + + encoder.EncodeHeader(writer, StaticTable.KnownHeaderMethod, pseudo.Method); + if (pseudo.Authority.Length > 0) + encoder.EncodeHeader(writer, StaticTable.KnownHeaderAuhtority, pseudo.Authority); + encoder.EncodeHeader(writer, StaticTable.KnownHeaderScheme, scheme); + if (pseudo.Path.Length > 0) + encoder.EncodeHeader(writer, StaticTable.KnownHeaderPath, pseudo.Path); + // RFC 8441 §5: :protocol must appear after the other pseudo-headers. + if (pseudo.Protocol.Length > 0) + encoder.EncodeHeader(writer, StaticTable.KnownHeaderProtocol, pseudo.Protocol); + + foreach (var header in headers) + { + // RFC 7540 §8.1.2: names must be lowercase on the wire (same guard as EncodeHeaderBlock). + var nameData = header.NameData; + if (HasUpperCaseAscii(nameData)) + nameData = AsciiToLowerByteString(nameData); + encoder.EncodeHeader(writer, nameData, header.ValueData); + } + + writer.Flush(); + return GetMemoryStreamMemory(ms).ToArray(); + } + } + + private static byte[]? BuildStaticLiteralAppendSuffix( + MitmCompressedRelayHelper.AddedHeaderBuffer added, string? extraName, string? extraValue) + { + var literalCount = added.Count + (extraName != null ? 1 : 0); + if (literalCount == 0) + return null; + + var extraSize = 0; + for (var i = 0; i < added.Count; i++) + { + var h = added[i]; + extraSize += GetStaticLiteralAppendSize(h.NameData.Length, h.ValueData.Length); + } + + if (extraName != null) + extraSize += GetStaticLiteralAppendSize(extraName.Length, extraValue!.Length); + + var result = new byte[extraSize]; + var offset = 0; + for (var i = 0; i < added.Count; i++) + { + var h = added[i]; + offset = WriteStaticLiteralWithoutIndexing(result, offset, h.NameData.Span, h.ValueData.Span); + } + + if (extraName != null) + WriteStaticLiteralWithoutIndexing(result, offset, extraName, extraValue!); + + return result; + } + + private static bool TryPrepareMitmStaticHpackRelay( + byte[] capturedBlock, + MitmCompressedRelayHelper.HeaderRelayBaseline baseline, + HeaderCollection after, + bool injectVia, + string? viaValue, + out byte[] blockToRelay, + out byte[]? appendSuffix) + { + blockToRelay = capturedBlock; + appendSuffix = null; + + if (!MitmStaticRebuildHelper.TryPrepareStaticHpackRelay( + capturedBlock, baseline, after, out blockToRelay, out var added)) + return false; + + var injectViaLiteral = injectVia && !after.HeaderExists("via"); + appendSuffix = BuildStaticLiteralAppendSuffix( + added, + injectViaLiteral && !added.ContainsName("via") ? "via" : null, + injectViaLiteral && !added.ContainsName("via") ? viaValue : null); + return true; + } + + private static int GetStaticLiteralAppendSize(int nameLength, int valueLength) => + 1 + GetHpackStringLiteralEncodedSize(nameLength) + GetHpackStringLiteralEncodedSize(valueLength); + + private static int GetHpackStringLiteralEncodedSize(int byteLength) => + byteLength < 127 ? 1 + byteLength : WriteHpackPrefixedIntSize(7, (ulong)byteLength) + byteLength; + + private static int WriteHpackPrefixedIntSize(int prefixBits, ulong value) + { + var mask = (uint)((1 << prefixBits) - 1); + if (value < mask) + return 1; + + var size = 1; + value -= mask; + while (value >= 0x80) + { + size++; + value >>= 7; + } + + return size + 1; + } + + private static int WriteStaticLiteralWithoutIndexing(byte[] dest, int offset, ReadOnlySpan name, + ReadOnlySpan value) + { + dest[offset++] = 0x00; // Literal without indexing, new name (name index 0) + offset += WriteHpackAsciiStringLiteral(dest.AsSpan(offset), name); + offset += WriteHpackAsciiStringLiteral(dest.AsSpan(offset), value); + return offset; + } + + private static void WriteStaticLiteralWithoutIndexing(byte[] dest, int offset, string name, string value) + { + dest[offset++] = 0x00; + offset += WriteHpackAsciiStringLiteral(dest.AsSpan(offset), name); + _ = WriteHpackAsciiStringLiteral(dest.AsSpan(offset), value); + } + + private static int WriteHpackAsciiStringLiteral(Span dest, ReadOnlySpan value) + { + var written = WriteHpackPrefixedInt(dest, 0x00, 7, (ulong)value.Length); + value.CopyTo(dest.Slice(written)); + return written + value.Length; + } + + private static int WriteHpackAsciiStringLiteral(Span dest, string value) + { + var written = WriteHpackPrefixedInt(dest, 0x00, 7, (ulong)value.Length); + for (var i = 0; i < value.Length; i++) + dest[written + i] = (byte)value[i]; + return written + value.Length; + } + + private static int WriteHpackPrefixedInt(Span dest, byte patternByte, int prefixBits, ulong value) + { + var mask = (uint)((1 << prefixBits) - 1); + if (value < mask) + { + dest[0] = (byte)(patternByte | (byte)value); + return 1; + } + + dest[0] = (byte)(patternByte | (byte)mask); + var written = 1; + value -= mask; + while (value >= 0x80) + { + dest[written++] = (byte)((value & 0x7F) | 0x80); + value >>= 7; + } + + dest[written++] = (byte)value; + return written; + } + } +} diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.Send.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.Send.cs new file mode 100644 index 000000000..68e6f2dcd --- /dev/null +++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.Send.cs @@ -0,0 +1,1411 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Titanium.Web.Proxy.Compression; +using Titanium.Web.Proxy.Diagnostics; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Exceptions; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Http2.Hpack; +using Titanium.Web.Proxy.Logging; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network.Streams; +using Titanium.Web.Proxy.Network.Tcp; +using Titanium.Web.Proxy.Options; +using Decoder = Titanium.Web.Proxy.Http2.Hpack.Decoder; +using Encoder = Titanium.Web.Proxy.Http2.Hpack.Encoder; + +namespace Titanium.Web.Proxy.Http2 +{ + internal partial class Http2Helper + { + internal static async Task SendHeader(Http2Settings settings, Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, RequestResponseBase rr, bool endStream, Stream output, bool pushPromise) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + { + // Same HPACK lock as QueueSendHeader: Encoder + encode scratch are connection-direction scoped. + ReadOnlyMemory block; + lock (settings.Sync) + block = EncodeHeaderBlock(settings, rr).ToArray(); + await WriteHeaderBlockAsync(frameHeader, frameHeaderBuffer, frameHeader.StreamId, + pushPromise ? Http2FrameType.PushPromise : Http2FrameType.Headers, endStream, + rr.Priority.HasValue, block, settings.MaxFrameSize, output); + } + + /// + /// Encodes HEADERS on the frame-read loop, copies the framed bytes, and queues the socket write + /// so the loop can admit the next stream without awaiting peer I/O (encode on the read loop, queue the write, continue). + /// DATA frames for the same direction must also go through + /// so they cannot overtake this HEADERS on the wire. + /// + private static void QueueSendHeader(Http2ConnectionState connectionState, bool towardServer, // NOSONAR S107 -- Parameters kept explicit to avoid allocating options bags on hot bridge/pool paths. + SemaphoreSlim writeLock, Http2Settings settings, Http2FrameHeader frameHeader, + byte[] frameHeaderBuffer, RequestResponseBase rr, bool endStream, Stream output, bool pushPromise) + { + // BeforeRequest dispatches may finish on different thread-pool threads. Keep HPACK encoding and + // write-chain admission atomic per direction so the connection-scoped dynamic table remains ordered. + lock (settings.Sync) + { + var block = EncodeHeaderBlock(settings, rr); + var framed = RentFramedHeaderBlock(frameHeader, frameHeaderBuffer, frameHeader.StreamId, + pushPromise ? Http2FrameType.PushPromise : Http2FrameType.Headers, endStream, + rr.Priority.HasValue, block, settings.MaxFrameSize); + connectionState.EnqueueWriteRented(towardServer, writeLock, output, framed.Array!, framed.Count); + } + } + + private static void QueueSendHeaderTowardServer(Http2ConnectionState connectionState, // NOSONAR S107 -- Parameters kept explicit to avoid allocating options bags on hot bridge/pool paths. + SemaphoreSlim serverWriteLock, Http2Settings settings, Http2FrameHeader frameHeader, + byte[] frameHeaderBuffer, RequestResponseBase rr, bool endStream, Stream output, bool pushPromise) => + QueueSendHeader(connectionState, towardServer: true, serverWriteLock, settings, frameHeader, + frameHeaderBuffer, rr, endStream, output, pushPromise); + + /// + /// Frames as one client-bound DATA frame into a rented buffer and + /// queues it on the dedicated client frame writer. The caller must already hold the + /// flow-control reservation for . Used by the synthetic/bridge + /// response paths so responses from many concurrent streams coalesce into few socket writes + /// instead of each taking per frame. + /// + private static void QueueDataFrame(Http2ConnectionState connectionState, Stream clientStream, + int streamId, ReadOnlyMemory payload, bool endStream) + { + var total = 9 + payload.Length; + var rented = ArrayPool.Shared.Rent(total); + var dataFrameHeader = new Http2FrameHeader + { + StreamId = streamId, + Type = Http2FrameType.Data, + Length = payload.Length, + Flags = endStream ? Http2FrameFlag.EndStream : 0 + }; + dataFrameHeader.CopyToBuffer(rented); + payload.Span.CopyTo(rented.AsSpan(9)); + connectionState.EnqueueWriteRented(towardServer: false, connectionState.ClientWriteLock, + clientStream, rented, total); + } + + /// + /// Queues a client-bound RST_STREAM through the same FIFO as the stream's queued HEADERS/DATA so + /// it cannot overtake them on the wire (a direct locked write could). + /// + private static void QueueRstStreamFrame(Http2ConnectionState connectionState, Stream clientStream, + int streamId, Http2ErrorCode errorCode) + { + const int frameSize = 9 + 4; + var rented = ArrayPool.Shared.Rent(frameSize); + var rstFrameHeader = new Http2FrameHeader + { + StreamId = streamId, + Type = Http2FrameType.RstStream, + Length = 4, + Flags = 0 + }; + rstFrameHeader.CopyToBuffer(rented); + BinaryPrimitives.WriteUInt32BigEndian(rented.AsSpan(9), (uint)errorCode); + connectionState.EnqueueWriteRented(towardServer: false, connectionState.ClientWriteLock, + clientStream, rented, frameSize); + } + + /// + /// Encodes and sends the given trailing headers (RFC 7230 ?4.1.2 / RFC 7540 ?8.1.2.1) as a + /// HEADERS frame carrying no pseudo-headers, using the same persistent per-direction HPACK + /// encoder as so the destination's dynamic table stays in sync + /// regardless of whether trailers are actually present on a given message. + /// + internal static async Task SendTrailer(Http2Settings settings, Http2FrameHeader frameHeader, + byte[] frameHeaderBuffer, int streamId, HeaderCollection trailingHeaders, bool endStream, Stream output) + { + ReadOnlyMemory block; + lock (settings.Sync) + { + var encoder = settings.Encoder; + if (encoder == null) + { + encoder = new Encoder(RfcDefaultHeaderTableSize); + settings.Encoder = encoder; + } + + var ms = settings.GetEncodeStream(); + var writer = settings.GetEncodeWriter(); + + // Same RFC 7541 §6.3 dual-DTSU logic as SendHeader (see the detailed comment there). + var minSizeT = settings.MinHeaderTableSizeSinceLastEncode; + var curSizeT = settings.HeaderTableSize; + if (encoder.MaxHeaderTableSize != minSizeT) + encoder.SetMaxHeaderTableSize(writer, minSizeT); + if (encoder.MaxHeaderTableSize != curSizeT) + encoder.SetMaxHeaderTableSize(writer, curSizeT); + settings.NotifyHeaderBlockEncoded(); + + foreach (var header in trailingHeaders) + { + // See the matching comment in SendHeader: field names must be lowercase on the wire. + var nameData = header.NameData; + if (HasUpperCaseAscii(nameData)) + nameData = AsciiToLowerByteString(nameData); + encoder.EncodeHeader(writer, nameData, header.ValueData); + } + + writer.Flush(); + // Encode scratch is reused; copy before releasing the HPACK lock. + block = GetMemoryStreamMemory(ms).ToArray(); + } + + await WriteHeaderBlockAsync(frameHeader, frameHeaderBuffer, streamId, Http2FrameType.Headers, + endStream, false, block, settings.MaxFrameSize, output); + } + + private static ReadOnlyMemory GetMemoryStreamMemory(MemoryStream ms) + { + if (ms.TryGetBuffer(out var segment)) + return segment.AsMemory(0, (int)ms.Length); + return ms.ToArray(); + } + + /// + /// Builds HEADERS/CONTINUATION wire bytes into an ArrayPool buffer (caller owns the rent). + /// + private static ArraySegment RentFramedHeaderBlock(Http2FrameHeader frameHeader, // NOSONAR S107 -- Frame fields stay explicit. + byte[] frameHeaderBuffer, int streamId, Http2FrameType type, bool endStream, bool hasPriority, + ReadOnlyMemory data, int maxFrameSize) => + RentFramedHeaderBlock(frameHeader, frameHeaderBuffer, streamId, type, endStream, hasPriority, data, + ReadOnlyMemory.Empty, maxFrameSize); + + private static ArraySegment RentFramedHeaderBlock(Http2FrameHeader frameHeader, // NOSONAR S107, S1172 -- Frame fields stay explicit; frameHeaderBuffer retained for call-site IL match. + byte[] frameHeaderBuffer, // NOSONAR S1172 -- retained for call-site IL match. + int streamId, Http2FrameType type, bool endStream, bool hasPriority, + ReadOnlyMemory data, ReadOnlyMemory append, int maxFrameSize) + { + if (maxFrameSize <= 0) maxFrameSize = 16384; + + var dataLen = data.Length + append.Length; + var frameCount = dataLen == 0 ? 1 : (dataLen + maxFrameSize - 1) / maxFrameSize; + var total = frameCount * 9 + dataLen; + var rented = ArrayPool.Shared.Rent(total); + var dest = rented.AsSpan(0, total); + var destPos = 0; + var pos = 0; + var first = true; + + frameHeader.StreamId = streamId; + + do + { + var chunkLength = Math.Min(maxFrameSize, dataLen - pos); + var isLast = pos + chunkLength >= dataLen; + + frameHeader.Type = first ? type : Http2FrameType.Continuation; + frameHeader.Length = chunkLength; + + var flags = (Http2FrameFlag)0; + if (isLast) + flags |= Http2FrameFlag.EndHeaders; + if (first) + { + if (endStream) flags |= Http2FrameFlag.EndStream; + if (hasPriority) flags |= Http2FrameFlag.Priority; + } + + frameHeader.Flags = flags; + frameHeader.CopyToBuffer(dest.Slice(destPos)); + destPos += 9; + if (chunkLength > 0) + { + CopyHeaderBlockSegment(data, append, pos, dest.Slice(destPos, chunkLength)); + destPos += chunkLength; + } + + pos += chunkLength; + first = false; + } while (pos < dataLen); + + return new ArraySegment(rented, 0, total); + } + + private static void CopyHeaderBlockSegment(ReadOnlyMemory data, ReadOnlyMemory append, int start, + Span dest) + { + if (start >= data.Length) + { + append.Span.Slice(start - data.Length, dest.Length).CopyTo(dest); + return; + } + + if (start + dest.Length <= data.Length) + { + data.Span.Slice(start, dest.Length).CopyTo(dest); + return; + } + + var fromData = data.Length - start; + data.Span.Slice(start, fromData).CopyTo(dest); + append.Span.Slice(0, dest.Length - fromData).CopyTo(dest.Slice(fromData)); + } + + /// + /// Writes one already-HPACK-encoded header block as a HEADERS (or PUSH_PROMISE) frame followed + /// by as many CONTINUATION frames as needed so that no single frame's payload exceeds the + /// destination's advertised SETTINGS_MAX_FRAME_SIZE (RFC 7540 ?4.2/?6.10). END_HEADERS is set + /// only on the last frame of the sequence; END_STREAM/PRIORITY (when applicable) are set only + /// on the first, matching the semantics of the frame types they belong to. HEADERS/CONTINUATION + /// frames are not subject to flow control (RFC 7540 ?6.9), so no reservation is made here. + /// + private static async Task WriteHeaderBlockAsync(Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, // NOSONAR S107 -- Frame fields are kept explicit in this low-level encoder helper. + int streamId, Http2FrameType type, bool endStream, bool hasPriority, ReadOnlyMemory data, + int maxFrameSize, Stream output) + { + if (maxFrameSize <= 0) maxFrameSize = 16384; + + frameHeader.StreamId = streamId; + + var pos = 0; + var first = true; + do + { + var chunkLength = Math.Min(maxFrameSize, data.Length - pos); + var isLast = pos + chunkLength >= data.Length; + + frameHeader.Type = first ? type : Http2FrameType.Continuation; + frameHeader.Length = chunkLength; + + var flags = (Http2FrameFlag)0; + if (isLast) + { + flags |= Http2FrameFlag.EndHeaders; + } + + if (first) + { + if (endStream) flags |= Http2FrameFlag.EndStream; + if (hasPriority) flags |= Http2FrameFlag.Priority; + } + + frameHeader.Flags = flags; + + frameHeader.CopyToBuffer(frameHeaderBuffer); + await output.WriteAsync(frameHeaderBuffer.AsMemory()); + await output.WriteAsync(data.Slice(pos, chunkLength)); + + pos += chunkLength; + first = false; + } while (pos < data.Length); + } + + internal static async Task SendBody(Http2Settings settings, RequestResponseBase rr, Http2FrameHeader frameHeader, // NOSONAR S107 -- Frame-writing state is kept explicit for this low-level helper. + byte[] frameHeaderBuffer, byte[] buffer, Http2FlowController flow, Stream output, + CancellationToken cancellationToken) + { + var body = rr.CompressBodyAndUpdateContentLength(); + await SendHeader(settings, frameHeader, frameHeaderBuffer, rr, !(rr.HasBody && rr.IsBodyRead), output, false); + + if (rr.HasBody && rr.IsBodyRead) + { + if (body == null) + throw new InvalidOperationException("An HTTP/2 body was marked as read but is unavailable."); + + int streamId = frameHeader.StreamId; + int pos = 0; + while (pos < body.Length) + { + int bodyFrameLength = Math.Min(buffer.Length, body.Length - pos); + Buffer.BlockCopy(body, pos, buffer, 0, bodyFrameLength); + pos += bodyFrameLength; + + await flow.ReserveAsync(streamId, bodyFrameLength, cancellationToken); + + frameHeader.Length = bodyFrameLength; + frameHeader.Type = Http2FrameType.Data; + frameHeader.Flags = pos < body.Length ? (Http2FrameFlag)0 : Http2FrameFlag.EndStream; + + frameHeader.CopyToBuffer(frameHeaderBuffer); + await output.WriteAsync(frameHeaderBuffer.AsMemory(), cancellationToken); + await output.WriteAsync(buffer.AsMemory(0, bodyFrameLength), cancellationToken); + } + } + } + + /// + /// Sends the given bytes as one or more HTTP/2 DATA frames on the specified stream, splitting on + /// the peer's max frame size. An END_STREAM flag is set on the final frame when endStream is true. + /// Each frame's payload is reserved against before being written, so + /// this never exceeds the destination's flow-control window (RFC 7540 ?6.9). + /// + /// + /// Optional socket write lock. When provided, runs + /// before the lock is taken so inbound WINDOW_UPDATE on the peer read loop can still be + /// processed while this writer is waiting for credit. Holding the write lock across + /// ReserveAsync deadlocks HTTP/2 clients (notably .NET HttpClient) once the 64 KiB + /// default window is exhausted — the peer cannot deliver WINDOW_UPDATE if the read loop is + /// blocked trying to take the same lock for control-frame replies. Matches the order used by + /// the main DATA relay. + /// + internal static async ValueTask SendData(Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, int streamId, // NOSONAR S107 -- Frame-writing state is kept explicit for this low-level helper. + ReadOnlyMemory data, bool endStream, int maxFrameSize, Http2FlowController flow, Stream output, + CancellationToken cancellationToken, SemaphoreSlim? writeLock = null) + { + if (maxFrameSize <= 0) maxFrameSize = 16384; + + frameHeader.StreamId = streamId; + frameHeader.Type = Http2FrameType.Data; + + if (data.Length == 0) + { + if (writeLock != null) await writeLock.WaitAsync(cancellationToken); + try + { + frameHeader.Length = 0; + frameHeader.Flags = endStream ? Http2FrameFlag.EndStream : (Http2FrameFlag)0; + frameHeader.CopyToBuffer(frameHeaderBuffer); + await output.WriteAsync(frameHeaderBuffer.AsMemory(), cancellationToken); + } + finally + { + writeLock?.Release(); + } + + return; + } + + var pos = 0; + while (pos < data.Length) + { + var frameLength = Math.Min(maxFrameSize, data.Length - pos); + var isLastFrame = pos + frameLength >= data.Length; + + // Always reserve outside writeLock (see parameter remarks). + await flow.ReserveAsync(streamId, frameLength, cancellationToken); + + if (writeLock != null) await writeLock.WaitAsync(cancellationToken); + try + { + frameHeader.Length = frameLength; + frameHeader.Flags = isLastFrame && endStream ? Http2FrameFlag.EndStream : (Http2FrameFlag)0; + frameHeader.CopyToBuffer(frameHeaderBuffer); + await output.WriteAsync(frameHeaderBuffer.AsMemory(), cancellationToken); + await output.WriteAsync(data.Slice(pos, frameLength), cancellationToken); + } + finally + { + writeLock?.Release(); + } + + pos += frameLength; + } + } + + /// Writes an RST_STREAM frame (RFC 7540 ?6.4) resetting the given stream with the given error code. + internal static ValueTask SendRstStreamAsync(Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, + int streamId, Http2ErrorCode errorCode, Stream output) + { + if (errorCode != Http2ErrorCode.NoError) ProxyMetrics.ParserError("http2"); + + frameHeader.StreamId = streamId; + frameHeader.Type = Http2FrameType.RstStream; + frameHeader.Flags = 0; + frameHeader.Length = 4; + frameHeader.CopyToBuffer(frameHeaderBuffer); + + var payload = new byte[4]; + BinaryPrimitives.WriteInt32BigEndian(payload, (int)errorCode); + return WriteTwoAsync(output, frameHeaderBuffer.AsMemory(0, 9), payload.AsMemory(0, 4)); + } + + /// Writes a GOAWAY frame (RFC 7540 ?6.8) announcing connection-level shutdown with the given error code. + internal static async ValueTask SendGoAwayAsync(Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, + int lastStreamId, Http2ErrorCode errorCode, Stream output) + { + if (errorCode != Http2ErrorCode.NoError) ProxyMetrics.ParserError("http2"); + + frameHeader.StreamId = 0; + frameHeader.Type = Http2FrameType.GoAway; + frameHeader.Flags = 0; + frameHeader.Length = 8; + frameHeader.CopyToBuffer(frameHeaderBuffer); + + var payload = new byte[8]; + BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(0, 4), lastStreamId & 0x7fffffff); + BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(4, 4), (int)errorCode); + await WriteTwoAsync(output, frameHeaderBuffer.AsMemory(0, 9), payload.AsMemory(0, 8)); + + // GOAWAY is often immediately followed by connection teardown (the sending relay returns + // and cancels its peer). Flushing here ensures the frame reaches the wire before the socket + // closes; otherwise clients can observe a TCP RST without ever seeing the error code. + await output.FlushAsync(); + } + + /// Writes a WINDOW_UPDATE frame (RFC 7540 ?6.9) granting the given amount of flow-control credit. + internal static ValueTask SendWindowUpdateAsync(Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, + int streamId, int increment, Stream output) + { + if (increment <= 0) return default; + + frameHeader.StreamId = streamId; + frameHeader.Type = Http2FrameType.WindowUpdate; + frameHeader.Flags = 0; + frameHeader.Length = 4; + frameHeader.CopyToBuffer(frameHeaderBuffer); + + var payload = new byte[4]; + BinaryPrimitives.WriteInt32BigEndian(payload, increment & 0x7fffffff); + return WriteTwoAsync(output, frameHeaderBuffer.AsMemory(0, 9), payload.AsMemory(0, 4)); + } + + /// + /// HPACK-encodes into a rented framed HEADERS/CONTINUATION block. + /// Takes lock(settings.Sync) around encode unless + /// (origin SendAsync already holds writeLock, which serializes the Encoder). + /// + internal static ArraySegment RentFramedHeaders(Http2Settings settings, Http2FrameHeader frameHeader, + byte[] frameHeaderBuffer, RequestResponseBase rr, bool endStream, bool pushPromise = false, + bool encoderAlreadyExclusive = false) + { + if (encoderAlreadyExclusive) + { + var block = EncodeHeaderBlock(settings, rr); + return RentFramedHeaderBlock(frameHeader, frameHeaderBuffer, frameHeader.StreamId, + pushPromise ? Http2FrameType.PushPromise : Http2FrameType.Headers, endStream, + rr.Priority.HasValue, block, settings.MaxFrameSize); + } + + lock (settings.Sync) + { + var block = EncodeHeaderBlock(settings, rr); + return RentFramedHeaderBlock(frameHeader, frameHeaderBuffer, frameHeader.StreamId, + pushPromise ? Http2FrameType.PushPromise : Http2FrameType.Headers, endStream, + rr.Priority.HasValue, block, settings.MaxFrameSize); + } + } + + /// + /// HPACK-encodes and enqueues the framed HEADERS/CONTINUATION bytes. + /// When is set (origin writeLock held), skips the + /// nested settings.Sync — Mac dual-TLS H1→H2 profiles nested-lock + encode under + /// writeLock as the multiplex convoy. + /// + internal static void EnqueueHeader(Http2Settings settings, Http2FrameHeader frameHeader, // NOSONAR S107 -- Frame-writing state is kept explicit for this low-level helper. + byte[] frameHeaderBuffer, RequestResponseBase rr, bool endStream, Http2FrameWriter writer, + bool pushPromise = false, bool encoderAlreadyExclusive = false) + { + var framed = RentFramedHeaders(settings, frameHeader, frameHeaderBuffer, rr, endStream, pushPromise, + encoderAlreadyExclusive); + writer.EnqueueRented(framed.Array!, framed.Count); + } + + /// + /// HPACK-encodes trailing headers into a rented framed HEADERS block. + /// Takes lock(settings.Sync) for the same Encoder/scratch contract as + /// . + /// + internal static ArraySegment RentFramedTrailers(Http2Settings settings, Http2FrameHeader frameHeader, + byte[] frameHeaderBuffer, int streamId, HeaderCollection trailingHeaders, bool endStream) + { + lock (settings.Sync) + { + var encoder = settings.Encoder; + if (encoder == null) + { + encoder = new Encoder(RfcDefaultHeaderTableSize); + settings.Encoder = encoder; + } + + var ms = settings.GetEncodeStream(); + var writerBuf = settings.GetEncodeWriter(); + + var minSizeT = settings.MinHeaderTableSizeSinceLastEncode; + var curSizeT = settings.HeaderTableSize; + if (encoder.MaxHeaderTableSize != minSizeT) + encoder.SetMaxHeaderTableSize(writerBuf, minSizeT); + if (encoder.MaxHeaderTableSize != curSizeT) + encoder.SetMaxHeaderTableSize(writerBuf, curSizeT); + settings.NotifyHeaderBlockEncoded(); + + foreach (var header in trailingHeaders) + { + var nameData = header.NameData; + if (HasUpperCaseAscii(nameData)) + nameData = AsciiToLowerByteString(nameData); + encoder.EncodeHeader(writerBuf, nameData, header.ValueData); + } + + writerBuf.Flush(); + + return RentFramedHeaderBlock(frameHeader, frameHeaderBuffer, streamId, + Http2FrameType.Headers, endStream, false, GetMemoryStreamMemory(ms), settings.MaxFrameSize); + } + } + + internal static void EnqueueTrailer(Http2Settings settings, Http2FrameHeader frameHeader, + byte[] frameHeaderBuffer, int streamId, HeaderCollection trailingHeaders, bool endStream, + Http2FrameWriter writer) + { + var framed = RentFramedTrailers(settings, frameHeader, frameHeaderBuffer, streamId, + trailingHeaders, endStream); + writer.EnqueueRented(framed.Array!, framed.Count); + } + + /// + /// Frames as one or more DATA frames and enqueues them. Caller must + /// already have reserved flow-control credit for the payload. Does not take a lock — the + /// dedicated writer serializes bytes. + /// + internal static void EnqueueDataFrames(Http2FrameWriter writer, int streamId, ReadOnlyMemory data, + bool endStream, int maxFrameSize) + { + if (maxFrameSize <= 0) maxFrameSize = 16384; + + if (data.Length == 0) + { + EnqueueControlFrame(writer, Http2FrameType.Data, + endStream ? Http2FrameFlag.EndStream : 0, streamId, ReadOnlySpan.Empty); + return; + } + + var pos = 0; + while (pos < data.Length) + { + var frameLength = Math.Min(maxFrameSize, data.Length - pos); + var isLast = pos + frameLength >= data.Length; + var flags = isLast && endStream ? Http2FrameFlag.EndStream : (Http2FrameFlag)0; + EnqueueControlFrame(writer, Http2FrameType.Data, flags, streamId, + data.Span.Slice(pos, frameLength)); + pos += frameLength; + } + } + + internal static void EnqueueRstStream(Http2FrameWriter writer, int streamId, Http2ErrorCode errorCode) + { + if (errorCode != Http2ErrorCode.NoError) ProxyMetrics.ParserError("http2"); + + Span payload = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(payload, (int)errorCode); + EnqueueControlFrame(writer, Http2FrameType.RstStream, 0, streamId, payload); + } + + internal static void EnqueueWindowUpdate(Http2FrameWriter writer, int streamId, int increment) + { + if (increment <= 0) return; + + Span payload = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(payload, increment & 0x7fffffff); + EnqueueControlFrame(writer, Http2FrameType.WindowUpdate, 0, streamId, payload); + } + + internal static void EnqueueSettingsAck(Http2FrameWriter writer) + { + EnqueueControlFrame(writer, Http2FrameType.Settings, Http2FrameFlag.Ack, 0, ReadOnlySpan.Empty); + } + + internal static void EnqueuePingAck(Http2FrameWriter writer, ReadOnlySpan payload) + { + EnqueueControlFrame(writer, Http2FrameType.Ping, Http2FrameFlag.Ack, 0, payload); + } + + /// + /// Copies a fully-formed frame into a rented buffer and transfers ownership to + /// . Safe to call without the origin write lock — DATA and + /// control frames do not mutate the HPACK table. + /// + internal static void EnqueueControlFrame(Http2FrameWriter writer, Http2FrameType type, + Http2FrameFlag flags, int streamId, ReadOnlySpan payload) + { + var total = 9 + payload.Length; + var rented = ArrayPool.Shared.Rent(total); + var header = new Http2FrameHeader + { + Type = type, + Flags = flags, + StreamId = streamId, + Length = payload.Length + }; + header.CopyToBuffer(rented); + if (payload.Length > 0) + payload.CopyTo(rented.AsSpan(9)); + writer.EnqueueRented(rented, total); + } + + private static ValueTask AsValueTask(Task task) => new(task); + + /// + /// Writes two buffers back-to-back without an async state machine when both complete synchronously. + /// + private static ValueTask WriteTwoAsync(Stream output, ReadOnlyMemory first, ReadOnlyMemory second, + CancellationToken cancellationToken = default) + { + var firstVt = output.WriteAsync(first, cancellationToken); + if (!firstVt.IsCompletedSuccessfully) + return WriteTwoSlowAsync(output, firstVt, second, cancellationToken); + + return output.WriteAsync(second, cancellationToken); + } + + private static async ValueTask WriteTwoSlowAsync(Stream output, ValueTask firstVt, ReadOnlyMemory second, + CancellationToken cancellationToken) + { + await firstVt; + await output.WriteAsync(second, cancellationToken); + } + + /// + /// Relays a 1xx interim response (e.g. 103 Early Hints) from an external bridge (H2→H3) to the + /// client as a HEADERS frame without END_STREAM. Mirrors the native H2 interim path in + /// ProcessCompleteHeaderBlockAsync. Flushing after the write is required so Navigation + /// Timing responseStart can move before the final response arrives. + /// + internal static async Task EmitInterimResponseAsync(SessionEventArgs args, int streamId, + Http2ConnectionState connectionState, Stream clientStream, Response interim, + CancellationToken cancellationToken) + { + await connectionState.ServerSettingsRelayed.Task.WaitAsync(cancellationToken); + + interim.Headers.RemoveHeader(KnownHeaders.Connection); + interim.Headers.RemoveHeader("Keep-Alive"); + interim.Headers.RemoveHeader(KnownHeaders.ProxyConnection); + interim.Headers.RemoveHeader(KnownHeaders.TransferEncoding); + interim.Headers.RemoveHeader(KnownHeaders.Upgrade); + + var frameHeader = new Http2FrameHeader { StreamId = streamId }; + var frameHeaderBuffer = new byte[9]; + + // QueueSendHeader locks ClientSettings for HPACK and admits onto the client frame FIFO — + // same ordering as final responses. SendHeader under ClientWriteLock alone raced concurrent + // QueueSendHeader encodes on the shared dynamic table. + QueueSendHeader(connectionState, towardServer: false, connectionState.ClientWriteLock, + connectionState.ClientSettings, frameHeader, frameHeaderBuffer, interim, + endStream: false, clientStream, pushPromise: false); + + // Flush so Navigation Timing responseStart can move before the final response arrives. + await connectionState.ClientWriteLock.WaitAsync(cancellationToken); + try + { + await clientStream.FlushAsync(cancellationToken); + } + finally + { + connectionState.ClientWriteLock.Release(); + } + } + + /// + /// Emits a proxy-generated (synthetic) response to the client on the given stream without relaying + /// the corresponding server response - either because the request never reached the server (a + /// BeforeRequest-time Ok/GenericResponse/Redirect/Respond/ + /// RespondStreaming call) or because a real response was received and then replaced (a + /// BeforeResponse-time Respond call). Three body shapes are supported, mirroring the + /// buffered/streamed distinction SessionEventArgs already exposes for HTTP/1.x: + /// + /// StreamBodyWriter set (RespondStreaming) - the body is produced on the fly + /// and written as DATA frames without ever being buffered. + /// otherwise, a buffered body (Ok/GenericResponse/Redirect/buffered + /// Respond) - the already-in-memory bytes are compressed (if requested) and sent as DATA + /// frames. + /// otherwise, no body at all - END_STREAM is set directly on the HEADERS frame. + /// + /// HTTP/2 frames the body with DATA/END_STREAM (Transfer-Encoding is never used over h2), so the + /// chunked header is always stripped regardless of which shape applies. + /// + internal static async Task EmitSyntheticResponseAsync(SessionEventArgs args, int streamId, + Http2ConnectionState connectionState, Stream clientStream, CancellationToken cancellationToken, + Func? onAfterResponse = null, ILogger? logger = null) + { + var response = args.HttpClient.Response; + + var frameHeader = new Http2FrameHeader { StreamId = streamId }; + var frameHeaderBuffer = new byte[9]; + + // The client must receive the connection SETTINGS frame (relayed from the server) before any + // HEADERS frame, otherwise it treats the connection as a protocol error. Wait for that relay, + // but honor cancellation so we never hang if the server never sends SETTINGS / closes early. + // Steady-state: SETTINGS already relayed — skip WaitAsync Task machinery per synthetic emit. + var settingsTask = connectionState.ServerSettingsRelayed.Task; + if (!settingsTask.IsCompletedSuccessfully) + await settingsTask.WaitAsync(cancellationToken); + + var streamBodyWriter = response.StreamBodyWriter; + if (streamBodyWriter != null) + { + await EmitStreamedSyntheticResponseAsync(response, streamBodyWriter, connectionState, + frameHeader, frameHeaderBuffer, clientStream, cancellationToken); + } + else + { + await EmitBufferedSyntheticResponseAsync(response, streamId, connectionState, frameHeader, + frameHeaderBuffer, clientStream, cancellationToken); + } + + response.IsBodySent = true; + MarkSyntheticResponseClosed(streamId, connectionState, onAfterResponse, logger); + } + + private static async Task EmitStreamedSyntheticResponseAsync(Response response, + Func streamBodyWriter, + Http2ConnectionState connectionState, Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, + Stream clientStream, CancellationToken cancellationToken) + { + var streamId = frameHeader.StreamId; + var clientSendFlow = connectionState.ClientSendFlow; + + // HTTP/2 does not use chunked transfer-encoding; body framing is done via DATA frames + END_STREAM. + response.Headers.RemoveHeader(KnownHeaders.TransferEncoding); + + // Keep origin Content-Length when known. Short delivery used to END_STREAM with a + // truncated body and poison Chrome (YouTube blank tab); we now RST on length mismatch + // instead, so advertising CL is safe and matches Kestrel/YARP (and avoids an extra empty + // END_STREAM DATA when the last payload frame can carry the flag). + var advertisedLength = response.ContentLength; + + // HEADERS and every DATA frame for this stream flow through the dedicated client frame + // writer's FIFO (QueueSendHeader + Http2BodyStreamWriter), which guarantees both that this + // stream's DATA can never overtake its HEADERS and that no other stream's frames interleave + // bytes - the same guarantees the previous hold-ClientWriteLock-across-HEADERS+first-DATA + // approach provided, but without serializing every multiplexed stream's response emission on + // one semaphore with several small syscalls each (measured as the dominant cost on the + // h2→h1 bridge arms: 2:1 sys:user CPU with all in-flight streams parked on this path). + QueueSendHeader(connectionState, towardServer: false, connectionState.ClientWriteLock, + connectionState.ClientSettings, frameHeader, frameHeaderBuffer, response, false, + clientStream, false); + + var maxFrameSize = connectionState.ClientSettings.MaxFrameSize; + if (maxFrameSize <= 0) maxFrameSize = 16384; + + var bodyWriter = new Http2BodyStreamWriter(streamId, connectionState, clientStream, clientSendFlow, + cancellationToken, advertisedLength, maxFrameSize); + + await streamBodyWriter(bodyWriter, cancellationToken); + + // Origin advertised a length but delivered a different amount. Prefer RST over a + // successful-looking END_STREAM so the browser retries instead of caching/executing + // a truncated body. + if (advertisedLength >= 0 && bodyWriter.BytesWritten != advertisedLength) + { + QueueRstStreamFrame(connectionState, clientStream, streamId, Http2ErrorCode.InternalError); + } + else + { + await bodyWriter.CompleteAsync(); + } + } + + private static async Task EmitBufferedSyntheticResponseAsync(Response response, int streamId, + Http2ConnectionState connectionState, Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, + Stream clientStream, CancellationToken cancellationToken) + { + var clientWriteLock = connectionState.ClientWriteLock; + var clientSendFlow = connectionState.ClientSendFlow; + + // buffered case (Ok/GenericResponse/Redirect/buffered Respond / H2→H3 bridge) - the whole + // body, if any, is already in memory. Compress WHILE Transfer-Encoding: chunked may still + // be present: Response.HasBody treats CL=-1 + chunked as "has body", and stripping TE + // first made HasBody false so CompressBodyAndUpdateContentLength zeroed Content-Length + // and dropped the buffered bytes (empty CDN JS/CSS through the H2→H3 bridge). + // Fast path: bridge already buffered a fixed-CL body that is ready for the wire + // (no content-encoding, or BodyIsWireEncoded from eager-buffer — do not re-compress). + byte[]? body; + if (response.IsBodyRead && response.BodyAvailable + && (response.ContentEncoding == null || response.BodyIsWireEncoded) + && !response.IsChunked && response.ContentLength >= 0) + { + body = response.Body; + if (body.Length != response.ContentLength) + body = response.CompressBodyAndUpdateContentLength(); + } + else + { + body = response.CompressBodyAndUpdateContentLength(); + } + + // HTTP/2 does not use chunked transfer-encoding; body framing is done via DATA frames. + response.Headers.RemoveHeader(KnownHeaders.TransferEncoding); + if (body is { Length: > 0 } && response.ContentLength < 0) + response.ContentLength = body.Length; + + var hasBody = body is { Length: > 0 }; + var maxFrameSize = connectionState.ClientSettings.MaxFrameSize; + if (maxFrameSize <= 0) maxFrameSize = 16384; + + // Queue HEADERS (+ DATA below) on the dedicated client frame writer instead of direct + // lock+write+flush: the FIFO preserves per-stream frame order, and the drain task coalesces + // frames from many concurrent bridge streams into few socket writes (see + // EmitStreamedSyntheticResponseAsync for the measurements behind this). No body at all: + // END_STREAM belongs on the HEADERS frame itself, there is no DATA frame to carry it. + QueueSendHeader(connectionState, towardServer: false, clientWriteLock, + connectionState.ClientSettings, frameHeader, frameHeaderBuffer, response, !hasBody, + clientStream, false); + + if (!hasBody) + return; + + // Reserve flow-control credit per frame before queueing so queued-but-unsent DATA can never + // exceed the client's advertised windows. + var bodyPos = 0; + while (bodyPos < body!.Length) + { + var frameLength = Math.Min(maxFrameSize, body.Length - bodyPos); + await clientSendFlow.ReserveAsync(streamId, frameLength, cancellationToken); + QueueDataFrame(connectionState, clientStream, streamId, + body.AsMemory(bodyPos, frameLength), endStream: bodyPos + frameLength >= body.Length); + bodyPos += frameLength; + } + } + + private static void MarkSyntheticResponseClosed(int streamId, Http2ConnectionState connectionState, + Func? onAfterResponse, ILogger? logger) + { + // Synthetic writes never produce an inbound END_STREAM for the response half, so mark + // ResponseClosed here. Finalize only when the request half is already done — do not force + // RequestClosed while the client may still be uploading (would race Dispose with the frame loop). + if (!connectionState.Streams.TryGetValue(streamId, out var streamState)) + return; + + streamState.ResponseClosed = true; + if (!streamState.IsClosed || onAfterResponse == null || logger == null) + return; + + connectionState.RemoveStream(streamId); + ScheduleFinalize(streamState, onAfterResponse, logger, connectionState); + } + + private static async Task ForceRead(Stream input, byte[] buffer, int offset, int bytesToRead, + CancellationToken cancellationToken) + { + int totalRead = 0; + while (bytesToRead > 0) + { + int read = await input.ReadAsync(buffer.AsMemory(offset, bytesToRead), cancellationToken); + if (read == 0) + { + break; + } + + totalRead += read; + bytesToRead -= read; + offset += read; + } + + return totalRead; + } + + /// + /// Best-effort drain of a rejected frame's still-incoming payload before this connection is torn down + /// in response to it (e.g. a declared length over - see the + /// FRAME_SIZE_ERROR checks above). The peer typically has already written (or is still writing) that + /// payload; if this leg's socket is closed while those bytes are still sitting unread in the OS + /// receive buffer, some platforms/stacks perform an abortive RST close instead of a graceful one, + /// which can also swallow the GOAWAY/RST_STREAM frame just flushed to the peer - turning an + /// intentionally clean protocol-error response into what looks like an unrelated connection failure. + /// Bounded by and a short timeout so a peer that declares a huge length and + /// then stalls cannot use this to hang the relay. + /// + private static async Task DiscardRejectedFramePayloadAsync(Stream input, int length, // NOSONAR S1144 -- reflection test seam + CancellationToken cancellationToken) + { + try + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromSeconds(2)); + + var remaining = Math.Min(length, 1024 * 1024); + var buffer = new byte[Math.Min(remaining, MaxAcceptableFrameSize)]; + while (remaining > 0) + { + var read = await ForceRead(input, buffer, 0, Math.Min(remaining, buffer.Length), cts.Token); + if (read <= 0) break; + remaining -= read; + } + } + catch + { + // best-effort only - if the peer is already gone or this times out, there is nothing further to + // do; the caller proceeds to tear down the connection either way. + } + } + + /// + /// A write-only stream handed to consumers of RespondStreaming over HTTP/2. Each write is emitted as + /// one or more DATA frames on the given stream (split at the guaranteed-safe 16384 byte frame size). + /// The terminating empty END_STREAM DATA frame is sent by . + /// Frames are flow-reserved by the producing task and then queued on the connection's dedicated + /// client frame writer (same FIFO as the HEADERS queued by ), so + /// per-stream frame order is preserved, no other stream's bytes can interleave, and the drain task + /// coalesces frames across streams into single socket writes instead of serializing every response + /// on with one small syscall per frame. + /// + internal sealed class Http2BodyStreamWriter : Stream + { + private readonly int streamId; + private readonly Http2ConnectionState connectionState; + private readonly Stream clientStream; + private readonly Http2FlowController flow; + private readonly CancellationToken cancellationToken; + private readonly long expectedLength; + private readonly int maxFrameSize; + private bool endStreamSent; + private bool completed; + + /// + /// Known Content-Length (≥0) so the last DATA can carry END_STREAM; −1 for + /// chunked/close-delimited bodies that need an empty END_STREAM DATA after the pump. + /// + internal Http2BodyStreamWriter(int streamId, Http2ConnectionState connectionState, Stream clientStream, + Http2FlowController flow, CancellationToken cancellationToken, long expectedLength = -1, + int maxFrameSize = 16384) + { + this.streamId = streamId; + this.connectionState = connectionState; + this.clientStream = clientStream; + this.flow = flow; + this.cancellationToken = cancellationToken; + this.expectedLength = expectedLength; + this.maxFrameSize = maxFrameSize > 0 ? maxFrameSize : 16384; + } + + /// Total body octets written as DATA (excludes any empty END_STREAM frame). + internal long BytesWritten { get; private set; } + + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException("Use WriteAsync."); + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + return WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) + { + if (buffer.IsEmpty) return; + + // Reserve flow-control credit per frame BEFORE queueing so queued-but-unsent DATA can + // never exceed the client's advertised windows (bounds writer-queue memory too). + var pos = 0; + while (pos < buffer.Length) + { + var frameLength = Math.Min(maxFrameSize, buffer.Length - pos); + var endStream = false; + if (expectedLength >= 0) + { + var remaining = expectedLength - BytesWritten - pos; + if (remaining <= 0) + break; + if (frameLength > remaining) + frameLength = (int)remaining; + endStream = BytesWritten + pos + frameLength >= expectedLength; + } + + await flow.ReserveAsync(streamId, frameLength, this.cancellationToken); + QueueDataFrame(connectionState, clientStream, streamId, buffer.Slice(pos, frameLength), + endStream); + if (endStream) + endStreamSent = true; + pos += frameLength; + } + + BytesWritten += pos; + } + + /// + /// Reads origin bytes directly into pre-sized DATA frame buffers (header + payload), + /// reserves flow-control credit, and enqueues for the client frame writer. + /// + internal async Task CopyFromAsync(Func, CancellationToken, ValueTask> readAsync, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + CancellationToken cancellationToken) + { + while (true) + { + var remaining = expectedLength >= 0 + ? expectedLength - BytesWritten + : maxFrameSize; + if (expectedLength >= 0 && remaining <= 0) + break; + + var payloadCap = (int)Math.Min(maxFrameSize, remaining); + var rented = ArrayPool.Shared.Rent(9 + payloadCap); + var read = 0; + try + { + while (read < payloadCap) + { + var n = await readAsync(rented.AsMemory(9 + read, payloadCap - read), cancellationToken) + .ConfigureAwait(false); + if (n == 0) + break; + read += n; + } + + if (read == 0) + { + ArrayPool.Shared.Return(rented); + rented = null!; + break; + } + + var endStream = expectedLength >= 0 && BytesWritten + read >= expectedLength; + // Prefer non-blocking reserve when the peer window already has room (typical + // after SETTINGS / WINDOW_UPDATE); avoid a Task alloc per 16 KiB frame. + if (!flow.TryReserve(streamId, read)) + await flow.ReserveAsync(streamId, read, this.cancellationToken).ConfigureAwait(false); + var dataFrameHeader = new Http2FrameHeader + { + StreamId = streamId, + Type = Http2FrameType.Data, + Length = read, + Flags = endStream ? Http2FrameFlag.EndStream : 0 + }; + dataFrameHeader.CopyToBuffer(rented); + connectionState.EnqueueWriteRented(towardServer: false, connectionState.ClientWriteLock, + clientStream, rented, 9 + read); + rented = null!; // ownership transferred + BytesWritten += read; + if (endStream) + { + endStreamSent = true; + break; + } + } + finally + { + if (rented != null) + ArrayPool.Shared.Return(rented); + } + } + } + + internal Task CompleteAsync() + { + if (completed) return Task.CompletedTask; + completed = true; + + // Known-CL path already put END_STREAM on the last payload DATA. + if (endStreamSent) + return Task.CompletedTask; + + // Empty END_STREAM needs no flow-control credit (chunked / unknown length / empty body). + QueueDataFrame(connectionState, clientStream, streamId, ReadOnlyMemory.Empty, + endStream: true); + endStreamSent = true; + return Task.CompletedTask; + } + } + + /// + /// HPACK listener that discards decoded headers. Used on the compressed-relay path so the + /// connection-scoped dynamic table stays in sync without allocating a . + /// + private sealed class NoOpHeaderListener : IHeaderListener + { + public static readonly NoOpHeaderListener Instance = new(); // NOSONAR S1144 -- reserved singleton for compressed-relay decode + + public void AddHeader(ByteString name, ByteString value, bool sensitive) + { + } + } + + // internal for unit tests that assert RFC 7540/8441 header-block validation contracts + internal class MyHeaderListener : IHeaderListener + { + private Action? addHeaderFunc; + private HeaderCollection? decodeTarget; + + /// + /// when this block is for a request (client→proxy direction). + /// Used to enforce the RFC 7540 §8.1.2.3 pseudo-header allow-lists: request fields + /// (:method, :authority, :scheme, :path, :protocol) are forbidden in response blocks and + /// :status is forbidden in request blocks. + /// + private readonly bool isRequest; + + // Per-pseudo-header "seen" flags for duplicate detection (RFC 7540 §8.1.2.1). + private bool sawMethod, sawStatus, sawAuthority, sawScheme, sawPath, sawProtocol; + + // RFC 7540 §8.1.2.1: pseudo-header fields MUST NOT appear after a regular header field. + private bool seenRegularHeader; + + public ByteString Method { get; private set; } + + public ByteString Status { get; private set; } + + public ByteString Authority { get; private set; } + + private ByteString scheme; + + public ByteString Path { get; private set; } + + /// RFC 8441 §5: the :protocol pseudo-header value for an extended CONNECT request. + public ByteString Protocol { get; private set; } + + /// + /// Set when this header block contained an unknown pseudo-header field, a field name with + /// uppercase characters, a duplicate pseudo-header, a pseudo-header that belongs to the + /// wrong message direction, or a pseudo-header that appears after a regular header field. + /// All are malformed per RFC 7540 §8.1.2 and the block's stream must be reset. + /// + public bool HasMalformedHeader { get; private set; } + + public string? MalformedReason { get; private set; } + + /// Raw ':scheme' value bytes as sent by the peer (empty when the block has none). + public ByteString RawScheme => scheme; + + public string Scheme + { + get + { + if (scheme.Equals(ProxyServer.UriSchemeHttp8)) + { + return ProxyServer.UriSchemeHttp; + } + + if (scheme.Equals(ProxyServer.UriSchemeHttps8)) + { + return ProxyServer.UriSchemeHttps; + } + + return string.Empty; + } + } + + public MyHeaderListener(Action addHeaderFunc, bool isRequest) + { + this.addHeaderFunc = addHeaderFunc; + this.isRequest = isRequest; + } + + /// Connection-scoped decode listener — target is rebound via . + public MyHeaderListener(HeaderCollection decodeTarget, bool isRequest) + { + this.decodeTarget = decodeTarget; + this.isRequest = isRequest; + } + + /// Clear per-block state so this listener can decode the next HEADERS on the same connection. + public void ResetForDecode(HeaderCollection target) + { + decodeTarget = target; + addHeaderFunc = null; + Method = default; + Status = default; + Authority = default; + scheme = default; + Path = default; + Protocol = default; + sawMethod = sawStatus = sawAuthority = sawScheme = sawPath = sawProtocol = false; + seenRegularHeader = false; + HasMalformedHeader = false; + MalformedReason = null; + } + + public void AddHeader(HttpHeader header, bool sensitive) => + AddHeader(header.NameData, header.ValueData, sensitive, header); + + public void AddHeader(ByteString name, ByteString value, bool sensitive) => + AddHeader(name, value, sensitive, prebuilt: null); + + private void AddHeader(ByteString name, ByteString value, bool sensitive, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + HttpHeader? prebuilt) + { + if (name.Length > 0 && name.Span[0] == ':') + { + // RFC 7540 §8.1.2.1: pseudo-header fields MUST NOT appear after a regular header field. + if (seenRegularHeader) + { + if (!HasMalformedHeader) + { + HasMalformedHeader = true; + MalformedReason = "pseudo-header field after a regular header field"; + } + return; + } + + // Byte-match known pseudos — avoid Encoding.ASCII.GetString per field on the MITM decode path. + var n = name.Span; + if (n.SequenceEqual(":method"u8)) + { + if (!isRequest || sawMethod) + { + MarkMalformed(isRequest + ? "duplicate pseudo-header field ':method'" + : "request pseudo-header ':method' in a response block"); + return; + } + sawMethod = true; + Method = value; + return; + } + + if (n.SequenceEqual(":authority"u8)) + { + if (!isRequest || sawAuthority) + { + MarkMalformed(isRequest + ? "duplicate pseudo-header field ':authority'" + : "request pseudo-header ':authority' in a response block"); + return; + } + sawAuthority = true; + Authority = value; + return; + } + + if (n.SequenceEqual(":scheme"u8)) + { + if (!isRequest || sawScheme) + { + MarkMalformed(isRequest + ? "duplicate pseudo-header field ':scheme'" + : "request pseudo-header ':scheme' in a response block"); + return; + } + sawScheme = true; + scheme = value; + return; + } + + if (n.SequenceEqual(":path"u8)) + { + if (!isRequest || sawPath) + { + MarkMalformed(isRequest + ? "duplicate pseudo-header field ':path'" + : "request pseudo-header ':path' in a response block"); + return; + } + sawPath = true; + Path = value; + return; + } + + if (n.SequenceEqual(":status"u8)) + { + if (isRequest || sawStatus) + { + MarkMalformed(!isRequest + ? "duplicate pseudo-header field ':status'" + : "response pseudo-header ':status' in a request block"); + return; + } + sawStatus = true; + Status = value; + return; + } + + if (n.SequenceEqual(":protocol"u8)) + { + // RFC 8441 §5: only valid on CONNECT requests. + if (!isRequest || sawProtocol) + { + MarkMalformed(isRequest + ? "duplicate pseudo-header field ':protocol'" + : "request pseudo-header ':protocol' in a response block"); + return; + } + sawProtocol = true; + Protocol = value; + return; + } + + MarkMalformed($"unknown pseudo-header field '{Encoding.ASCII.GetString(n)}'"); + return; + } + + seenRegularHeader = true; + + if (!HasMalformedHeader) + { + foreach (var b in name.Span) + { + if (b is >= (byte)'A' and <= (byte)'Z') + { + HasMalformedHeader = true; + MalformedReason = "header field name contains uppercase characters"; + break; + } + } + } + + addHeaderFunc?.Invoke(name, value); + if (prebuilt != null) + decodeTarget?.AddHeader(prebuilt); + else + decodeTarget?.AddHeader(new HttpHeader(name, value)); + } + + private void MarkMalformed(string reason) + { + if (!HasMalformedHeader) + { + HasMalformedHeader = true; + MalformedReason = reason; + } + } + + public Uri GetUri() + { + if (Authority.Length == 0) + throw new InvalidOperationException( + "HTTP/2 request is missing the :authority pseudo-header."); + + var bytes = new byte[scheme.Length + 3 + Authority.Length + Path.Length]; + scheme.Span.CopyTo(bytes); + int idx = scheme.Length; + bytes[idx++] = (byte)':'; + bytes[idx++] = (byte)'/'; + bytes[idx++] = (byte)'/'; + Authority.Span.CopyTo(bytes.AsSpan(idx, Authority.Length)); + idx += Authority.Length; + Path.Span.CopyTo(bytes.AsSpan(idx, Path.Length)); + + return new Uri(HttpHeader.Encoding.GetString(bytes)); + } + } + } +} diff --git a/src/Titanium.Web.Proxy/Http2/Http2Helper.cs b/src/Titanium.Web.Proxy/Http2/Http2Helper.cs index 4da9842b7..5f3887a0c 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2Helper.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2Helper.cs @@ -40,7 +40,7 @@ internal sealed class Http2HeaderListTooLargeException : IOException internal Http2HeaderListTooLargeException(string message) : base(message) { } } - internal class Http2Helper + internal partial class Http2Helper { public static readonly byte[] ConnectionPreface = Encoding.ASCII.GetBytes("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); @@ -246,6 +246,7 @@ internal static async Task SendHttp2(Stream clientStream, Stream serverStream, / // streams may relay via the primary leg while mutated streams re-encode through // QueueSendHeaderTowardServer onto the same serverStream — enabling the pool would // dispose ServerFrameWriter and race those writes with the pool's primary Writer. + // Tip A/B (MITM MaxOrigin=2 + pool): Lite err%~29 / RSS blow-up — keep disabled. var useMultiOrigin = canCompressedRelayTopology && !httpInterceptionEnabled && openOriginConnectionAsync != null @@ -442,5204 +443,5 @@ void EnsureLegReceive(Http2OriginRelayPool.OriginLeg leg) } } } - - /// - /// Runs and disposes 's - /// exactly once, guarded by - /// so concurrent callers (the normal end-stream - /// path, RST_STREAM, and final connection-teardown cleanup all race to finalize the same stream) - /// never run it twice or race Dispose against a still-running AfterResponse. - /// - internal static async Task FinalizeStreamAsync(Http2StreamState state, - Func onAfterResponse, ILogger logger, - Http2ConnectionState? connectionState = null) - { - if (Interlocked.CompareExchange(ref state.FinalizedFlag, 1, 0) != 0) - { - return; - } - - // Compressed-relay streams never allocate SessionEventArgs. CTS is TryReset in PrepareForPool. - if (state.SessionArgs == null) - { - connectionState?.ReturnStreamState(state); - return; - } - - try - { - var requestDispatch = state.SessionArgs.HttpClient.Request.Http2BeforeHandlerTask; - var responseDispatch = state.SessionArgs.HttpClient.Response.Http2BeforeHandlerTask; - if (requestDispatch != null) - await requestDispatch; - if (responseDispatch != null && !ReferenceEquals(responseDispatch, requestDispatch)) - await responseDispatch; - - await onAfterResponse(state.SessionArgs); - } - catch (Exception ex) - { - ReportException(logger, new ProxyHttpException("HTTP/2 AfterResponse handler failed", ex, - state.SessionArgs)); - } - finally - { - state.SessionArgs.Dispose(); - connectionState?.ReturnStreamState(state); - } - } - - /// - /// Schedules finalize. Compressed-relay finalize is synchronous (no SessionEventArgs) — avoid - /// allocating a into . - /// - private static void ScheduleFinalize(Http2StreamState state, - Func onAfterResponse, ILogger logger, - Http2ConnectionState connectionState) - { - if (state.IsCompressedRelay && state.SessionArgs == null) - { - // Inline hot path: FinalizedFlag + pool return. Prefer TryReset over dispose+new CTS. - if (Interlocked.CompareExchange(ref state.FinalizedFlag, 1, 0) != 0) - return; - connectionState.ReturnStreamState(state); - return; - } - - connectionState.PendingFinalizations.Track( - FinalizeStreamAsync(state, onAfterResponse, logger, connectionState)); - } - - /// - /// Upper bound on the total compressed bytes buffered for one in-progress HEADERS/CONTINUATION - /// sequence, so a peer that never sends END_HEADERS cannot grow memory unboundedly. - /// - private const int MaxHeaderBlockBytes = 256 * 1024; - - private static readonly HashSet ForbiddenConnectionSpecificHeaders = new(StringComparer.OrdinalIgnoreCase) - { - "connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade" - }; - - /// - /// Header fields that RFC 7540 §8.1.2.2 / RFC 9110 §6.5.1 forbid in HTTP/2 trailer sections. - /// - private static readonly HashSet ForbiddenTrailerHeaders = new(StringComparer.OrdinalIgnoreCase) - { - "transfer-encoding", "content-length", "host", "trailer" - }; - - private static async Task CopyHttp2FrameAsync(Stream input, Stream output, // NOSONAR S3776, CA1068 -- Protocol flow and established token position are retained. - Http2ConnectionState connectionState, - Func sessionFactory, - Func onBeforeRequestResponse, - Func onAfterResponse, - Action? prepareRequestHeaders, - bool isClient, - CancellationToken cancellationToken, - ILogger logger, - int maxDecodedHeaderListBytes = 64 * 1024, - bool enableRfc8441 = false, - ProxyResourceLimits? resourceLimits = null, - TcpServerConnection? originConnection = null, - bool httpInterceptionEnabled = true, - Func? shouldInterceptHttp = null, - Http2OriginRelayPool.OriginLeg? originReceiveLeg = null, - bool forceStaticHpackForMitmUnchangedRelay = false) - { - resourceLimits ??= ProxyResourceLimits.Default; - var cancellationTokenSource = connectionState.CancellationTokenSource; - - // Same-protocol H2↔H2 (not NullOrigin / RFC 8441): compressed-relay topology. - // Gate-off: full compressed-relay (no SessionEventArgs). Gate-on (transparent/socks): - // decode + handlers, then relay compressed bytes when unchanged — requires static HPACK. - // Explicit MITM re-encodes (Via) and must not force HEADER_TABLE_SIZE=0. - bool canCompressedRelayTopology = !enableRfc8441 - && output is not NullOriginStream - && input is not NullOriginStream; - bool useCompressedRelay = canCompressedRelayTopology && !httpInterceptionEnabled; - bool forceStaticHpackTable = useCompressedRelay - || (canCompressedRelayTopology && httpInterceptionEnabled - && forceStaticHpackForMitmUnchangedRelay); - if (forceStaticHpackTable) - { - // Static-table-only on both legs so compressed blocks are interchangeable. - connectionState.ClientSettings.UpdateHeaderTableSize(0); - connectionState.ServerSettings.UpdateHeaderTableSize(0); - } - - // Mixed-transport passthrough (inbound h2c client → TLS origin, or TLS-terminated client → - // cleartext h2 origin): the verbatim compressed block still carries the client's ':scheme', - // and strict ASP.NET Core origins reset every stream whose :scheme does not match the - // origin transport with RST_STREAM(PROTOCOL_ERROR). Detect the mismatch once here; request - // blocks are then patched/re-encoded with the origin-transport scheme in - // RelayCompressedHeaderBlockAsync. Same-transport connections keep the zero-work verbatim - // relay (decode stays NoOp). - // Apply whenever compressed blocks may be relayed: gate-off (useCompressedRelay) *and* - // MITM unchanged-lite (forceStaticHpackTable) — the latter also calls - // RelayCompressedHeaderBlockAsync with the captured client block. - ByteString compressedRelaySchemeOverride = default; - if (forceStaticHpackTable && isClient && originConnection != null - && input is HttpClientStream { Connection: { } relayClientConnection } - && originConnection.IsHttps == relayClientConnection.Http2CleartextClient) - { - compressedRelaySchemeOverride = originConnection.IsHttps - ? ProxyServer.UriSchemeHttps8 - : ProxyServer.UriSchemeHttp8; - } - - // "Settings describing the peer this task reads from" - used both to size the HPACK decoder for - // header blocks read from that peer, and (SETTINGS handling below) updated directly from that - // peer's own SETTINGS frames, since both describe properties *of that same peer*. - var localSettings = isClient ? connectionState.ClientSettings : connectionState.ServerSettings; - - // "Settings describing the peer this task writes to" - used to size outbound HEADERS/ - // CONTINUATION/DATA framing so it never exceeds what that peer advertised it will accept. - var remoteSettings = isClient ? connectionState.ServerSettings : connectionState.ClientSettings; - - // Flow control governing DATA this task writes toward `output`; replenished by WINDOW_UPDATE/ - // SETTINGS_INITIAL_WINDOW_SIZE frames read from that same peer - necessarily by the *other* - // relay task, since both directions of one leg are read/written by different tasks here. Also - // used by SendBody/SendData for this same output. - var outboundFlow = isClient ? connectionState.ServerSendFlow : connectionState.ClientSendFlow; - - // The lock protecting every write onto `input` itself (same-leg replies: PING ACK, WINDOW_UPDATE - // receive-credit grants, RST_STREAM for a malformed block). - SemaphoreSlim ownLegWriteLock; - if (originReceiveLeg != null) - ownLegWriteLock = originReceiveLeg.WriteLock; - else if (isClient) - ownLegWriteLock = connectionState.ClientWriteLock; - else - ownLegWriteLock = connectionState.ServerWriteLock; - - // The lock protecting every write onto `output` (shared with the other task, which reads from - // `output`'s peer and may itself need to reply directly on it). - var outputWriteLock = isClient ? connectionState.ServerWriteLock : connectionState.ClientWriteLock; - - // Multi-origin overflow legs must not forward connection-level frames to the client. - var suppressConnectionFrameRelay = originReceiveLeg != null - && connectionState.OriginRelayPool != null - && !ReferenceEquals(originReceiveLeg, connectionState.OriginRelayPool.PrimaryLeg); - - int headerTableSize = 0; - Decoder? decoder = null; - - // stream ids that were answered with a synthetic (proxy-generated) response and therefore must not - // be forwarded to the server. Only relevant on the client=>server relay. - // Must be cleared when streams leave the registry — otherwise keep-alive H2→H1 multiplex - // retains one entry per historical stream id for the connection lifetime (saturation dump: - // ~225k ConcurrentDictionary nodes / ~14 MiB managed on a single client connection). - var syntheticStreams = new ConcurrentDictionary(); - if (isClient) - connectionState.ClientSyntheticStreams = syntheticStreams; - - // Synthetic responses (Ok/Respond/RespondStreaming during BeforeRequest) are no longer awaited - // inline in the frame loop below (see the HEADERS dispatch) so that a slow synthetic body does - // not stall every other multiplexed stream on the connection. Track them here so we can still - // observe/report failures and make sure they are fully drained before this relay direction's - // task completes. - var pendingSynthetics = connectionState.PendingSynthetics; - - var frameHeader = new Http2FrameHeader(); - var frameHeaderBuffer = new byte[9]; - var requestDispatchChain = Task.CompletedTask; - - // Writes toward `output` must be serialized against every other writer of that same stream: the - // other relay task's own-leg control-frame replies (WINDOW_UPDATE receive-credit grants, - // RST_STREAM, GOAWAY, PING ACK - all written directly onto this task's `output`, since it is - // that other task's `input`), and any synthetic response task writing toward the client. Every - // write onto `output`, including this task's own main relay/dispatch path, must go through this - // helper - a writer that bypasses it can still interleave bytes with one that does not. - async ValueTask lockedOutputWrite(Func writeAction) - { - await outputWriteLock.WaitAsync(cancellationToken); - try - { - await writeAction(); - } - finally - { - outputWriteLock.Release(); - } - } - - // Writes directly back onto `input` (same leg this task reads from) - PING ACK, receive-credit - // WINDOW_UPDATE, or a stream-level RST_STREAM for a malformed header block. - async ValueTask lockedOwnLegWrite(Func writeAction) - { - await ownLegWriteLock.WaitAsync(cancellationToken); - try - { - await writeAction(); - } - finally - { - ownLegWriteLock.Release(); - } - } - - // Grants back flow-control credit consumed by reading DATA frames. Batched at - // ReceiveCreditBatchThreshold (half of the 768 KiB stream window) so every DATA frame - // does not take the write lock for two WINDOW_UPDATE frames. Flushed on END_STREAM / stream - // removal and when the threshold is crossed. - int pendingConnectionReceiveCredit = 0; - var pendingStreamReceiveCredit = new Dictionary(); - - ValueTask GrantReceiveCreditAsync(int streamId, int bytes, bool forceFlush = false) - { - if (bytes <= 0 && !forceFlush) return default; - - if (bytes > 0) - { - pendingConnectionReceiveCredit += bytes; - if (pendingStreamReceiveCredit.TryGetValue(streamId, out var streamPending)) - pendingStreamReceiveCredit[streamId] = streamPending + bytes; - else - pendingStreamReceiveCredit[streamId] = bytes; - } - - var flushConnection = forceFlush || pendingConnectionReceiveCredit >= ReceiveCreditBatchThreshold; - var flushStream = forceFlush - || (pendingStreamReceiveCredit.TryGetValue(streamId, out var streamCredit) - && streamCredit >= ReceiveCreditBatchThreshold); - - if (!flushConnection && !flushStream) - return default; - - var connectionBytes = flushConnection ? pendingConnectionReceiveCredit : 0; - var streamBytes = 0; - if (flushStream && pendingStreamReceiveCredit.TryGetValue(streamId, out streamBytes)) - pendingStreamReceiveCredit.Remove(streamId); - if (flushConnection) - pendingConnectionReceiveCredit = 0; - - var streamStillTracked = streamBytes > 0 && connectionState.Streams.ContainsKey(streamId); - return GrantReceiveCreditLockedAsync( - streamStillTracked ? streamId : 0, - connectionBytes, - streamStillTracked ? streamBytes : 0); - } - - async ValueTask GrantReceiveCreditLockedAsync(int streamId, int connectionBytes, int streamBytes) - { - if (connectionBytes <= 0 && streamBytes <= 0) return; - - await ownLegWriteLock.WaitAsync(cancellationToken); - try - { - var controlFrameHeader = new Http2FrameHeader(); - var controlFrameHeaderBuffer = new byte[9]; - if (connectionBytes > 0) - await SendWindowUpdateAsync(controlFrameHeader, controlFrameHeaderBuffer, 0, connectionBytes, - input); - if (streamBytes > 0 && streamId != 0) - await SendWindowUpdateAsync(controlFrameHeader, controlFrameHeaderBuffer, streamId, streamBytes, - input); - } - finally - { - ownLegWriteLock.Release(); - } - } - - async ValueTask FlushAllPendingReceiveCreditAsync() - { - if (pendingConnectionReceiveCredit <= 0 && pendingStreamReceiveCredit.Count == 0) - return; - - await ownLegWriteLock.WaitAsync(CancellationToken.None); - try - { - var controlFrameHeader = new Http2FrameHeader(); - var controlFrameHeaderBuffer = new byte[9]; - if (pendingConnectionReceiveCredit > 0) - { - await SendWindowUpdateAsync(controlFrameHeader, controlFrameHeaderBuffer, 0, - pendingConnectionReceiveCredit, input); - pendingConnectionReceiveCredit = 0; - } - - foreach (var kvp in pendingStreamReceiveCredit) - { - if (kvp.Value > 0 && connectionState.Streams.ContainsKey(kvp.Key)) - await SendWindowUpdateAsync(controlFrameHeader, controlFrameHeaderBuffer, kvp.Key, - kvp.Value, input); - } - - pendingStreamReceiveCredit.Clear(); - } - finally - { - ownLegWriteLock.Release(); - } - } - - // Removes a stream's bookkeeping (registry + both flow-control windows) and schedules its - // AfterResponse + Dispose (see FinalizeStreamAsync) without blocking the caller - used wherever - // a stream is refused/closed and will never receive a normal end-stream or RST_STREAM of its - // own to trigger that cleanup through the main loop below. - void RemoveAndFinalizeStream(int removeStreamId) - { - // Flush any batched receive credit for this stream before removing it. - if (pendingStreamReceiveCredit.TryGetValue(removeStreamId, out var leftover) && leftover > 0) - { - pendingStreamReceiveCredit.Remove(removeStreamId); - // Fire-and-forget under the loop; connection credit stays batched. - _ = GrantReceiveCreditLockedAsync(removeStreamId, 0, leftover).AsTask(); - } - - connectionState.OriginRelayPool?.ReleaseStream(removeStreamId); - - if (connectionState.TryTakeStream(removeStreamId, out var removedState)) - { - removedState.InboundTunnelChannel?.Writer.TryComplete( - new IOException("HTTP/2 stream removed due to protocol error.")); - removedState.Cancellation.Cancel(); - // Compressed-relay CTS is TryReset in PrepareForPool; disposing here forces a new CTS. - if (!removedState.IsCompressedRelay) - removedState.Cancellation.Dispose(); - connectionState.ClientSendFlow.RemoveStream(removeStreamId); - connectionState.ServerSendFlow.RemoveStream(removeStreamId); - ScheduleFinalize(removedState, onAfterResponse, logger, connectionState); - } - } - - // Gate-off same-protocol path: keep HPACK decoder in sync with a no-op listener, then - // forward the compressed block unchanged (valid when both legs negotiated table size 0). - async Task RelayCompressedHeaderBlockAsync(int hbStreamId, byte[] compressed, bool endStreamFlag, - byte[]? appendSuffix = null) - { - // Mixed-transport: prefer a structural HPACK walk that only rewrites Indexed - // :scheme (0x86↔0x87) — no Decoder, no HeaderCollection. .NET HttpClient and most - // browsers emit static-indexed :scheme; decode+re-encode is the rare fallback. - // Same-transport relay stays verbatim (no override). - ReadOnlyMemory blockToRelay = compressed; - if (compressedRelaySchemeOverride.Length > 0) - { - switch (TryApplyStaticIndexedSchemeOverride(compressed, compressedRelaySchemeOverride, - out var patchedFast)) - { - case StaticSchemeOverrideResult.Patched: - blockToRelay = patchedFast; - break; - case StaticSchemeOverrideResult.AlreadyMatching: - break; - default: - { - var overrideHeaders = new HeaderCollection(); - var overrideListener = new MyHeaderListener( - (name, value) => overrideHeaders.AddHeader(new HttpHeader(name, value)), - isRequest: true); - try - { - if (decoder == null) - { - headerTableSize = remoteSettings.HeaderTableSize; - decoder = new Decoder(maxDecodedHeaderListBytes, headerTableSize); - } - else if (headerTableSize != remoteSettings.HeaderTableSize) - { - headerTableSize = remoteSettings.HeaderTableSize; - decoder.SetMaxHeaderTableSize(headerTableSize); - } - - decoder.Decode(compressed.AsSpan(0, compressed.Length), overrideListener); - if (decoder.EndHeaderBlock()) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 header list too large on compressed-relay stream.", null, null)); - RemoveAndFinalizeStream(hbStreamId); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), - new byte[9], hbStreamId, (Http2ErrorCode)0xb /* ENHANCE_YOUR_CALM */, - input)); - return; - } - } - catch (Exception ex) - { - ReportException(logger, new ProxyHttpException( - "Failed to decode HTTP/2 headers on compressed-relay stream", ex, null)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], - hbStreamId, Http2ErrorCode.CompressionError, input)); - throw; - } - - // Trailers / CONNECT (no :scheme) and already-matching schemes stay verbatim. - if (!overrideListener.HasMalformedHeader - && overrideListener.RawScheme.Length > 0 - && !overrideListener.RawScheme.Equals(compressedRelaySchemeOverride)) - { - if (TryPatchStaticIndexedScheme(compressed, overrideListener.RawScheme, - compressedRelaySchemeOverride, out var patched)) - blockToRelay = patched; - else - blockToRelay = ReencodeCompressedRequestBlock(remoteSettings, - overrideListener, overrideHeaders, compressedRelaySchemeOverride); - } - - break; - } - } - } - - var wireStreamId = hbStreamId; - Http2FrameWriter? dedicatedWriter = null; - SemaphoreSlim writeLock = outputWriteLock; - Stream writeStream = output; - var towardServer = isClient; - - if (isClient && connectionState.OriginRelayPool != null) - { - var assignment = await connectionState.OriginRelayPool - .AssignStreamAsync(hbStreamId, cancellationToken).ConfigureAwait(false); - wireStreamId = assignment.OriginStreamId; - dedicatedWriter = assignment.Leg.Writer; - writeStream = assignment.Leg.Stream; - } - else if (!isClient && originReceiveLeg != null) - { - // Origin → client: hbStreamId is already remapped to the client stream id by the caller. - dedicatedWriter = connectionState.ClientFrameWriter; - } - - var relayFrameHeader = new Http2FrameHeader { StreamId = wireStreamId }; - var relayFrameHeaderBuffer = new byte[9]; - var appendMemory = appendSuffix == null ? ReadOnlyMemory.Empty : appendSuffix.AsMemory(); - var framed = RentFramedHeaderBlock(relayFrameHeader, relayFrameHeaderBuffer, wireStreamId, - Http2FrameType.Headers, endStreamFlag, hasPriority: false, blockToRelay, appendMemory, - remoteSettings.MaxFrameSize); - if (dedicatedWriter != null) - dedicatedWriter.EnqueueRented(framed.Array!, framed.Count); - else - connectionState.EnqueueWriteRented(towardServer, writeLock, writeStream, - framed.Array!, framed.Count); - } - - // Decodes one fully-assembled HEADERS(+CONTINUATION...) block (already stripped of padding/ - // priority bytes) and dispatches it. A HEADERS block on an already-established request/response - // (one that already carries pseudo-headers) is the *main* message; a further block without - // request/status pseudo-headers is trailers (RFC 7230 ?4.1.2 / RFC 7540 ?8.1.2.1); a response - // block whose :status is 1xx is an interim informational response (RFC 9110 ?15.2) and is - // relayed without invoking BeforeRequest/BeforeResponse and without ever touching/locking the - // final Request/Response. Returns true if this block was an interim (1xx) response, so the - // caller does not treat a (spec-invalid, but let's be defensive) END_STREAM flag on it as ending - // the stream. - async Task ProcessCompleteHeaderBlockAsync(int hbStreamId, SessionEventArgs sessionArgs, - RequestResponseBase headerRr, byte[] compressed, bool endStreamFlag, bool isPromise) - { - var collected = new HeaderCollection(); - var headerListener = new MyHeaderListener( - (name, value) => collected.AddHeader(new HttpHeader(name, value)), isRequest: isClient); - - try - { - // The header block being decoded here was encoded by the peer this task reads from - // (`localSettings`'s peer), but that peer's encoder is constrained by whatever *we* - // told it its dynamic-table budget is - which, since SETTINGS frames are relayed - // transparently between the two legs (see the Settings frame handling below), is the - // value recorded in `remoteSettings` (the settings of the *other* peer, forwarded - // verbatim to this one). Sizing the decoder from `localSettings` instead is wrong: it - // uses the peer's own self-reported receive budget (irrelevant to what its encoder is - // actually bounded by) and, once a real peer advertises a non-default value, causes - // "invalid max dynamic table size" decode failures that permanently desync this - // connection's HPACK state. - // The dynamic table is connection-scoped (RFC 7541 §2.3.2), so the decoder itself must be - // created exactly once per direction and kept for the connection's lifetime - never - // recreated. A previous version of this code recreated the Decoder outright whenever - // `remoteSettings.HeaderTableSize` grew, which silently discarded every entry the peer's - // encoder had already inserted (and which that encoder still believes is indexable). - // The very next indexed reference into one of those now-missing entries then either threw - // (decoded as garbage/out-of-range) or resolved to the wrong slot, permanently desyncing - // this connection's HPACK state - observable as intermittent net::ERR_HTTP2_COMPRESSION_ERROR - // failures in the browser once a real peer advertised a table-size change mid-connection. - // Resizing the *existing* decoder's dynamic table (which evicts oldest entries only if the - // new size is smaller, per RFC 7541 §4.3) is the correct, entry-preserving way to react to - // a table-size change instead. - if (decoder == null) - { - headerTableSize = remoteSettings.HeaderTableSize; - decoder = new Decoder(maxDecodedHeaderListBytes, headerTableSize); - } - else if (headerTableSize != remoteSettings.HeaderTableSize) - { - headerTableSize = remoteSettings.HeaderTableSize; - decoder.SetMaxHeaderTableSize(headerTableSize); - } - - decoder.Decode(compressed.AsSpan(0, compressed.Length), headerListener); - var truncated = decoder.EndHeaderBlock(); - if (truncated) - { - // The decoded header list exceeded the local policy limit. The HPACK decoder - // state is still valid (EndHeaderBlock reset it), so future blocks on this - // connection remain safe. Reject only this stream with ENHANCE_YOUR_CALM (0xb) - // rather than a connection-level COMPRESSION_ERROR. - throw new Http2HeaderListTooLargeException( - "Decoded header list exceeded the configured limit; stream rejected."); - } - } - catch (Http2HeaderListTooLargeException ex) - { - // Policy rejection (not a structural HPACK error) - decoder state is intact. - ReportException(logger, new ProxyHttpException( - "HTTP/2 header list too large: " + ex.Message, ex, sessionArgs)); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, - (Http2ErrorCode)0xb /* ENHANCE_YOUR_CALM */, input)); - return false; - } - catch (Exception ex) - { - // RFC 7541 §7: "A decoding error in a header block MUST be treated as a connection - // error of type COMPRESSION_ERROR." The dynamic table is connection-scoped, so once a - // block fails to decode this decoder's state can no longer be trusted to stay in sync - // with the peer's encoder for any later stream either - swallowing this and continuing - // (as before) meant every subsequent header block on the connection failed too, each - // one silently dropped with no reply, hanging every affected stream. Tear the whole - // connection down instead so both sides observe a clean failure and can retry on a new - // connection. - ReportException(logger, new ProxyHttpException("Failed to decode HTTP/2 headers", ex, sessionArgs)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], hbStreamId, - Http2ErrorCode.CompressionError, input)); - throw; - } - - if (headerListener.HasMalformedHeader) - { - // RFC 7540 ?8.1.2/?8.1.2.1: unknown pseudo-header fields, uppercase field names, and - // (checked just below) connection-specific header fields are malformed - a stream-level - // PROTOCOL_ERROR that must not tear down the rest of the connection, whose HPACK decoder - // state has already been kept in sync by the decode above. - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: " + headerListener.MalformedReason, null, sessionArgs)); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, - Http2ErrorCode.ProtocolError, input)); - return false; - } - - var forbiddenConnectionHeader = collected.FirstOrDefault(header => - ForbiddenConnectionSpecificHeaders.Contains(header.Name)); - if (forbiddenConnectionHeader != null) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: connection-specific header field '" + forbiddenConnectionHeader.Name + - "' is forbidden.", null, sessionArgs)); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, - Http2ErrorCode.ProtocolError, input)); - return false; - } - - // RFC 9113 §8.5: once an extended CONNECT tunnel is established, no HEADERS or CONTINUATION - // frame is permitted on that stream. The HPACK decode above already ran to keep the - // connection-level dynamic table in sync; now reject the stream itself. - if (connectionState.Streams.TryGetValue(hbStreamId, out var estConnectState) - && estConnectState.ExtendedConnectEstablished) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: HEADERS received on an established extended CONNECT tunnel.", - null, sessionArgs)); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, - Http2ErrorCode.ProtocolError, input)); - return false; - } - - if (isClient) - { - var method = headerListener.Method; - var path = headerListener.Path; - // RFC 7540 §8.1.2.3: CONNECT requests have :method + :authority but no :path or :scheme. - // All other requests require :method, :path, and :scheme. - // RFC 8441 §5: extended CONNECT has :method=CONNECT + :protocol + :scheme + :path + :authority. - bool isConnect = method.Length > 0 && - method.Span.SequenceEqual(ConnectMethodBytes); - bool isExtendedConnect = isConnect && headerListener.Protocol.Length > 0; - bool isMainHeaders = (method.Length > 0 && path.Length > 0) || - (isConnect && headerListener.Authority.Length > 0); - - // RFC 8441 §5: :protocol is only valid on CONNECT requests. - if (!isConnect && headerListener.Protocol.Length > 0) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: :protocol pseudo-header is only allowed on CONNECT requests.", - null, sessionArgs)); - RemoveAndFinalizeStream(hbStreamId); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - hbStreamId, Http2ErrorCode.ProtocolError, input)); - return false; - } - - if (isMainHeaders) - { - // Validate required pseudo-fields for initial request HEADERS. - if (isExtendedConnect) - { - // RFC 8441 §5: extended CONNECT requires :scheme and :path (unlike plain CONNECT). - if (!enableRfc8441) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 extended CONNECT (RFC 8441) is not enabled on this proxy.", - null, sessionArgs)); - RemoveAndFinalizeStream(hbStreamId); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - hbStreamId, Http2ErrorCode.RefusedStream, input)); - return false; - } - - if (headerListener.Scheme == string.Empty || - headerListener.Path.Length == 0 || - headerListener.Authority.Length == 0) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: extended CONNECT HEADERS missing required " + - ":scheme, :path, or :authority pseudo-header.", - null, sessionArgs)); - RemoveAndFinalizeStream(hbStreamId); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - hbStreamId, Http2ErrorCode.ProtocolError, input)); - return false; - } - - // Mark the stream as extended CONNECT so the relay can handle DATA frames appropriately. - string? ecProtocol = Encoding.ASCII.GetString(headerListener.Protocol.Span); - if (connectionState.Streams.TryGetValue(hbStreamId, out var extStreamState)) - { - extStreamState.IsExtendedConnect = true; - extStreamState.ExtendedConnectProtocol = ecProtocol; - } - // Expose on the request so BeforeRequest handlers can identify the upgrade. - ((Request)headerRr).ExtendedConnectProtocol = ecProtocol; - } - else if (!isConnect && headerListener.Scheme == string.Empty) - { - // RFC 7540 §8.1.2.3: non-CONNECT requests must include :scheme. - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: request HEADERS missing required :scheme pseudo-header.", - null, sessionArgs)); - RemoveAndFinalizeStream(hbStreamId); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - hbStreamId, Http2ErrorCode.ProtocolError, input)); - return false; - } - - // RFC 7540 ?5.1.1: client-initiated stream ids must be odd and strictly increasing - // on a given connection. An even id (reserved for server-initiated streams, which - // this proxy never admits - see the PUSH_PROMISE rejection in the main frame loop) - // or an id that does not exceed one already seen (reuse, or the client's own - // ids arriving out of order) is a connection-level PROTOCOL_ERROR: continuing would - // risk colliding with flow-control/session state for a stream id already in use or - // already torn down. - if (hbStreamId % 2 == 0 || hbStreamId <= connectionState.LastClientStreamId) - { - ReportException(logger, new ProxyHttpException( - $"HTTP/2 protocol error: invalid client-initiated stream id {hbStreamId}.", null, - sessionArgs)); - RemoveAndFinalizeStream(hbStreamId); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], - connectionState.LastClientStreamId, Http2ErrorCode.ProtocolError, input)); - return false; - } - - connectionState.LastClientStreamId = hbStreamId; - } - - if (isMainHeaders && connectionState.ServerGoingAway && - hbStreamId > connectionState.ServerLastStreamId) - { - // the server has already told us (via GOAWAY) it will not process any new stream - // above its last-accepted id - refuse this one locally instead of forwarding a - // request we already know will never be answered. - RemoveAndFinalizeStream(hbStreamId); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, - Http2ErrorCode.RefusedStream, input)); - return false; - } - - if (isMainHeaders && connectionState.ClientResetBudgetExceeded && - hbStreamId > connectionState.ClientResetBudgetLastStreamId) - { - // The proxy already announced (via its own GOAWAY, sent when the Rapid Reset - // budget was exceeded) that it will not process any client-initiated stream above - // this id - refuse locally rather than doing further setup work for it. - RemoveAndFinalizeStream(hbStreamId); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, - Http2ErrorCode.RefusedStream, input)); - return false; - } - - if (isMainHeaders && connectionState.Streams.Count > remoteSettings.MaxConcurrentStreams) - { - // Streams.Count already includes this stream (registered by the caller before - // decoding, so HPACK state stays in sync regardless of admission) - so ">" (not - // ">=") here correctly means "admitting this one would exceed the limit the server - // (this stream's ultimate destination) advertised it will tolerate concurrently" - // (RFC 7540 ?6.5.2 SETTINGS_MAX_CONCURRENT_STREAMS). - ReportException(logger, new ProxyHttpException( - "HTTP/2 stream refused: maximum concurrent streams exceeded.", null, sessionArgs)); - RemoveAndFinalizeStream(hbStreamId); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], hbStreamId, - Http2ErrorCode.RefusedStream, input)); - return false; - } - - if (!isMainHeaders) - { - // request trailers - never valid before any main request headers were seen. - if (headerRr.HttpVersion < HttpHeader.Version20) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: trailer HEADERS received before request headers.", null, - sessionArgs)); - return false; - } - - // RFC 7540 §8.1.2.1: trailer HEADERS MUST NOT contain pseudo-header fields. - if (headerListener.Method.Length > 0 || headerListener.Path.Length > 0 || - headerListener.Status.Length > 0 || headerListener.Authority.Length > 0 || - headerListener.Scheme != string.Empty || headerListener.Protocol.Length > 0) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: request trailer HEADERS contains pseudo-header fields.", - null, sessionArgs)); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - hbStreamId, Http2ErrorCode.ProtocolError, input)); - return false; - } - - // RFC 9110 §6.5.1: certain fields are forbidden in trailers. - var forbiddenTrailerHeader = collected.FirstOrDefault(header => - ForbiddenTrailerHeaders.Contains(header.Name)); - if (forbiddenTrailerHeader != null) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: request trailer HEADERS contains forbidden field '" + - forbiddenTrailerHeader.Name + "'.", null, sessionArgs)); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - hbStreamId, Http2ErrorCode.ProtocolError, input)); - return false; - } - - foreach (var header in collected) - { - headerRr.TrailingHeaders.AddHeader(header); - } - - // a request answered synthetically never reached the server - nothing to forward, - // but the block above still had to be decoded to keep this connection's HPACK - // decoder state in sync with the peer's encoder. - await requestDispatchChain; - - if (!syntheticStreams.ContainsKey(hbStreamId)) - { - // Drain queued HEADERS/DATA so trailers cannot overtake them on the wire. - if (isClient) - await connectionState.ServerWriteChain; - await lockedOutputWrite(() => AsValueTask(SendTrailer(remoteSettings, frameHeader, frameHeaderBuffer, - hbStreamId, headerRr.TrailingHeaders, endStreamFlag, output))); - } - - return false; - } - - var request = (Request)headerRr; - request.HttpVersion = HttpVersion.Version20; - request.Method = method.GetString(); - request.IsHttps = headerListener.Scheme == ProxyServer.UriSchemeHttps; - request.Authority = headerListener.Authority; - request.RequestUriString8 = path; - foreach (var header in collected) - { - request.Headers.AddHeader(header); - } - - // Capture compressed block for intercept unchanged → relay (static-HPACK MITM only). - if (httpInterceptionEnabled && forceStaticHpackTable - && connectionState.Streams.TryGetValue(hbStreamId, out var captureState)) - { - captureState.CapturedCompressedHeaders = compressed; - captureState.HeadersRelayBaseline = - MitmCompressedRelayHelper.HeaderRelayBaseline.Capture(request.Headers); - captureState.CapturedMethod = request.Method; - captureState.CapturedPath = request.RequestUriString8; - captureState.CapturedAuthority = request.Authority; - } - - // Per-stream predicate: gate is on but this stream may still be passthrough. - if (httpInterceptionEnabled && shouldInterceptHttp != null && isMainHeaders) - { - var authority = headerListener.Authority.GetString(); - var host = authority; - var port = request.IsHttps ? 443 : 80; - var colon = authority.LastIndexOf(':'); - if (colon > 0 && int.TryParse(authority.AsSpan(colon + 1), out var parsedPort)) - { - host = authority[..colon]; - port = parsedPort; - } - - var interceptionCtx = new HttpInterceptionContext - { - Hostname = host, - Port = port, - IsHttps = request.IsHttps, - Method = request.Method ?? string.Empty, - PathAndQuery = path.GetString(), - HttpVersion = HttpVersion.Version20, - ProxyEndPoint = sessionArgs.ProxyEndPoint, - ClientRemoteEndPoint = sessionArgs.ClientRemoteEndPoint, - ClientProcessId = null - }; - sessionArgs.IsFastPath = !shouldInterceptHttp(interceptionCtx); - } - - var tcs = new TaskCompletionSource(); - request.ReadHttp2BeforeHandlerTaskCompletionSource = tcs; - - var streamContext = new Http2StreamContext(hbStreamId, connectionState, - isClient ? input : output, cancellationToken); - - // HPACK decode and Request population above must stay ordered on this frame loop. - // Everything from the user handler on is per-stream work, though, and running it - // here serializes unrelated streams on the same connection. Dispatch it independently; - // DATA/body completion awaits this task before SendBody, preserving HEADERS-before-DATA - // ordering for the stream without delaying subsequent HEADERS decode. - var dispatchFrameHeader = new Http2FrameHeader { StreamId = hbStreamId }; - var dispatchFrameHeaderBuffer = new byte[9]; - var previousDispatch = requestDispatchChain; - var dispatchTask = Task.Run(async () => - { - // The handler must start here on the pool, not on the frame loop: its synchronous - // prefix (BeforeRequest dispatch, bridge request prep, origin pool checkout - even - // the origin header write when the socket buffer accepts it without suspending) - // otherwise runs inline per HEADERS and caps one client connection at the - // reciprocal of that prefix (~22k streams/s measured on the h2-to-h1 bridge). - // DATA routing stays correct: client DATA frames await this dispatch task before - // being routed, so channels the handler registers are always visible in time. - var handler = onBeforeRequestResponse(sessionArgs, streamContext); - var handlerCompleted = handler == await Task.WhenAny(tcs.Task, handler); - - // The origin must observe newly opened client streams in increasing stream-id order. - // Handlers run concurrently, but admit each completed decision after the prior stream's - // decision has queued (or suppressed) its HEADERS. - await previousDispatch; - - if (handlerCompleted) - { - request.ReadHttp2BeforeHandlerTaskCompletionSource = null; - tcs.SetResult(true); - - // Apply the same outgoing-request normalization and Via policy as HTTP/1.x. - // External bridges (H2→H1 via NullOriginStream, H2→H3 via IsExternalBridge) - // apply Via themselves before launching their independent origin round trip. - // Re-applying here would see their Via entry and falsely return 508 Loop Detected, - // and would race a second synthetic response against the bridge task. - connectionState.Streams.TryGetValue(hbStreamId, out var viaOwnerState); - bool bridgeOwnsRequestPrep = output is NullOriginStream - || viaOwnerState?.IsExternalBridge == true; - - // Did the consumer answer this request synthetically during BeforeRequest (Ok, - // GenericResponse, Redirect, buffered Respond, or RespondStreaming - all funnel - // through Respond(), which is the single source of truth for "short-circuit this - // request" and is what HTTP/1.x's RequestHandler already keys off of)? - // PrepareRequestHeaders / Via run only on the re-encode forward path below — - // applying them before the unchanged-relay check would rewrite Accept-Encoding - // (MutationCount) and either block relay or diverge from the compressed block. - if (sessionArgs.HttpClient.Request.CancelRequest) - { - // do not forward the request upstream; answer the client directly. Run this in - // the background (rather than awaiting inline) so a slow synthetic body does not - // block reading/relaying frames for every other multiplexed stream on this - // connection; failures are reported centrally instead of tearing down the whole - // relay. - syntheticStreams.TryAdd(hbStreamId, 0); - connectionState.Streams.TryGetValue(hbStreamId, out var streamState); - var linkedCts694 = streamState != null - ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, - streamState.Cancellation.Token) - : null; - var streamToken = linkedCts694?.Token ?? cancellationToken; - // we are inside the `if (isClient)` branch, so `input` is always the client - // stream here (see the isClient=true call in SendHttp2). - var synthTask = EmitSyntheticResponseAsync(sessionArgs, hbStreamId, connectionState, - input, streamToken, onAfterResponse, logger) - .ContinueWith(t => - { - linkedCts694?.Dispose(); - if (t.IsFaulted) - { - ReportException(logger, new ProxyHttpException( - SyntheticResponseFailedMessage, t.Exception.GetBaseException(), - sessionArgs)); - } - }, TaskScheduler.Default); - if (streamState != null) streamState.SyntheticTask = synthTask; - pendingSynthetics.Track(synthTask); - } - else - { - // RFC 8441: extended CONNECT tunnel streams are handled entirely by the - // bridge's tunnel task (which manages its own response). Do not forward - // the CONNECT HEADERS to the (null) origin - the tunnel task sends the - // actual WebSocket upgrade request and response independently. - connectionState.Streams.TryGetValue(hbStreamId, out var ecTunnelState); - bool isExtendedConnectTunnel = ecTunnelState?.IsExtendedConnect == true - && ecTunnelState.InboundTunnelChannel != null; - bool isNativeExtendedConnect = ecTunnelState?.IsExtendedConnect == true - && ecTunnelState.InboundTunnelChannel == null; - bool isExternalBridge = ecTunnelState?.IsExternalBridge == true - || output is NullOriginStream; - - if (isExtendedConnectTunnel) - { - // h2→h1 bridge: the tunnel task owns the origin connection; skip. - } - else if (isExternalBridge) - { - // An external bridge (e.g. H2→H3) registered its background task in - // SyntheticTask and owns this stream's origin round trip entirely. - // Suppress forwarding the request HEADERS to the native H2 origin; - // the bridge task emits the response via EmitSyntheticResponseAsync. - syntheticStreams.TryAdd(hbStreamId, 0); - } - else if (isNativeExtendedConnect && output is not NullOriginStream) - { - // Wait for the origin's initial SETTINGS to be processed before checking - // SETTINGS_ENABLE_CONNECT_PROTOCOL. The client may send its extended CONNECT - // request before the server→client relay has had a chance to relay the origin's - // SETTINGS frame; without this await the check below would always see false. - await connectionState.ServerSettingsRelayed.Task.WaitAsync(cancellationToken); - - // Native h2↔h2 extended CONNECT path. - string? ecProto = ecTunnelState?.ExtendedConnectProtocol; - if (!string.Equals(ecProto, "websocket", StringComparison.OrdinalIgnoreCase)) - { - // Only the 'websocket' protocol token is implemented. BeforeRequest ran - // but did not synthesize a response - return 501 so the client can retry. - sessionArgs.GenericResponse( - $"RFC 8441 extended CONNECT (protocol: {ecProto ?? "unknown"}) " + - "is not supported by this proxy. Only 'websocket' is implemented.", - HttpStatusCode.NotImplemented); - syntheticStreams.TryAdd(hbStreamId, 0); - connectionState.Streams.TryGetValue(hbStreamId, out var unknProtoState); - var linkedCts751 = unknProtoState != null - ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, - unknProtoState.Cancellation.Token) - : null; - var unknProtoToken = linkedCts751?.Token ?? cancellationToken; - var synthTask501 = EmitSyntheticResponseAsync(sessionArgs, hbStreamId, - connectionState, input, unknProtoToken, onAfterResponse, logger) - .ContinueWith(t => - { - linkedCts751?.Dispose(); - if (t.IsFaulted) - ReportException(logger, new ProxyHttpException( - SyntheticResponseFailedMessage, - t.Exception.GetBaseException(), sessionArgs)); - }, TaskScheduler.Default); - if (unknProtoState != null) unknProtoState.SyntheticTask = synthTask501; - pendingSynthetics.Track(synthTask501); - } - else if (!connectionState.ServerSettings.EnableConnectProtocol) - { - // Origin did not advertise SETTINGS_ENABLE_CONNECT_PROTOCOL=1. - // Refuse deterministically so the client can retry or fall back; - // do NOT leak the extended-CONNECT HEADERS to an unsupporting origin. - ReportException(logger, new ProxyHttpException( - "HTTP/2 extended CONNECT refused: origin did not advertise " + - "SETTINGS_ENABLE_CONNECT_PROTOCOL=1.", - null, sessionArgs)); - RemoveAndFinalizeStream(hbStreamId); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - hbStreamId, Http2ErrorCode.RefusedStream, input)); - } - else - { - // Origin supports RFC 8441 - forward the extended CONNECT HEADERS. - if (originConnection != null) - BindOriginForHttp2Stream(sessionArgs, originConnection); - ApplyCleartextOriginScheme(request, originConnection, - sessionArgs.ClientConnection); - if (!bridgeOwnsRequestPrep) - prepareRequestHeaders?.Invoke(request.Headers); - // Encode HPACK under the ordered dispatch chain and queue copied wire - // bytes without awaiting origin socket I/O. - QueueSendHeaderTowardServer(connectionState, outputWriteLock, - remoteSettings, dispatchFrameHeader, dispatchFrameHeaderBuffer, request, - endStreamFlag, output, isPromise); - } - } - else - { - // Bind shared origin metadata without SetConnection so HasConnection stays - // false (H1 syphon/drain must not touch the multiplexed H2 socket). - if (originConnection != null) - BindOriginForHttp2Stream(sessionArgs, originConnection); - ApplyCleartextOriginScheme(request, originConnection, - sessionArgs.ClientConnection); - - // True MITM noop-safe: relay the original compressed HEADERS when handlers - // did not mutate method/path/authority/headers or buffer/replace the body - // (GetRequestBody sets IsBodyRead and would leave origin without DATA). - // Skip relay when Via would be injected (explicit MITM) — append as HPACK - // literal on the static block instead of full re-encode (matches H3). - var injectVia = !sessionArgs.IsFastPath && !sessionArgs.IsTransparent - && !sessionArgs.IsSocks - && !string.IsNullOrEmpty(sessionArgs.Server.ViaHeaderPseudonym); - if (forceStaticHpackTable - && connectionState.Streams.TryGetValue(hbStreamId, out var relayState) - && relayState.CapturedCompressedHeaders != null - && !request.IsBodyRead - && !request.BodyAvailable - && TryPrepareMitmStaticHpackRelay( - relayState.CapturedCompressedHeaders, - relayState.HeadersRelayBaseline, request.Headers, - injectVia, - injectVia - ? $"{request.HttpVersion.Major}.{request.HttpVersion.Minor} {sessionArgs.Server.ViaHeaderPseudonym}" - : null, - out var reqBlockToRelay, out var reqAppendSuffix) - && string.Equals(request.Method, relayState.CapturedMethod, StringComparison.Ordinal) - && request.RequestUriString8.Equals(relayState.CapturedPath) - && request.Authority.Equals(relayState.CapturedAuthority)) - { - await RelayCompressedHeaderBlockAsync(hbStreamId, reqBlockToRelay, endStreamFlag, - reqAppendSuffix); - relayState.EnableRequestDataCompressedRelay(); - } - else - { - if (!bridgeOwnsRequestPrep) - { - // The h2-to-h1 / h2-to-h3 bridges own request preparation before they - // start their background origin operation; doing it here afterward - // races with that operation and can mutate headers while they are sent. - prepareRequestHeaders?.Invoke(request.Headers); - if (injectVia) - { - var pseudonym = sessionArgs.Server.ViaHeaderPseudonym; - if (ProxyServer.HasLoopedVia(request.Headers, pseudonym)) - { - sessionArgs.GenericResponse(string.Empty, (HttpStatusCode)508); - } - else - { - ProxyServer.AddViaHeader(request.Headers, request.HttpVersion, - pseudonym); - } - } - } - - if (sessionArgs.HttpClient.Request.CancelRequest) - { - syntheticStreams.TryAdd(hbStreamId, 0); - connectionState.Streams.TryGetValue(hbStreamId, out var loopStreamState); - var linkedCts508 = loopStreamState != null - ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, - loopStreamState.Cancellation.Token) - : null; - var loopToken = linkedCts508?.Token ?? cancellationToken; - var synthTask508 = EmitSyntheticResponseAsync(sessionArgs, hbStreamId, - connectionState, input, loopToken, onAfterResponse, logger) - .ContinueWith(t => - { - linkedCts508?.Dispose(); - if (t.IsFaulted) - { - ReportException(logger, new ProxyHttpException( - SyntheticResponseFailedMessage, - t.Exception.GetBaseException(), sessionArgs)); - } - }, TaskScheduler.Default); - if (loopStreamState != null) loopStreamState.SyntheticTask = synthTask508; - pendingSynthetics.Track(synthTask508); - } - else - { - if (connectionState.Streams.TryGetValue(hbStreamId, out var clearCapture)) - clearCapture.CapturedCompressedHeaders = null; - QueueSendHeaderTowardServer(connectionState, outputWriteLock, - remoteSettings, dispatchFrameHeader, dispatchFrameHeaderBuffer, request, - endStreamFlag, output, isPromise); - } - } - } - } - } - else - { - request.Http2IgnoreBodyFrames = true; - } - - request.Locked = true; - }, cancellationToken); - requestDispatchChain = dispatchTask; - request.Http2BeforeHandlerTask = dispatchTask; - pendingSynthetics.Track(dispatchTask); - return false; - } - else - { - bool hasStatus = headerListener.Status.Length > 0; - int statusCode = 0; - if (hasStatus) - { - // RFC 7540 §8.1.2.4 / RFC 9110: :status MUST be exactly three ASCII decimal - // digits in the range 100–999. Any other encoding is a stream-level protocol error. - var statusSpan = headerListener.Status.Span; - if (statusSpan.Length != 3 || - !IsAsciiDigit(statusSpan[0]) || - !IsAsciiDigit(statusSpan[1]) || - !IsAsciiDigit(statusSpan[2])) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: :status pseudo-header is not exactly three ASCII digits.", - null, sessionArgs)); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - hbStreamId, Http2ErrorCode.ProtocolError, input)); - return false; - } - - statusCode = (statusSpan[0] - '0') * 100 - + (statusSpan[1] - '0') * 10 - + (statusSpan[2] - '0'); - - if (statusCode < 100 || statusCode > 999) - { - ReportException(logger, new ProxyHttpException( - $"HTTP/2 protocol error: :status value {statusCode} is outside the valid range (100-999).", - null, sessionArgs)); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - hbStreamId, Http2ErrorCode.ProtocolError, input)); - return false; - } - } - - bool isInterim = hasStatus && statusCode is >= 100 and <= 199; - - if (hasStatus && !isInterim) - { - var response = (Response)headerRr; - response.HttpVersion = HttpVersion.Version20; - response.StatusCode = statusCode; - response.StatusDescription = string.Empty; - foreach (var header in collected) - { - response.Headers.AddHeader(header); - } - - if (httpInterceptionEnabled && forceStaticHpackTable - && connectionState.Streams.TryGetValue(hbStreamId, out var respCapture)) - { - respCapture.CapturedCompressedHeaders = compressed; - respCapture.HeadersRelayBaseline = - MitmCompressedRelayHelper.HeaderRelayBaseline.Capture(response.Headers); - respCapture.CapturedStatusCode = statusCode; - } - - // Matches HTTP/1.x's ResponseHeadersReceivedAt timing mark (see - // ResponseHandler.HandleHttpSessionResponse), stamped here at the same logical point: - // right after the final (non-interim) response headers are parsed, before BeforeResponse runs. - sessionArgs.Timing?.MarkResponseHeadersReceived(); - - var tcs = new TaskCompletionSource(); - response.ReadHttp2BeforeHandlerTaskCompletionSource = tcs; - - var streamContext = new Http2StreamContext(hbStreamId, connectionState, - isClient ? input : output, cancellationToken); - var handler = onBeforeRequestResponse(sessionArgs, streamContext); - response.Http2BeforeHandlerTask = handler; - - if (handler == await Task.WhenAny(tcs.Task, handler)) - { - response.ReadHttp2BeforeHandlerTaskCompletionSource = null; - tcs.SetResult(true); - - // BeforeResponse may have replaced HttpClient.Response outright - exactly what - // Respond()/Ok()/Redirect() do when called after the real response was already - // received. Note that this is the *one* Respond() call site that does not set - // Request.CancelRequest (see SessionEventArgs.Respond: that flag only means - // "never forward the request", which is meaningless once the request has already - // gone out) - so the only reliable signal that a replacement happened is whether - // HttpClient.Response is no longer the same object `response` above was captured - // from *before* the handler ran. Dispatching the stale `response` here would - // silently drop the replacement and send the original object instead. - var finalResponse = sessionArgs.HttpClient.Response; - - if (!ReferenceEquals(finalResponse, response)) - { - // the real response's own body (if the server is still sending one) must - // never reach the client now that a different response has been substituted; - // suppress it exactly like an in-flight GetBody() wait does. Flow-control - // credit for those bytes is still granted back to the server unconditionally - // by the generic DATA-frame handling below, regardless of this flag. - finalResponse.Http2IgnoreBodyFrames = true; - finalResponse.Locked = true; - - connectionState.Streams.TryGetValue(hbStreamId, out var streamState); - var linkedCts893 = streamState != null - ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, - streamState.Cancellation.Token) - : null; - var streamToken = linkedCts893?.Token ?? cancellationToken; - // we are inside the isClient=false branch, so `output` is the client stream - // here (see the isClient=false call in SendHttp2). - var synthTask = EmitSyntheticResponseAsync(sessionArgs, hbStreamId, connectionState, - output, streamToken, onAfterResponse, logger) - .ContinueWith(t => - { - linkedCts893?.Dispose(); - if (t.IsFaulted) - { - ReportException(logger, new ProxyHttpException( - SyntheticResponseFailedMessage, t.Exception.GetBaseException(), - sessionArgs)); - } - }, TaskScheduler.Default); - if (streamState != null) streamState.SyntheticTask = synthTask; - pendingSynthetics.Track(synthTask); - - return false; - } - - // Match H1/H3 fast-path: skip Via when no HTTP interception — append as HPACK - // literal on compressed relay instead of mutating before the relay gate. - var injectViaResp = !sessionArgs.IsFastPath && !sessionArgs.IsTransparent - && !sessionArgs.IsSocks - && !string.IsNullOrEmpty(sessionArgs.Server.ViaHeaderPseudonym); - - // True MITM noop-safe: relay original compressed response HEADERS when unchanged. - // GetResponseBody / SetResponseBody set IsBodyRead/BodyAvailable — must re-encode. - if (forceStaticHpackTable - && ReferenceEquals(finalResponse, response) - && connectionState.Streams.TryGetValue(hbStreamId, out var respRelay) - && respRelay.CapturedCompressedHeaders != null - && !finalResponse.IsBodyRead - && TryPrepareMitmStaticHpackRelay( - respRelay.CapturedCompressedHeaders, - respRelay.HeadersRelayBaseline, finalResponse.Headers, - injectViaResp, - injectViaResp - ? $"{finalResponse.HttpVersion.Major}.{finalResponse.HttpVersion.Minor} {sessionArgs.Server.ViaHeaderPseudonym}" - : null, - out var respBlockToRelay, out var respAppendSuffix) - && finalResponse.StatusCode == respRelay.CapturedStatusCode) - { - await RelayCompressedHeaderBlockAsync(hbStreamId, respBlockToRelay, endStreamFlag, - respAppendSuffix); - respRelay.EnableResponseDataCompressedRelay(); - } - else - { - if (injectViaResp) - { - ProxyServer.AddViaHeader(finalResponse.Headers, finalResponse.HttpVersion, - sessionArgs.Server.ViaHeaderPseudonym); - } - - if (connectionState.Streams.TryGetValue(hbStreamId, out var clearResp)) - clearResp.CapturedCompressedHeaders = null; - QueueSendHeader(connectionState, towardServer: false, outputWriteLock, - remoteSettings, frameHeader, frameHeaderBuffer, finalResponse, - endStreamFlag, output, isPromise); - } - - // RFC 8441: once a final 2xx response to a native h2↔h2 extended CONNECT is - // forwarded to the client, the stream enters tunnel state. DATA frames from either - // direction are raw tunnel bytes; any subsequent HEADERS/CONTINUATION is rejected. - if (finalResponse.StatusCode is >= 200 and < 300 - && connectionState.Streams.TryGetValue(hbStreamId, out var tunnelEstState) - && tunnelEstState.IsExtendedConnect - && tunnelEstState.InboundTunnelChannel == null) - { - tunnelEstState.ExtendedConnectEstablished = true; - } - - finalResponse.Locked = true; - return false; - } - else - { - response.Http2IgnoreBodyFrames = true; - } - - response.Locked = true; - return false; - } - - if (isInterim) - { - // interim (1xx) response: relay verbatim on its own HEADERS frame, do not fire - // BeforeResponse and do not touch the final Response object - mirrors how HTTP/1.x - // interim responses are handled (see ResponseHandler.HandleHttpSessionResponse). - var synthetic = new Response { StatusCode = statusCode, StatusDescription = string.Empty }; - foreach (var header in collected) - { - synthetic.Headers.AddHeader(header); - } - - QueueSendHeader(connectionState, towardServer: false, outputWriteLock, - remoteSettings, frameHeader, frameHeaderBuffer, synthetic, false, output, false); - return true; - } - - // response trailers - never valid before any final response headers were seen. - // Also catches the case where a response HEADERS block is missing the required :status - // pseudo-field (RFC 7540 §8.1.2.4). - if (headerRr.HttpVersion < HttpHeader.Version20) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: response HEADERS missing required :status pseudo-header.", - null, sessionArgs)); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - hbStreamId, Http2ErrorCode.ProtocolError, input)); - return false; - } - - // RFC 7540 §8.1.2.1: trailer HEADERS MUST NOT contain pseudo-header fields. - if (headerListener.Method.Length > 0 || headerListener.Path.Length > 0 || - headerListener.Status.Length > 0 || headerListener.Authority.Length > 0 || - headerListener.Scheme != string.Empty) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: response trailer HEADERS contains pseudo-header fields.", - null, sessionArgs)); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - hbStreamId, Http2ErrorCode.ProtocolError, input)); - return false; - } - - // RFC 9110 §6.5.1: certain fields are forbidden in trailers. - var forbiddenTrailerHeader = collected.FirstOrDefault(header => - ForbiddenTrailerHeaders.Contains(header.Name)); - if (forbiddenTrailerHeader != null) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: response trailer HEADERS contains forbidden field '" + - forbiddenTrailerHeader.Name + "'.", null, sessionArgs)); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - hbStreamId, Http2ErrorCode.ProtocolError, input)); - return false; - } - - foreach (var header in collected) - { - headerRr.TrailingHeaders.AddHeader(header); - } - - // Drain queued response HEADERS/DATA so trailers cannot overtake them. - await connectionState.ClientWriteChain; - await lockedOutputWrite(() => AsValueTask(SendTrailer(remoteSettings, frameHeader, frameHeaderBuffer, - hbStreamId, headerRr.TrailingHeaders, endStreamFlag, output))); - return false; - } - } - - byte[] buffer = new byte[MaxAcceptableFrameSize]; - // Typical HTTP/2 server stacks read a large Pipe buffer then peel frames with - // Http2FrameReader.TryReadFrame. Mirror that without a ReadOnlySequence retrofit: - // one socket ReadAsync fills up to 64 KiB; subsequent frames reuse leftover bytes. - var intake = new Http2FrameIntake(input); - - // Metadata for a HEADERS/PUSH_PROMISE block that has not yet been terminated by END_HEADERS and - // is being assembled from subsequent CONTINUATION frames (RFC 7540 ?6.10). Only one such block - // may be in flight per connection direction at a time - a HEADERS/PUSH_PROMISE frame arriving - // while another block is still open, or a CONTINUATION frame for a different stream, is a - // connection-level PROTOCOL_ERROR. - MemoryStream? pendingHeaderBlock = null; - int pendingHeaderStreamId = -1; - SessionEventArgs? pendingHeaderArgs = null; - RequestResponseBase? pendingHeaderRr = null; - bool pendingHeaderEndStream = false; - bool pendingHeaderIsPromise = false; - bool pendingCompressedRelay = false; - - // Companion bounds for the open header block above: a byte cap alone never trips on - // zero-length CONTINUATION frames, and only one header block may be open per connection - // direction, so an attacker sending an endless sequence of empty CONTINUATION frames would - // otherwise head-of-line block every other multiplexed stream on this leg forever. Both are - // reset whenever a block opens and checked on every CONTINUATION frame for it. - int pendingHeaderBlockFrameCount = 0; - long pendingHeaderBlockOpenedAt = 0; - - // RFC 7540 ?3.5: "each endpoint is required to send a connection preface... this sequence MUST - // be followed by a SETTINGS frame". The connection preface itself (the literal - // "PRI * HTTP/2.0..." bytes) is already validated before this relay starts (see the explicit - // handler's preface check); this tracks the second half of that requirement, that the first - // frame this task ever reads from `input` is SETTINGS, for both directions (a server's first - // frame is required to be SETTINGS too, even though it has no separate textual preface). - bool isFirstFrame = true; - - try - { - // Best-effort graceful shutdown notice sent to `output` (the *other* leg) when this task's own - // `input` peer disconnects or the connection is otherwise ending on this side - so that peer - // learns the connection is going away (and which streams were actually seen) via GOAWAY instead - // of only ever observing an abrupt socket close. Exceptions are swallowed: by the time this - // fires, `output` may already be broken too (e.g. both legs disconnecting around the same - // time), and a failed shutdown notice must never turn a clean teardown into a fault. - async Task TrySendGracefulGoAwayAsync() - { - try - { - await lockedOutputWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], - connectionState.LastClientStreamId, Http2ErrorCode.NoError, output)); - } - catch - { - // best-effort only - see remarks above. - } - } - - while (true) - { - if (!await intake.ReadExactAsync(frameHeaderBuffer, 0, 9, cancellationToken)) - { - await TrySendGracefulGoAwayAsync(); - return; - } - - int length = (frameHeaderBuffer[0] << 16) + (frameHeaderBuffer[1] << 8) + frameHeaderBuffer[2]; - var type = (Http2FrameType)frameHeaderBuffer[3]; - var flags = (Http2FrameFlag)frameHeaderBuffer[4]; - int streamId = ((frameHeaderBuffer[5] & 0x7f) << 24) + (frameHeaderBuffer[6] << 16) + - (frameHeaderBuffer[7] << 8) + frameHeaderBuffer[8]; - - // Wire id on `input` (origin stream id when reading an origin leg). - int peerStreamId = streamId; - - frameHeader.Length = length; - frameHeader.Type = type; - frameHeader.Flags = flags; - frameHeader.StreamId = streamId; - - if (isFirstFrame) - { - isFirstFrame = false; - - // RFC 7540 §6.8: an endpoint may send GOAWAY at any time, including immediately - // after the connection preface and before ever sending SETTINGS - e.g. a browser - // gracefully tearing down a freshly-opened (often speculative/pooled) HTTP/2 - // connection it decided it no longer needs. That is normal, expected behavior, not - // a protocol violation, so let it fall through to the ordinary GOAWAY handling - // below (which relays it and records the going-away state) instead of treating - // "first frame wasn't SETTINGS" as fatal. - if (type != Http2FrameType.Settings && type != Http2FrameType.GoAway) - { - ReportException(logger, new ProxyHttpException( - $"HTTP/2 protocol error: expected a SETTINGS frame immediately after the connection preface, got {type}.", - null, null)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], 0, - Http2ErrorCode.ProtocolError, input)); - return; - } - } - - if (length > MaxAcceptableFrameSize) - { - // RFC 7540 ?4.2: a frame larger than what we (implicitly, by never advertising anything - // else) declared we would accept is a connection-level FRAME_SIZE_ERROR. Reject before - // attempting to buffer/read the (potentially huge, up to 2^24-1 byte) payload. - ReportException(logger, new ProxyHttpException( - $"HTTP/2 protocol error: frame of type {type} exceeded the maximum accepted frame size.", - null, null)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, - Http2ErrorCode.FrameSizeError, input)); - // Unlike every other rejection path here, this one fires before the frame's payload is - // ever read (see the ForceRead call right below this block) - drain it now so the GOAWAY - // just flushed above is not itself lost to an abortive close; see - // DiscardRejectedFramePayloadAsync's remarks. - await intake.DiscardAsync(length, cancellationToken); - return; - } - - if ((type == Http2FrameType.Data || type == Http2FrameType.Headers || - type == Http2FrameType.RstStream || type == Http2FrameType.Priority) && streamId == 0) - { - // RFC 7540 ?5.1.1 / relevant frame definitions: these frame types are always - // stream-specific; stream id 0 on any of them is a connection-level PROTOCOL_ERROR. - ReportException(logger, new ProxyHttpException( - $"HTTP/2 protocol error: frame of type {type} received with stream id 0.", null, null)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], 0, - Http2ErrorCode.ProtocolError, input)); - return; - } - - // Compressed-relay DATA: resolve stream remap + state before reading payload so we can - // ReadExact straight into the rented wire buffer (skip the shared frame `buffer` copy). - if (type == Http2FrameType.Data) - { - var dataStreamId = streamId; - if (originReceiveLeg != null && peerStreamId != 0) - { - if (!originReceiveLeg.OriginToClient.TryGetValue(peerStreamId, out dataStreamId)) - { - await GrantReceiveCreditAsync(peerStreamId, length, forceFlush: true); - await intake.DiscardAsync(length, cancellationToken); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - peerStreamId, Http2ErrorCode.StreamClosed, input)); - continue; - } - - frameHeader.StreamId = dataStreamId; - frameHeaderBuffer[5] = (byte)((dataStreamId >> 24) & 0x7f); - frameHeaderBuffer[6] = (byte)((dataStreamId >> 16) & 0xff); - frameHeaderBuffer[7] = (byte)((dataStreamId >> 8) & 0xff); - frameHeaderBuffer[8] = (byte)(dataStreamId & 0xff); - } - - if (connectionState.Streams.TryGetValue(dataStreamId, out var compressedDataState) - && (compressedDataState.IsCompressedRelay - || (isClient && compressedDataState.RequestDataCompressedRelay) - || (!isClient && compressedDataState.ResponseDataCompressedRelay))) - { - bool dataEndStream = (flags & Http2FrameFlag.EndStream) != 0; - var creditStreamId = originReceiveLeg != null ? peerStreamId : dataStreamId; - if (dataEndStream) - { - // Tiny-GET hot path: END_STREAM closes the stream — skip stream WINDOW_UPDATE - // and do not force-flush connection credit (was one WINDOW_UPDATE pair per - // ~56 B response; profiled ~6% in GrantReceiveCredit). - if (length > 0) - pendingConnectionReceiveCredit += length; - pendingStreamReceiveCredit.Remove(creditStreamId); - if (pendingConnectionReceiveCredit >= ReceiveCreditBatchThreshold) - { - var connBytes = pendingConnectionReceiveCredit; - pendingConnectionReceiveCredit = 0; - await GrantReceiveCreditLockedAsync(0, connBytes, 0); - } - } - else - { - await GrantReceiveCreditAsync(creditStreamId, length, forceFlush: false); - } - - Http2FrameWriter? dedicatedWriter = null; - if (isClient && connectionState.OriginRelayPool != null - && connectionState.OriginRelayPool.TryGetAssignment(dataStreamId, out var assignment)) - { - var wireStreamId = assignment.OriginStreamId; - dedicatedWriter = assignment.Leg.Writer; - await assignment.Leg.SendFlow - .ReserveAsync(wireStreamId, length, cancellationToken) - .ConfigureAwait(false); - frameHeader.StreamId = wireStreamId; - frameHeaderBuffer[5] = (byte)((wireStreamId >> 24) & 0x7f); - frameHeaderBuffer[6] = (byte)((wireStreamId >> 16) & 0xff); - frameHeaderBuffer[7] = (byte)((wireStreamId >> 8) & 0xff); - frameHeaderBuffer[8] = (byte)(wireStreamId & 0xff); - } - else if (!isClient && originReceiveLeg != null) - { - dedicatedWriter = connectionState.ClientFrameWriter; - await outboundFlow.ReserveAsync(dataStreamId, length, cancellationToken); - } - else - { - await outboundFlow.ReserveAsync(dataStreamId, length, cancellationToken); - } - - var wireLen = 9 + length; - var rented = ArrayPool.Shared.Rent(wireLen); - frameHeader.CopyToBuffer(rented); - if (length > 0 && !await intake.ReadExactAsync(rented, 9, length, cancellationToken)) - { - ArrayPool.Shared.Return(rented); - await TrySendGracefulGoAwayAsync(); - return; - } - - if (dedicatedWriter != null) - dedicatedWriter.EnqueueRented(rented, wireLen); - else - connectionState.EnqueueWriteRented(towardServer: isClient, outputWriteLock, output, - rented, wireLen); - - if (dataEndStream - && connectionState.Streams.TryGetValue(dataStreamId, out var closingCompressed)) - { - if (isClient) - closingCompressed.RequestClosed = true; - else - closingCompressed.ResponseClosed = true; - - if (closingCompressed.IsClosed) - { - connectionState.OriginRelayPool?.ReleaseStream(dataStreamId); - connectionState.RemoveStream(dataStreamId); - ScheduleFinalize(closingCompressed, onAfterResponse, logger, connectionState); - } - } - - continue; - } - - // Not compressed-relay DATA: restore peer stream id so the shared remap below - // can apply OriginToClient after the payload is read into `buffer`. - if (originReceiveLeg != null && peerStreamId != 0) - streamId = peerStreamId; - } - - if (length > 0 && !await intake.ReadExactAsync(buffer, 0, length, cancellationToken)) - { - await TrySendGracefulGoAwayAsync(); - return; - } - - if (originReceiveLeg != null && peerStreamId != 0) - { - if (!originReceiveLeg.OriginToClient.TryGetValue(peerStreamId, out var clientStreamId)) - { - if (type == Http2FrameType.Data) - await GrantReceiveCreditAsync(peerStreamId, length, forceFlush: true); - - if (type is Http2FrameType.Data or Http2FrameType.Headers or Http2FrameType.RstStream - or Http2FrameType.Continuation or Http2FrameType.Priority) - { - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - peerStreamId, Http2ErrorCode.StreamClosed, input)); - } - - continue; - } - - streamId = clientStreamId; - frameHeader.StreamId = streamId; - frameHeaderBuffer[5] = (byte)((streamId >> 24) & 0x7f); - frameHeaderBuffer[6] = (byte)((streamId >> 16) & 0xff); - frameHeaderBuffer[7] = (byte)((streamId >> 8) & 0xff); - frameHeaderBuffer[8] = (byte)(streamId & 0xff); - } - if (type == Http2FrameType.PushPromise) - { - // This proxy always advertises SETTINGS_ENABLE_PUSH=0 toward the server (see the - // SETTINGS handling below), so a PUSH_PROMISE is never valid in either direction: from - // the client it is always meaningless (clients don't push), and from the server it is a - // direct violation of the value we declared (RFC 7540 ?6.6: "PUSH_PROMISE MUST NOT be - // sent if SETTINGS_ENABLE_PUSH... is 0"). Reject as a connection-level PROTOCOL_ERROR - // rather than attempting to decode/relay it: this relay's decoder for this direction - // never observes the encode event a forwarded-but-undecoded push header block would - // represent, which would otherwise permanently desync HPACK for every later header - // block from the same peer. Tearing down the whole connection avoids that risk entirely. - ReportException(logger, new ProxyHttpException( - $"HTTP/2 protocol error: unexpected PUSH_PROMISE frame from the {(isClient ? "client" : "server")}.", - null, null)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, - Http2ErrorCode.ProtocolError, input)); - return; - } - - bool sendPacket = true; - bool endStream = false; - - SessionEventArgs? args = null; - RequestResponseBase? rr = null; - Http2StreamState? existingStreamState = null; - if ((type == Http2FrameType.Data || type == Http2FrameType.Headers) && - connectionState.Streams.TryGetValue(streamId, out existingStreamState)) - { - args = existingStreamState.SessionArgs; - } - - // Request DATA must not be routed before the stream's BeforeRequest dispatch has finished: - // the dispatch task (thread-pool since the HEADERS decode was decoupled from handler - // execution) is what marks bridge/synthetic streams (syntheticStreams, Http2IgnoreBodyFrames). - // DATA racing past it falls through to the default relay and reserves send-window credit - // toward the origin leg - which for bridge connections is a NullOriginStream that never - // grants WINDOW_UPDATE, permanently leaking the 64 KiB connection window and deadlocking the - // whole frame loop in ReserveAsync (uploads and every response writer stall together). The - // The dispatch completes even when the user handler is still waiting on the request body - // (ReadHttp2BeforeHandlerTaskCompletionSource unblocks it), so awaiting here cannot deadlock. - // The END_STREAM/SendBody path below already relies on the same contract. - if (isClient && type == Http2FrameType.Data - && args?.HttpClient.Request.Http2BeforeHandlerTask is { IsCompleted: false } dataDispatch) - { - await dataDispatch; - } - - if (type == Http2FrameType.Data && existingStreamState == null) - { - // DATA is flow-controlled at the connection level even when it arrives - // for an already-closed stream. Return that connection credit, then reject - // the frame locally instead of relaying it to the other leg. - await GrantReceiveCreditAsync(streamId, length, forceFlush: true); - - bool isIdleStream = streamId > connectionState.LastClientStreamId || (streamId & 1) == 0; - if (isIdleStream) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: DATA frame received for an idle stream.", null, null)); - await lockedOwnLegWrite(() => SendGoAwayAsync( - new Http2FrameHeader(), new byte[9], connectionState.LastClientStreamId, - Http2ErrorCode.ProtocolError, input)); - return; - } - - await lockedOwnLegWrite(() => SendRstStreamAsync( - new Http2FrameHeader(), new byte[9], streamId, Http2ErrorCode.StreamClosed, input)); - continue; - } - - // HEADERS/CONTINUATION must always be decoded - even for a stream already answered - // synthetically - because HPACK's dynamic table is connection-scoped: skipping the decode - // of any header block silently desyncs this connection's decoder from the peer's encoder - // for every subsequent stream. Suppressing the *forward* of a synthetic stream's trailers - // is handled inside ProcessCompleteHeaderBlockAsync instead of the blanket synthetic-stream - // gate used for other frame types below, so both are checked ahead of that gate. - if (type == Http2FrameType.Headers) - { - bool endHeaders = (flags & Http2FrameFlag.EndHeaders) != 0; - bool padded = (flags & Http2FrameFlag.Padded) != 0; - bool priority = (flags & Http2FrameFlag.Priority) != 0; - bool endStreamFlag = (flags & Http2FrameFlag.EndStream) != 0; - - int offset = 0; - int padLength = 0; - if (padded) - { - padLength = buffer[0]; - offset = 1; - } - - bool compressedRelayHeaders = useCompressedRelay - && (existingStreamState == null || existingStreamState.IsCompressedRelay); - - if (compressedRelayHeaders) - { - if (existingStreamState == null) - { - if (isClient) - { - if (streamId % 2 == 0 || streamId <= connectionState.LastClientStreamId) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: invalid client stream id on compressed-relay HEADERS.", - null, null)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], - connectionState.LastClientStreamId, Http2ErrorCode.ProtocolError, input)); - return; - } - - connectionState.LastClientStreamId = streamId; - } - - if (connectionState.ServerGoingAway && - streamId > connectionState.ServerLastStreamId) - { - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - streamId, Http2ErrorCode.RefusedStream, input)); - continue; - } - - if (isClient && connectionState.ClientResetBudgetExceeded) - { - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - streamId, Http2ErrorCode.RefusedStream, input)); - continue; - } - - existingStreamState = connectionState.RegisterCompressedRelayStream(streamId); - if (connectionState.Streams.Count > remoteSettings.MaxConcurrentStreams) - { - RemoveAndFinalizeStream(streamId); - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - streamId, Http2ErrorCode.RefusedStream, input)); - continue; - } - } - - if (priority) - offset += 5; - - int fragmentLength = length - offset - padLength; - if (fragmentLength < 0) - fragmentLength = 0; - - if (pendingHeaderBlock != null) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: HEADERS frame received while a previous header block on this connection was still open.", - null, null)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], - pendingHeaderStreamId, Http2ErrorCode.ProtocolError, input)); - return; - } - - if (endHeaders) - { - var fragment = new byte[fragmentLength]; - Buffer.BlockCopy(buffer, offset, fragment, 0, fragmentLength); - await RelayCompressedHeaderBlockAsync(streamId, fragment, endStreamFlag); - if (endStreamFlag) - endStream = true; - } - else - { - pendingHeaderBlock = new MemoryStream(); - await pendingHeaderBlock.WriteAsync(buffer.AsMemory(offset, fragmentLength), - cancellationToken); - pendingHeaderStreamId = streamId; - pendingHeaderArgs = null; - pendingHeaderRr = null; - pendingHeaderEndStream = endStreamFlag; - pendingHeaderIsPromise = false; - pendingCompressedRelay = true; - pendingHeaderBlockFrameCount = 1; - pendingHeaderBlockOpenedAt = Environment.TickCount64; - } - - sendPacket = false; - } - else - { - if (args == null) - { - args = sessionFactory(); - // Gate off: every stream on this connection uses the fast-forward path. - // When the gate is on, IsFastPath may still be set per-stream after HEADERS decode - // once :authority / method / path are known (predicate evaluation below). - if (!httpInterceptionEnabled) - args.IsFastPath = true; - connectionState.RegisterStream(streamId, args); - } - - rr = isClient ? (RequestResponseBase)args.HttpClient.Request : args.HttpClient.Response; - if (priority) - { - var priorityData = ((long)buffer[offset++] << 32) + ((long)buffer[offset++] << 24) + - (buffer[offset++] << 16) + (buffer[offset++] << 8) + buffer[offset++]; - rr.Priority = priorityData; - } - - int fragmentLength = length - offset - padLength; - if (fragmentLength < 0) - { - fragmentLength = 0; - } - - if (pendingHeaderBlock != null) - { - // RFC 7540 ?6.10: only a CONTINUATION frame for the same stream may follow a - // HEADERS frame sent without END_HEADERS. Anything else while a block is still - // open (including a new HEADERS frame) is a connection-level PROTOCOL_ERROR. - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: HEADERS frame received while a previous header block on this connection was still open.", - null, args)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], - pendingHeaderStreamId, Http2ErrorCode.ProtocolError, input)); - return; - } - - if (endHeaders) - { - var fragment = new byte[fragmentLength]; - Buffer.BlockCopy(buffer, offset, fragment, 0, fragmentLength); - bool isInterim = await ProcessCompleteHeaderBlockAsync(streamId, args, rr, fragment, - endStreamFlag, args.IsPromise); - if (endStreamFlag && !isInterim) - { - endStream = true; - - // Matches HTTP/1.x's RequestSentAt timing mark for the client leg, and finalizes - // timing for the response leg (see MarkComplete's remarks on OnAfterResponse - - // this is normally called again there too, but the guard there makes that a - // no-op, so CompletedAt reflects this earlier, more precise instant instead) for - // the common single-frame (no CONTINUATION needed) no-body/trailer-terminated case. - if (isClient) args.Timing?.MarkRequestSent(); - else args.Timing?.MarkComplete(); - } - } - else - { - // start of a multi-frame header block; buffer this fragment and wait for the - // CONTINUATION frame(s) that must immediately follow on the same stream. - pendingHeaderBlock = new MemoryStream(); - await pendingHeaderBlock.WriteAsync(buffer.AsMemory(offset, fragmentLength), cancellationToken); - pendingHeaderStreamId = streamId; - pendingHeaderArgs = args; - pendingHeaderRr = rr; - pendingHeaderEndStream = endStreamFlag; - pendingHeaderIsPromise = args.IsPromise; - pendingCompressedRelay = false; - pendingHeaderBlockFrameCount = 1; - pendingHeaderBlockOpenedAt = Environment.TickCount64; - } - - sendPacket = false; - } - } - else if (type == Http2FrameType.Continuation) - { - if (pendingHeaderBlock == null || pendingHeaderStreamId != streamId) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: unexpected CONTINUATION frame.", null, args)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, - Http2ErrorCode.ProtocolError, input)); - return; - } - - if (pendingHeaderBlock.Length + length > MaxHeaderBlockBytes) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 header block exceeded the maximum allowed compressed size.", null, - pendingHeaderArgs)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, - Http2ErrorCode.EnhanceYourCalm, input)); - return; - } - - // Frame-count and wall-clock bound: a zero-length CONTINUATION never advances - // pendingHeaderBlock.Length above, so the byte cap alone cannot bound an attacker - // that never sets END_HEADERS and sends an endless sequence of empty CONTINUATION - // frames (or paces non-empty ones just slowly enough to never look byte-abusive). - pendingHeaderBlockFrameCount++; - var openMillis = Environment.TickCount64 - pendingHeaderBlockOpenedAt; - var http2AbuseMode = pendingHeaderArgs?.Server.PolicyModes[PolicyFamily.Http2AbuseBudget] - ?? PolicyMode.Enforce; - var continuationBudgetBreached = http2AbuseMode != PolicyMode.Disabled && - (pendingHeaderBlockFrameCount > resourceLimits.MaxOpenHeaderBlockFrames || - openMillis > resourceLimits.MaxOpenHeaderBlockDuration.TotalMilliseconds); - - if (continuationBudgetBreached) - { - ProxyMetrics.PolicyBreach(PolicyFamily.Http2AbuseBudget, http2AbuseMode); - - // Enforce-only reaction: Observe records the breach (above) but must not tear - // down the connection, since the whole point of Observe is measuring what a - // stricter mode would have caught without acting on it yet. - if (http2AbuseMode == PolicyMode.Enforce) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 header block exceeded the maximum allowed CONTINUATION frame count or " + - "stayed open too long - possible CONTINUATION flood.", null, pendingHeaderArgs)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], - streamId, Http2ErrorCode.EnhanceYourCalm, input)); - return; - } - } - - await pendingHeaderBlock.WriteAsync(buffer.AsMemory(0, length), cancellationToken); - - if ((flags & Http2FrameFlag.EndHeaders) != 0) - { - var completeBlock = pendingHeaderBlock.ToArray(); - var pStreamId = pendingHeaderStreamId; - var pArgs = pendingHeaderArgs; - var pRr = pendingHeaderRr; - var pEndStream = pendingHeaderEndStream; - var pIsPromise = pendingHeaderIsPromise; - var pCompressedRelay = pendingCompressedRelay; - - pendingHeaderBlock = null; - pendingHeaderArgs = null; - pendingHeaderRr = null; - pendingHeaderStreamId = -1; - pendingHeaderBlockFrameCount = 0; - pendingHeaderBlockOpenedAt = 0; - pendingCompressedRelay = false; - - args = pArgs; - rr = pRr; - - if (pCompressedRelay) - { - await RelayCompressedHeaderBlockAsync(pStreamId, completeBlock, pEndStream); - if (pEndStream) - endStream = true; - } - else - { - bool isInterim = await ProcessCompleteHeaderBlockAsync(pStreamId, pArgs!, pRr!, completeBlock, - pEndStream, pIsPromise); - if (pEndStream && !isInterim) - { - endStream = true; - - // Matches HTTP/1.x's RequestSentAt/MarkComplete timing marks (see - // RequestHandler.HandleHttpSessionRequest / ResponseHandler.OnAfterResponse) - // for the no-body (headers-only END_STREAM, or trailer-terminated) case; the - // with-body case is stamped where the terminating DATA frame is handled below. - if (isClient) pArgs!.Timing?.MarkRequestSent(); - else pArgs!.Timing?.MarkComplete(); - } - } - } - - sendPacket = false; - } - else if (type == Http2FrameType.Data && existingStreamState?.IsCompressedRelay == true) - { - // Passthrough: grant receive credit and forward the frame unchanged (no body API). - bool dataEndStream = (flags & Http2FrameFlag.EndStream) != 0; - var creditStreamId = originReceiveLeg != null ? peerStreamId : streamId; - await GrantReceiveCreditAsync(creditStreamId, length, forceFlush: dataEndStream); - if (dataEndStream) - endStream = true; - // sendPacket remains true - } - else if (isClient && syntheticStreams.ContainsKey(streamId) - && type != Http2FrameType.WindowUpdate - && type != Http2FrameType.RstStream) - { - // This stream was answered with a synthetic / external-bridge response; never forward - // its request frames upstream. WINDOW_UPDATE and RST_STREAM must still fall through: - // EmitSyntheticResponseAsync / RespondStreaming write DATA toward the client under - // ClientSendFlow, which is replenished only by stream-level WINDOW_UPDATE from the - // client. Swallowing those frames stalls every synthetic body larger than the default - // 64 KiB stream window (.NET HttpClient, browsers, etc.). - sendPacket = false; - - if (type == Http2FrameType.Data) - { - bool dataEndStream = (flags & Http2FrameFlag.EndStream) != 0; - await GrantReceiveCreditAsync(streamId, length, forceFlush: dataEndStream); - - // External-bridge streaming: pump DATA into InboundRequestBodyChannel instead of - // discarding it. Create the channel in onBeforeRequest before returning. - if (connectionState.Streams.TryGetValue(streamId, out var synthState) - && synthState.InboundRequestBodyChannel != null - && args != null - && !args.HttpClient.Request.Http2IgnoreBodyFrames) - { - int dataOff = (flags & Http2FrameFlag.Padded) != 0 ? 1 : 0; - int dataLen = (flags & Http2FrameFlag.Padded) != 0 ? length - 1 - buffer[0] : length; - if (dataLen < 0) dataLen = 0; - if (dataLen > 0) - { - var rented = ArrayPool.Shared.Rent(dataLen); - Buffer.BlockCopy(buffer, dataOff, rented, 0, dataLen); - // TryWrite only — never await on the frame loop (HOL for every stream - // on this connection). Bound is large; full means the origin pump stalled. - if (!synthState.InboundRequestBodyChannel.Writer.TryWrite((rented, dataLen))) - { - ArrayPool.Shared.Return(rented); - ReportException(logger, new ProxyHttpException( - "HTTP/2 bridge stream exceeded its bounded request-body buffer.", - null, args)); - RemoveAndFinalizeStream(streamId); - await lockedOwnLegWrite(() => SendRstStreamAsync( - new Http2FrameHeader(), new byte[9], streamId, - Http2ErrorCode.EnhanceYourCalm, input)); - } - } - - if (dataEndStream) - { - endStream = true; - synthState.InboundRequestBodyChannel.Writer.TryComplete(); - } - - rr = args.HttpClient.Request; - } - } - } - else if (type == Http2FrameType.Data && args != null) - { - // Grant back the credit consumed by reading this frame's on-wire payload before doing - // anything else with it. Batched at ReceiveCreditBatchThreshold; flushed on END_STREAM. - bool dataEndStream = (flags & Http2FrameFlag.EndStream) != 0; - await GrantReceiveCreditAsync(streamId, length, forceFlush: dataEndStream); - - connectionState.Streams.TryGetValue(streamId, out var dataStreamState); - - // RFC 8441 h2→h1 bridge: route frame payload directly to the per-stream channel - // rather than the normal body-buffering path. The channel is created by - // BridgeOnBeforeRequest before the tunnel task starts, so it is always populated - // before the first DATA frame for the stream can be processed here. - if (isClient - && dataStreamState?.IsExtendedConnect == true - && dataStreamState.InboundTunnelChannel != null) - { - bool endStreamFlag = (flags & Http2FrameFlag.EndStream) != 0; - int dataOff = (flags & Http2FrameFlag.Padded) != 0 ? 1 : 0; - int dataLen = (flags & Http2FrameFlag.Padded) != 0 ? length - 1 - buffer[0] : length; - if (dataLen < 0) dataLen = 0; - if (dataLen > 0) - { - var chunk = new byte[dataLen]; - Buffer.BlockCopy(buffer, dataOff, chunk, 0, dataLen); - if (!dataStreamState.InboundTunnelChannel.Writer.TryWrite( - new ReadOnlyMemory(chunk))) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 extended CONNECT stream exceeded its bounded relay buffer.", - null, args)); - RemoveAndFinalizeStream(streamId); - await lockedOwnLegWrite(() => SendRstStreamAsync( - new Http2FrameHeader(), new byte[9], streamId, - Http2ErrorCode.EnhanceYourCalm, input)); - } - } - if (endStreamFlag) - { - endStream = true; - dataStreamState.InboundTunnelChannel.Writer.TryComplete(); - } - - rr = args.HttpClient.Request; // required for the endStream cleanup block below - sendPacket = false; - } - else if (dataStreamState?.IsExtendedConnect == true - && dataStreamState.InboundTunnelChannel == null - && (isClient || dataStreamState.ExtendedConnectEstablished)) - { - // RFC 8441 native h2↔h2 tunnel: relay DATA unchanged, fire events with the - // unpadded payload bytes only, and bypass HTTP body buffering and mutation hooks. - bool endStreamFlag = (flags & Http2FrameFlag.EndStream) != 0; - bool padded = (flags & Http2FrameFlag.Padded) != 0; - int payloadOff = padded ? 1 : 0; - int padLen = padded ? buffer[0] : 0; - int payloadLen = length - payloadOff - padLen; - if (payloadLen < 0) payloadLen = 0; - - // Reject DATA from a direction whose half is already closed (RFC 9113 §6.9). - bool halfClosed = isClient - ? dataStreamState.RequestClosed - : dataStreamState.ResponseClosed; - if (halfClosed) - { - ReportException(logger, new ProxyHttpException( - $"HTTP/2 protocol error: DATA received on a half-closed ({(isClient ? "local" : "remote")}) stream.", - null, args)); - await lockedOwnLegWrite(() => SendRstStreamAsync( - new Http2FrameHeader(), new byte[9], streamId, - Http2ErrorCode.StreamClosed, input)); - sendPacket = false; - } - else - { - if (isClient) - args.OnDataSent(buffer, payloadOff, payloadLen); - else - args.OnDataReceived(buffer, payloadOff, payloadLen); - - if (endStreamFlag) - { - endStream = true; - if (isClient) args.Timing?.MarkRequestSent(); - else args.Timing?.MarkComplete(); - } - } - - rr = isClient ? (RequestResponseBase)args.HttpClient.Request : args.HttpClient.Response; - // sendPacket remains true: forward the raw frame unchanged. - } - else - { - if (isClient) - args.OnDataSent(buffer, 0, length); - else - args.OnDataReceived(buffer, 0, length); - - rr = isClient ? (RequestResponseBase)args.HttpClient.Request : args.HttpClient.Response; - - bool padded = (flags & Http2FrameFlag.Padded) != 0; - bool endStreamFlag = (flags & Http2FrameFlag.EndStream) != 0; - if (endStreamFlag) - { - endStream = true; - - // Matches HTTP/1.x's RequestSentAt/MarkComplete timing marks for the with-body case - // (the headers-only/trailer-terminated case is stamped above). - if (isClient) args.Timing?.MarkRequestSent(); - else args.Timing?.MarkComplete(); - } - - // HTTP/2 multipart/form-data boundary-aware streaming observation (purely observational). - if (isClient && args.HasMulipartEventSubscribers && - args.HttpClient.Request.IsMultipartFormData) - { - var mpContentType = args.HttpClient.Request.ContentType; - if (mpContentType != null) - { - if (!connectionState.MultipartObservers.TryGetValue(streamId, out var mpObserver)) - { - var mpBoundaryMemory = HttpHelper.GetBoundaryFromContentType(mpContentType); - var mpBoundary = mpBoundaryMemory.IsEmpty - ? string.Empty - : mpBoundaryMemory.ToString(); - var newObserver = MultipartStreamObserver.TryCreate( - mpContentType, - headers => args.OnMultipartRequestPartSent(mpBoundary.AsSpan(), headers), - null); - if (newObserver != null) - { - connectionState.MultipartObservers.TryAdd(streamId, newObserver); - mpObserver = newObserver; - } - } - - if (mpObserver != null) - { - int mpOffset = padded ? 1 : 0; - int mpLength = padded ? length - 1 - buffer[0] : length; - if (mpLength < 0) mpLength = 0; - if (mpLength > 0) - mpObserver.Observe(new ReadOnlySpan(buffer, mpOffset, mpLength)); - } - } - } - - if (rr.Http2IgnoreBodyFrames) - { - sendPacket = false; - } - - if (rr.ReadHttp2BodyTaskCompletionSource != null) - { - // Get body method was called in the "before" event handler - - var data = rr.Http2BodyData; - int offset = 0; - if (padded) - { - offset++; - length--; - length -= buffer[0]; - } - - if (data == null) - throw new InvalidOperationException("HTTP/2 body buffering was requested without a buffer."); - - // Native H2 whole-body buffering (BeforeRequest/BeforeResponse called - // GetRequestBody/GetResponseBody) has no cumulative cap of its own: each DATA - // frame is already bounded by SETTINGS_MAX_FRAME_SIZE, but per-frame limits are - // not cumulative limits, so a peer sending enough frames could otherwise grow - // this MemoryStream unbounded. Mirrors the extended-CONNECT relay-buffer-exceeded - // handling just above: abort only this stream (not the whole connection), and - // fault the waiting body-read task so ReadRequestBodyAsync/ReadResponseBodyAsync - // surfaces BodySizeLimitExceededException instead of hanging forever. - var maxBufferedBodyBytes = args.MaxBufferedBodyBytes ?? args.Server.MaxBufferedBodyBytes; - var bodyBudgetMode = args.Server.PolicyModes[PolicyFamily.BodyBudget]; - var bodyBudgetBreached = bodyBudgetMode != PolicyMode.Disabled && - maxBufferedBodyBytes > 0 && - data.Length + length > maxBufferedBodyBytes; - - if (bodyBudgetBreached) ProxyMetrics.PolicyBreach(PolicyFamily.BodyBudget, bodyBudgetMode); - - if (bodyBudgetBreached && bodyBudgetMode == PolicyMode.Enforce) - { - // Intentional policy enforcement, not a proxy defect — Debug only. - ProxyDiagnostics.ReportBenign(logger, - $"HTTP/2 {(isClient ? "request" : "response")} body exceeded the configured " + - $"buffering limit of {maxBufferedBodyBytes:N0} bytes.", - new ProxyHttpException( - $"HTTP/2 {(isClient ? "request" : "response")} body exceeded the configured " + - $"buffering limit of {maxBufferedBodyBytes:N0} bytes.", null, args)); - - var sizeLimitException = new BodySizeLimitExceededException( - $"HTTP/2 body byte count {data.Length + length:N0} exceeds the limit of {maxBufferedBodyBytes:N0}."); - - var pendingTcs = rr.ReadHttp2BodyTaskCompletionSource; - rr.ReadHttp2BodyTaskCompletionSource = null; - pendingTcs.TrySetException(sizeLimitException); - - if (rr.Http2BodyData != null) await rr.Http2BodyData.DisposeAsync(); - rr.Http2BodyData = null; - - RemoveAndFinalizeStream(streamId); - await lockedOwnLegWrite(() => SendRstStreamAsync( - new Http2FrameHeader(), new byte[9], streamId, - Http2ErrorCode.EnhanceYourCalm, input)); - sendPacket = false; - } - else - { - // Disabled, or Observe: the breach (if any) was already recorded above, but - // the stream is not reset and the caller's whole-body read is not faulted - - // per the plan, Observe detects without acting. - await data.WriteAsync(buffer.AsMemory(offset, length), cancellationToken); - } - } - else if (!args.IsFastPath && !rr.Http2IgnoreBodyFrames && !rr.IsBodyRead && - (isClient - ? args.Server.ShouldCallBeforeRequestBodyWrite() - : args.Server.ShouldCallBeforeResponseBodyWrite())) - { - // per-DATA-frame inspection/modification hook (streams without buffering the whole body) - int dataOffset = 0; - int dataLength = length; - if (padded) - { - var padLength = buffer[0]; - dataOffset = 1; - dataLength = length - 1 - padLength; - if (dataLength < 0) dataLength = 0; - } - - var dataBytes = new byte[dataLength]; - Buffer.BlockCopy(buffer, dataOffset, dataBytes, 0, dataLength); - - var bodyWriteArgs = new BeforeBodyWriteEventArgs(args, dataBytes, true, endStreamFlag); - if (isClient) - await args.Server.OnBeforeRequestBodyWrite(bodyWriteArgs); - else - await args.Server.OnBeforeResponseBodyWrite(bodyWriteArgs); - - var outBytes = bodyWriteArgs.BodyBytes ?? Array.Empty(); - - // Reserve outside outputWriteLock — same ordering as the default DATA relay above. - await SendData(frameHeader, frameHeaderBuffer, streamId, outBytes, - endStreamFlag, remoteSettings.MaxFrameSize, outboundFlow, output, cancellationToken, - outputWriteLock); - - // we have emitted our own (possibly re-sized) DATA frame(s); suppress the default relay - sendPacket = false; - } - } - } - else if (type == Http2FrameType.WindowUpdate) - { - sendPacket = false; - - if (length != 4) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: WINDOW_UPDATE frame with invalid length.", null, args)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, - Http2ErrorCode.FrameSizeError, input)); - return; - } - - int increment = ((buffer[0] & 0x7f) << 24) + (buffer[1] << 16) + (buffer[2] << 8) + buffer[3]; - if (increment == 0) - { - // RFC 7540 ?6.9.1: a zero increment is a stream error (or connection error if - // stream id 0) of type PROTOCOL_ERROR. - if (streamId == 0) - { - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], 0, - Http2ErrorCode.ProtocolError, input)); - return; - } - - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - peerStreamId, Http2ErrorCode.ProtocolError, input)); - } - else - { - // Multi-origin: each origin leg has its own send window (peer ids). - Http2FlowController flow; - if (originReceiveLeg != null) - flow = originReceiveLeg.SendFlow; - else if (isClient) - flow = connectionState.ClientSendFlow; - else - flow = connectionState.ServerSendFlow; - var flowStreamId = originReceiveLeg != null ? peerStreamId : streamId; - bool overflow = flow.OnWindowUpdate(flowStreamId, increment); - if (overflow) - { - // RFC 7540 ?6.9.1: a WINDOW_UPDATE that drives a flow-control window above - // 2^31-1 is a FLOW_CONTROL_ERROR - stream-level (RST_STREAM) for a stream - // window, connection-level (GOAWAY) for the connection window. - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: WINDOW_UPDATE increment overflowed the flow-control window.", - null, args)); - if (flowStreamId == 0) - { - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], 0, - Http2ErrorCode.FlowControlError, input)); - return; - } - - await lockedOwnLegWrite(() => SendRstStreamAsync(new Http2FrameHeader(), new byte[9], - peerStreamId, Http2ErrorCode.FlowControlError, input)); - } - } - } - else if (type == Http2FrameType.Ping) - { - sendPacket = false; - - if (length != 8) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: PING frame with invalid length.", null, args)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, - Http2ErrorCode.FrameSizeError, input)); - return; - } - - if ((flags & Http2FrameFlag.Ack) == 0) - { - // terminate PING/PONG locally on the leg it arrived on rather than relaying it - // through to the other leg, which has no bearing on this leg's round trip. - var ackPayload = new byte[8]; - Buffer.BlockCopy(buffer, 0, ackPayload, 0, 8); - await lockedOwnLegWrite(async () => - { - // dedicated header/buffer - never the outer `frameHeader`/`frameHeaderBuffer`, - // which still holds this same PING frame's own metadata that the main loop below - // (harmlessly, since PING always suppresses the default relay) still references. - var pingFrameHeader = new Http2FrameHeader - { - StreamId = 0, Type = Http2FrameType.Ping, Flags = Http2FrameFlag.Ack, Length = 8 - }; - var pingFrameHeaderBuffer = new byte[9]; - pingFrameHeader.CopyToBuffer(pingFrameHeaderBuffer); - await input.WriteAsync(pingFrameHeaderBuffer.AsMemory(), cancellationToken); - await input.WriteAsync(ackPayload.AsMemory(0, 8), cancellationToken); - }); - } - // an ACK for a PING this proxy never sends today - nothing to do. - } - else if (type == Http2FrameType.GoAway) - { - // Overflow origin GOAWAY must not tear down the client session. - sendPacket = !suppressConnectionFrameRelay; - - if (length >= 8) - { - int lastStreamId = ((buffer[0] & 0x7f) << 24) + (buffer[1] << 16) + (buffer[2] << 8) + buffer[3]; - if (isClient) - { - connectionState.ClientGoingAway = true; - connectionState.ClientLastStreamId = lastStreamId; - } - else if (!suppressConnectionFrameRelay) - { - connectionState.ServerGoingAway = true; - connectionState.ServerLastStreamId = lastStreamId; - } - - // unblock any stream-scoped waiter (synthetic response task, etc.) for streams the - // sender has already said it will not process, without tearing down the streams - // that are still permitted to drain. - if (!suppressConnectionFrameRelay) - { - foreach (var kvp in connectionState.Streams) - { - if (kvp.Key > lastStreamId) - { - connectionState.MultipartObservers.TryRemove(kvp.Key, out _); - // RFC 8441: unblock any tunnel relay waiting on the inbound channel - // so it can shut down promptly without waiting for more DATA frames - // that the peer has already said it will not send. - kvp.Value.InboundTunnelChannel?.Writer.TryComplete( - new IOException("Connection received GOAWAY.")); - await kvp.Value.Cancellation.CancelAsync(); - kvp.Value.Cancellation.Dispose(); - } - } - } - } - } - else if (type == Http2FrameType.Settings) - { - if (length % 6 != 0) - { - // https://httpwg.org/specs/rfc7540.html#SETTINGS - // 6.5. SETTINGS - // A SETTINGS frame with a length other than a multiple of 6 octets MUST be treated as a connection error (Section 5.4.1) of type FRAME_SIZE_ERROR - ReportException(logger, new ProxyHttpException("Invalid settings length", null, null)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, - Http2ErrorCode.FrameSizeError, input)); - return; - } - - if ((flags & Http2FrameFlag.Ack) != 0 && length != 0) - { - // RFC 7540 ?6.5: "Receipt of a SETTINGS frame with the ACK flag set and a length - // field value other than 0 MUST be treated as a connection error of type - // FRAME_SIZE_ERROR." - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: SETTINGS ACK frame with non-zero length.", null, null)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, - Http2ErrorCode.FrameSizeError, input)); - return; - } - - bool invalidSettings = false; - Http2ErrorCode invalidSettingsError = Http2ErrorCode.ProtocolError; - bool sawEnablePush = false; - bool sawEnableConnectProtocol = false; - bool sawMaxConcurrentStreams = false; - bool sawInitialWindowSize = false; - bool sawHeaderTableSize = false; - - int pos = 0; - while (pos < length) - { - int identifier = (buffer[pos] << 8) + buffer[pos + 1]; - int valueOffset = pos + 2; - long value = ((long)buffer[valueOffset] << 24) + (buffer[valueOffset + 1] << 16) + - (buffer[valueOffset + 2] << 8) + buffer[valueOffset + 3]; - pos += 6; - - if (identifier == (int)Http2SettingsId.HeaderTableSize) - { - sawHeaderTableSize = true; - if (forceStaticHpackTable) - { - // Force static-table-only so compressed HEADERS are interchangeable across legs. - localSettings.UpdateHeaderTableSize(0); - buffer[valueOffset] = 0; - buffer[valueOffset + 1] = 0; - buffer[valueOffset + 2] = 0; - buffer[valueOffset + 3] = 0; - if (logger.IsEnabled(LogLevel.Trace)) - logger.LogTrace( - "[h2 settings] SETTINGS_HEADER_TABLE_SIZE forced to 0 (compressed relay) from {Direction} (peer sent {Value})", - isClient ? "browser" : "origin", value); - } - else - { - localSettings.UpdateHeaderTableSize((int)value); - if (logger.IsEnabled(LogLevel.Trace)) - logger.LogTrace("[h2 settings] SETTINGS_HEADER_TABLE_SIZE={Value} from {Direction}", - value, isClient ? "browser" : "origin"); - } - } - else if (identifier == (int)Http2SettingsId.MaxFrameSize) - { - // RFC 7540 ?6.5.2: valid range is [2^14, 2^24-1]; below the minimum every - // implementation must support is a PROTOCOL_ERROR. - if (value < 16384 || value > 16777215) - { - invalidSettings = true; - invalidSettingsError = Http2ErrorCode.ProtocolError; - } - else - { - localSettings.MaxFrameSize = (int)value; - } - } - else if (identifier == (int)Http2SettingsId.InitialWindowSize) - { - // RFC 7540 ?6.5.2: valid range is [0, 2^31-1]; above that is a FLOW_CONTROL_ERROR. - if (value > Http2FlowController.MaxWindow) - { - invalidSettings = true; - invalidSettingsError = Http2ErrorCode.FlowControlError; - } - else - { - sawInitialWindowSize = true; - // this peer is telling us the initial send-window it grants us for streams - // we open toward it - i.e. it feeds the SEND flow controller for writes - // toward *this* peer, symmetrically with WINDOW_UPDATE above. - Http2FlowController flow; - if (originReceiveLeg != null) - flow = originReceiveLeg.SendFlow; - else if (isClient) - flow = connectionState.ClientSendFlow; - else - flow = connectionState.ServerSendFlow; - flow.OnInitialWindowSizeChanged((int)value); - - if (!suppressConnectionFrameRelay && value < ClientInitialStreamWindowSize) - { - // Raise only the stream window *advertised to the other leg* (wire rewrite), - // in both directions. Toward the client this lifts upload throughput; toward - // the origin it is required for liveness: receive credit is batched at - // ReceiveCreditBatchThreshold (384 KiB), so an origin left at the RFC-default - // 65,535 window (e.g. relayed from an HttpClient) stalls a >64 KiB response - // body waiting for a WINDOW_UPDATE the batching will never flush. - // Do not change the send flow controller above — that must reflect the - // peer's real grant for writes toward it. - var advertised = ClientInitialStreamWindowSize; - buffer[valueOffset] = (byte)((advertised >> 24) & 0xff); - buffer[valueOffset + 1] = (byte)((advertised >> 16) & 0xff); - buffer[valueOffset + 2] = (byte)((advertised >> 8) & 0xff); - buffer[valueOffset + 3] = (byte)(advertised & 0xff); - } - } - } - else if (identifier == (int)Http2SettingsId.MaxConcurrentStreams) - { - sawMaxConcurrentStreams = true; - var advertised = value > int.MaxValue ? int.MaxValue : (int)value; - - if (!isClient) - { - // This is the server's own SETTINGS frame, about to be relayed on toward - // the real client below. Consolidate what were previously two independent - // mechanisms (this origin-advertised value, admitted against verbatim at - // the isMainHeaders check, and Http2OriginConnection's separate - // proxy-owned concurrencyGate for the H1-to-H2 bridge) into one: clamp to - // the proxy-owned cap and rewrite the wire value so what the client is - // told matches what will actually be enforced. Not clamping the advertised - // value while still enforcing a lower one would let the client legitimately - // open a stream believing it is within budget, only for the proxy to refuse - // it - the PROTOCOL_ERROR-vs-REFUSED_STREAM ambiguity RFC 9113 §5.1.2 warns - // against. - var effective = Math.Min(advertised, resourceLimits.MaxConcurrentStreamsPerConnection); - localSettings.MaxConcurrentStreams = effective; - - buffer[valueOffset] = (byte)((effective >> 24) & 0xff); - buffer[valueOffset + 1] = (byte)((effective >> 16) & 0xff); - buffer[valueOffset + 2] = (byte)((effective >> 8) & 0xff); - buffer[valueOffset + 3] = (byte)(effective & 0xff); - } - else - { - // The client's own SETTINGS value governs server-initiated (push) stream - // admission, which this proxy always advertises as disabled (see the - // ENABLE_PUSH override below) - nothing to consolidate on this leg. - localSettings.MaxConcurrentStreams = advertised; - } - } - else if (identifier == (int)Http2SettingsId.EnablePush) - { - sawEnablePush = true; - if (isClient) - { - // This relay never implements server push translation, so the proxy must - // never let the server believe push is welcome on this connection - - // regardless of what the real client declared (most modern clients already - // send 0 here, but this must not depend on that). Overwrite in place before - // this frame is forwarded to the server below. - buffer[valueOffset] = 0; - buffer[valueOffset + 1] = 0; - buffer[valueOffset + 2] = 0; - buffer[valueOffset + 3] = 0; - } - } - else if (identifier == (int)Http2SettingsId.MaxHeaderListSize) - { - // RFC 7540 §6.5.2: advisory limit on the header list size this peer is willing - // to receive. Store it so outbound header encoding can respect the peer's limit. - localSettings.MaxHeaderListSize = value > int.MaxValue ? int.MaxValue : (int)value; - } - else if (identifier == (int)Http2SettingsId.EnableConnectProtocol) - { - // RFC 8441 §3: the proxy manages ENABLE_CONNECT_PROTOCOL independently per leg. - sawEnableConnectProtocol = true; - - // RFC 8441 §3: value MUST be 0 or 1; any other value is a connection error. - if ((value != 0 && value != 1) || - (!isClient && value == 0 && localSettings.EnableConnectProtocolEverSet)) - { - invalidSettings = true; - invalidSettingsError = Http2ErrorCode.ProtocolError; - } - else if (isClient) - { - // Suppress the client's ENABLE_CONNECT_PROTOCOL preference - do not relay - // it to the server; the proxy negotiates RFC 8441 with each leg independently. - buffer[valueOffset] = 0; - buffer[valueOffset + 1] = 0; - buffer[valueOffset + 2] = 0; - buffer[valueOffset + 3] = 0; - } - else - { - localSettings.EnableConnectProtocol = (value == 1); - if (value == 1) localSettings.EnableConnectProtocolEverSet = true; - - // Overwrite with what the proxy chooses to advertise to the client. - int wireValue = enableRfc8441 ? 1 : 0; - buffer[valueOffset] = 0; - buffer[valueOffset + 1] = 0; - buffer[valueOffset + 2] = 0; - buffer[valueOffset + 3] = (byte)wireValue; - if (wireValue == 1) - connectionState.DownstreamAdvertisedEnableConnect = true; - } - } - } - - if (invalidSettings) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: SETTINGS frame contained an out-of-range value.", null, null)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, - invalidSettingsError, input)); - return; - } - - if (isClient && !sawEnablePush && (flags & Http2FrameFlag.Ack) == 0 && - length + 6 <= buffer.Length) - { - // The client's SETTINGS frame did not declare SETTINGS_ENABLE_PUSH at all (its RFC - // default, 1, would otherwise apply) - append an explicit "disabled" entry before - // relaying this frame to the server, for the same reason as the override above. - buffer[length] = (byte)(((int)Http2SettingsId.EnablePush >> 8) & 0xff); - buffer[length + 1] = (byte)((int)Http2SettingsId.EnablePush & 0xff); - buffer[length + 2] = 0; - buffer[length + 3] = 0; - buffer[length + 4] = 0; - buffer[length + 5] = 0; - length += 6; - frameHeader.Length = length; - } - - if (forceStaticHpackTable && !sawHeaderTableSize && (flags & Http2FrameFlag.Ack) == 0 && - length + 6 <= buffer.Length) - { - // Peer omitted SETTINGS_HEADER_TABLE_SIZE (RFC default 4096). Inject 0 so the - // other leg encodes static-table-only and compressed blocks stay interchangeable. - localSettings.UpdateHeaderTableSize(0); - buffer[length] = (byte)(((int)Http2SettingsId.HeaderTableSize >> 8) & 0xff); - buffer[length + 1] = (byte)((int)Http2SettingsId.HeaderTableSize & 0xff); - buffer[length + 2] = 0; - buffer[length + 3] = 0; - buffer[length + 4] = 0; - buffer[length + 5] = 0; - length += 6; - frameHeader.Length = length; - } - - if (!isClient && !suppressConnectionFrameRelay && enableRfc8441 && !sawEnableConnectProtocol && - (flags & Http2FrameFlag.Ack) == 0 && length + 6 <= buffer.Length) - { - // The server's SETTINGS frame did not include ENABLE_CONNECT_PROTOCOL but the proxy - // is configured to accept RFC 8441 extended CONNECT from clients - inject - // SETTINGS_ENABLE_CONNECT_PROTOCOL=1 so the client knows extended CONNECT is available. - buffer[length] = (byte)(((int)Http2SettingsId.EnableConnectProtocol >> 8) & 0xff); - buffer[length + 1] = (byte)((int)Http2SettingsId.EnableConnectProtocol & 0xff); - buffer[length + 2] = 0; - buffer[length + 3] = 0; - buffer[length + 4] = 0; - buffer[length + 5] = 1; - length += 6; - frameHeader.Length = length; - connectionState.DownstreamAdvertisedEnableConnect = true; - } - - if (!suppressConnectionFrameRelay && !sawInitialWindowSize && (flags & Http2FrameFlag.Ack) == 0 && - length + 6 <= buffer.Length) - { - // Peer omitted SETTINGS_INITIAL_WINDOW_SIZE (RFC default 65535). Inject the - // 768 KiB stream window onto the wire toward the other leg — required toward - // the origin for the same batched-receive-credit liveness reason as the in-place - // rewrite above. - var window = ClientInitialStreamWindowSize; - buffer[length] = (byte)(((int)Http2SettingsId.InitialWindowSize >> 8) & 0xff); - buffer[length + 1] = (byte)((int)Http2SettingsId.InitialWindowSize & 0xff); - buffer[length + 2] = (byte)((window >> 24) & 0xff); - buffer[length + 3] = (byte)((window >> 16) & 0xff); - buffer[length + 4] = (byte)((window >> 8) & 0xff); - buffer[length + 5] = (byte)(window & 0xff); - length += 6; - frameHeader.Length = length; - } - - if (!isClient && !suppressConnectionFrameRelay && !sawMaxConcurrentStreams && - resourceLimits.MaxConcurrentStreamsPerConnection < int.MaxValue && - (flags & Http2FrameFlag.Ack) == 0 && length + 6 <= buffer.Length) - { - // The server's SETTINGS frame did not declare SETTINGS_MAX_CONCURRENT_STREAMS at - // all (its RFC default, unbounded, would otherwise apply) - append an explicit - // entry advertising the proxy-owned cap before relaying this frame to the client, - // for the same "advertised must equal enforced" reason as the in-place overwrite - // above. - var effective = resourceLimits.MaxConcurrentStreamsPerConnection; - localSettings.MaxConcurrentStreams = effective; - - buffer[length] = (byte)(((int)Http2SettingsId.MaxConcurrentStreams >> 8) & 0xff); - buffer[length + 1] = (byte)((int)Http2SettingsId.MaxConcurrentStreams & 0xff); - buffer[length + 2] = (byte)((effective >> 24) & 0xff); - buffer[length + 3] = (byte)((effective >> 16) & 0xff); - buffer[length + 4] = (byte)((effective >> 8) & 0xff); - buffer[length + 5] = (byte)(effective & 0xff); - length += 6; - frameHeader.Length = length; - } - - if (suppressConnectionFrameRelay) - sendPacket = false; - } - - if (type == Http2FrameType.RstStream) - { - if (length != 4) - { - ReportException(logger, new ProxyHttpException( - "HTTP/2 protocol error: RST_STREAM frame with invalid length.", null, args)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], streamId, - Http2ErrorCode.FrameSizeError, input)); - return; - } - - int errorCode = (buffer[0] << 24) + (buffer[1] << 16) + (buffer[2] << 8) + buffer[3]; - - // stream error: cancel any waiter/synthetic task scoped to this stream and stop tracking - // its flow-control windows and session mapping - regardless of the error code, the - // stream is now closed. - // Only remove the multipart observer when the RST came from the client: the observer - // is scoped to the client-side DATA stream and must survive an origin RST_STREAM so - // that any already-received client DATA frames can still finish firing their events. - // (An origin RST_STREAM with NO_ERROR is a normal post-response cleanup by servers - // by strict server stacks; removing the observer here would silently drop multipart events on - // slower hosts where the RST races the client DATA processing.) - if (isClient) - connectionState.MultipartObservers.TryRemove(streamId, out _); - connectionState.OriginRelayPool?.ReleaseStream(streamId); - if (connectionState.TryTakeStream(streamId, out var resetStream)) - { - // RFC 8441: if the reset stream is an extended CONNECT tunnel, unblock the relay - // that is reading from the inbound channel so it can shut down promptly. - resetStream.InboundTunnelChannel?.Writer.TryComplete(); - await resetStream.Cancellation.CancelAsync(); - if (!resetStream.IsCompressedRelay) - resetStream.Cancellation.Dispose(); - connectionState.ClientSendFlow.RemoveStream(streamId); - connectionState.ServerSendFlow.RemoveStream(streamId); - - // A stream reset before it ever reached a response leaves SessionArgs.Response at - // its default (StatusCode 0, HttpVersion null). Setting Exception here - matching - // every other forwarding path's convention of recording even OperationCanceledException - // on the session (see RequestHandler/Http11ToHttp2BridgeHandler/Http2ToHttp3BridgeHandler) - - // lets AfterResponse consumers tell "client reset this incomplete stream" apart from - // an actual proxy failure, instead of seeing an unexplained zero-status entry. - if (resetStream.SessionArgs is { } resetArgs - && resetArgs.Exception == null - && !resetArgs.HttpClient.Response.Locked) - { - resetArgs.Exception = new OperationCanceledException( - isClient - ? "Stream was reset by the client before it received a response." - : "Stream was reset by the origin before it received a response."); - } - - ScheduleFinalize(resetStream, onAfterResponse, logger, connectionState); - - // Wire up args so the RST_STREAM error log below can include the request URL - // (args is only populated for DATA/HEADERS frames in the outer scope, so it is - // always null here without this assignment). - args = resetStream.SessionArgs; - - if (args != null) - { - var resetRr = isClient - ? (RequestResponseBase)args.HttpClient.Request - : args.HttpClient.Response; - - // unblock a pending GetBody()-style waiter rather than hanging forever now that no - // further DATA/END_STREAM will ever arrive for this stream. - var bodyTcs = resetRr.ReadHttp2BodyTaskCompletionSource; - if (bodyTcs != null && !bodyTcs.Task.IsCompleted) - { - resetRr.ReadHttp2BodyTaskCompletionSource = null; - resetRr.IsBodyRead = true; - resetRr.IsBodyReceived = true; - bodyTcs.TrySetResult(true); - } - } - - // Rapid Reset (CVE-2023-44487) abuse budget: this branch only runs for a stream - // that was still tracked (i.e. never reached a normal end-stream) at the moment - // the RST_STREAM arrived, and only the client->server relay task ever reads an - // RST_STREAM frame directly off the client's own wire, so `isClient` here means - // exactly "the client reset a stream it never let complete" - never a - // proxy-initiated reset, which this task never reads back from its own writes. - if (isClient && resourceLimits.MaxPeerInitiatedIncompleteStreamResets.HasValue && - !connectionState.ClientResetBudgetExceeded) - { - var resetBudgetMode = args?.Server.PolicyModes[PolicyFamily.Http2AbuseBudget] - ?? PolicyMode.Enforce; - var resetCount = Interlocked.Increment(ref connectionState.ClientIncompleteStreamResetCount); - if (resetBudgetMode != PolicyMode.Disabled && - resetCount > resourceLimits.MaxPeerInitiatedIncompleteStreamResets.Value) - { - ProxyMetrics.PolicyBreach(PolicyFamily.Http2AbuseBudget, resetBudgetMode); - - // Enforce-only reaction, matching the CONTINUATION-flood budget above: - // Observe records every breach but must not GOAWAY the connection. - if (resetBudgetMode == PolicyMode.Enforce) - { - connectionState.ClientResetBudgetExceeded = true; - connectionState.ClientResetBudgetLastStreamId = connectionState.LastClientStreamId; - ReportException(logger, new ProxyHttpException( - "HTTP/2 abuse budget exceeded: too many client-initiated resets of " + - "incomplete streams (possible Rapid Reset / CVE-2023-44487).", null, null)); - await lockedOwnLegWrite(() => SendGoAwayAsync(new Http2FrameHeader(), new byte[9], - connectionState.ClientResetBudgetLastStreamId, Http2ErrorCode.EnhanceYourCalm, - input)); - // Do not return: already-admitted streams (id <= the last-stream-id just - // announced) must still be allowed to drain per RFC 9113 §6.8. Only new - // stream admission is refused, at the isMainHeaders check below. - } - } - } - } - - // NO_ERROR (0) from the origin is a normal post-response cleanup; CANCEL is the usual - // client abort. REFUSED_STREAM is also expected under origin load-shedding / GOAWAY - // races (observed live from github.com/Fastly both direct and via this proxy). - // STREAM_CLOSED is the peer saying the stream is already done (half-close races). - // PROTOCOL_ERROR on a received RST is the peer's assessment — our own framing - // defects are already ReportException'd at the detection site before we send RST. - // INTERNAL_ERROR is commonly used by long-lived peer streams (e.g. LaunchDarkly / - // SonarCloud ld-stream) when they tear down; not a proxy defect. - // Forward the RST either way; do not flood Error logs for peer-initiated codes. - if (errorCode != (int)Http2ErrorCode.NoError && - errorCode != (int)Http2ErrorCode.Cancel && - errorCode != (int)Http2ErrorCode.RefusedStream && - errorCode != (int)Http2ErrorCode.StreamClosed && - errorCode != (int)Http2ErrorCode.ProtocolError && - errorCode != (int)Http2ErrorCode.InternalError) - { - var direction = isClient ? "client→proxy" : "origin→proxy"; - var requestUrl = args?.HttpClient.Request.Url ?? "(unknown)"; - ReportException(logger, new ProxyHttpException( - $"HTTP/2 stream error. Error code: {errorCode}; direction: {direction}; " + - $"stream: {streamId}; request: {requestUrl}", null, args)); - } - else if (logger.IsEnabled(LogLevel.Debug) && - errorCode != (int)Http2ErrorCode.NoError && - errorCode != (int)Http2ErrorCode.Cancel) - { - var direction = isClient ? "client→proxy" : "origin→proxy"; - var requestUrl = args?.HttpClient.Request.Url ?? "(unknown)"; - ProxyDiagnostics.ReportBenign(logger, - $"HTTP/2 peer RST_STREAM. Error code: {errorCode}; direction: {direction}; " + - $"stream: {streamId}; request: {requestUrl}", - new ProxyHttpException( - $"HTTP/2 peer stream reset code {errorCode}", null, args)); - } - } - - if (endStream && rr == null) - { - var compressedEndStream = existingStreamState?.IsCompressedRelay == true - || (connectionState.Streams.TryGetValue(streamId, out var endStreamState) - && endStreamState.IsCompressedRelay); - if (!compressedEndStream) - throw new InvalidOperationException( - "An HTTP/2 end-stream frame has no request or response."); - } - - if (endStream && rr != null && rr.ReadHttp2BodyTaskCompletionSource != null) - { - if (!rr.BodyAvailable) - { - var data = rr.Http2BodyData; - if (data == null) - throw new InvalidOperationException("HTTP/2 body completion was signaled without a buffer."); - - var body = data.ToArray(); - var leftAsWireEncoded = false; - - if (rr.ContentEncoding != null) - { - var (decompressStream, owned) = - CompressionUtil.CreateDecompressionChain(new MemoryStream(body), rr.ContentEncoding); - try - { - if (owned.Count > 0) - { - using var ms = new MemoryStream(); - await decompressStream.CopyToAsync(ms, cancellationToken); - body = ms.ToArray(); - } - else - { - // Unsupported encoding (dcb/dcz/zstd…): keep wire bytes. - leftAsWireEncoded = true; - } - } - finally - { - for (var i = owned.Count - 1; i >= 0; i--) - await owned[i].DisposeAsync(); - } - } - - if (!rr.BodyAvailable) - { - rr.Body = body; - rr.BodyIsWireEncoded = leftAsWireEncoded; - } - } - - rr.IsBodyRead = true; - rr.IsBodyReceived = true; - - var tcs = rr.ReadHttp2BodyTaskCompletionSource; - rr.ReadHttp2BodyTaskCompletionSource = null; - - if (!tcs.Task.IsCompleted) - { - tcs.SetResult(true); - } - - if (rr.Http2BodyData != null) await rr.Http2BodyData.DisposeAsync(); - rr.Http2BodyData = null; - - if (rr.Http2BeforeHandlerTask != null) - { - await rr.Http2BeforeHandlerTask; - } - - if (args == null) - throw new InvalidOperationException("HTTP/2 body completion has no session."); - - if (args.IsPromise) - { - Breakpoint(); - } - - // If the before-handler claimed exclusive bridge ownership (e.g. H2→H3 bridge), skip - // SendBody: the bridge already forwarded the complete request (headers + body) on its own - // transport (QUIC or TCP-fallback). Sending it again here over the H2 TCP origin would - // double-submit the request and cause a PROTOCOL_ERROR on the H2 origin connection. - // - // By the time Http2BeforeHandlerTask has completed the handler has already set - // IsExternalBridge = true on the stream state and fired the background bridge task. The - // background task cannot have removed the stream from the dictionary yet (it hasn't - // started executing on the thread pool), so TryGetValue is guaranteed to return the - // already-mutated state object. - connectionState.Streams.TryGetValue(streamId, out var bodyStreamState); - if (bodyStreamState?.IsExternalBridge != true) - { - // Drain queued HEADERS/DATA so this SendBody cannot overtake them on the wire. - if (isClient) - await connectionState.ServerWriteChain; - else - await connectionState.ClientWriteChain; - await lockedOutputWrite(() => - AsValueTask(SendBody(remoteSettings, rr, frameHeader, frameHeaderBuffer, buffer, outboundFlow, - output, cancellationToken))); - } - } - - if (endStream) - { - if (isClient) - connectionState.MultipartObservers.TryRemove(streamId, out _); - - if (connectionState.Streams.TryGetValue(streamId, out var closingStream)) - { - if (isClient) - { - closingStream.RequestClosed = true; - if (closingStream.IsExtendedConnect) - closingStream.InboundTunnelChannel?.Writer.TryComplete(); - } - else - closingStream.ResponseClosed = true; - - if (closingStream.IsClosed) - { - connectionState.OriginRelayPool?.ReleaseStream(streamId); - connectionState.RemoveStream(streamId); - ScheduleFinalize(closingStream, onAfterResponse, logger, connectionState); - } - } - } - - if (sendPacket) - { - var frameLength = length; - - if (type == Http2FrameType.Data) - { - if (isClient && connectionState.OriginRelayPool != null - && connectionState.OriginRelayPool.TryGetAssignment(streamId, out var dataAssignment)) - { - await dataAssignment.Leg.SendFlow - .ReserveAsync(dataAssignment.OriginStreamId, frameLength, cancellationToken) - .ConfigureAwait(false); - } - else - { - await outboundFlow.ReserveAsync(streamId, frameLength, cancellationToken); - } - } - - if (type == Http2FrameType.Data) - { - // Copy and queue so DATA cannot overtake a queued HEADERS write on this direction - // (and so the frame loop does not await peer socket I/O on the hot path). - Http2FrameWriter? dedicatedWriter = null; - if (isClient && connectionState.OriginRelayPool != null - && connectionState.OriginRelayPool.TryGetAssignment(streamId, out var assignment)) - { - frameHeader.StreamId = assignment.OriginStreamId; - dedicatedWriter = assignment.Leg.Writer; - } - else if (!isClient && originReceiveLeg != null) - { - dedicatedWriter = connectionState.ClientFrameWriter; - } - - frameHeader.CopyToBuffer(frameHeaderBuffer); - var wireLen = 9 + frameLength; - var rented = ArrayPool.Shared.Rent(wireLen); - frameHeaderBuffer.AsSpan(0, 9).CopyTo(rented); - if (frameLength > 0) - buffer.AsSpan(0, frameLength).CopyTo(rented.AsSpan(9)); - if (dedicatedWriter != null) - dedicatedWriter.EnqueueRented(rented, wireLen); - else - connectionState.EnqueueWriteRented(towardServer: isClient, outputWriteLock, output, rented, - wireLen); - } - else - { - // Control frames (SETTINGS/WINDOW_UPDATE/PING/HEADERS/…): stream-scoped frames - // (HEADERS etc.) go through the dedicated writer for coalesced writes. Connection- - // level SETTINGS/WINDOW_UPDATE/PING/GOAWAY stay awaited under the write lock so - // the post-SETTINGS connection WINDOW_UPDATE below cannot overtake SETTINGS. - if (isClient && streamId != 0 && connectionState.OriginRelayPool != null - && connectionState.OriginRelayPool.TryGetAssignment(streamId, out var ctrlAssignment)) - { - frameHeader.StreamId = ctrlAssignment.OriginStreamId; - frameHeader.CopyToBuffer(frameHeaderBuffer); - var wireLen = 9 + frameLength; - var rented = ArrayPool.Shared.Rent(wireLen); - frameHeaderBuffer.AsSpan(0, 9).CopyTo(rented); - if (frameLength > 0) - buffer.AsSpan(0, frameLength).CopyTo(rented.AsSpan(9)); - ctrlAssignment.Leg.Writer.EnqueueRented(rented, wireLen); - } - else - { - frameHeader.CopyToBuffer(frameHeaderBuffer); - var wireLen = 9 + frameLength; - var streamScoped = type is Http2FrameType.Headers or Http2FrameType.Continuation - or Http2FrameType.RstStream or Http2FrameType.Priority; - Http2FrameWriter? dedicatedWriter = null; - if (streamScoped) - dedicatedWriter = isClient - ? connectionState.ServerFrameWriter - : connectionState.ClientFrameWriter; - if (dedicatedWriter != null) - { - var rented = ArrayPool.Shared.Rent(wireLen); - frameHeaderBuffer.AsSpan(0, 9).CopyTo(rented); - if (frameLength > 0) - buffer.AsSpan(0, frameLength).CopyTo(rented.AsSpan(9)); - dedicatedWriter.EnqueueRented(rented, wireLen); - } - else - { - async ValueTask writeFrame() - { - await output.WriteAsync(frameHeaderBuffer.AsMemory(0, 9), CancellationToken.None); - if (frameLength > 0) - await output.WriteAsync(buffer.AsMemory(0, frameLength), CancellationToken.None); - } - - await lockedOutputWrite(writeFrame); - } - } - } - - // signal once the server's SETTINGS frame has actually reached the client, so a synthetic - // response on the other relay can safely send HEADERS afterwards. - if (!isClient && type == Http2FrameType.Settings && (flags & Http2FrameFlag.Ack) == 0) - { - connectionState.ServerSettingsRelayed.TrySetResult(true); - - // 1 MiB connection window toward the client — must follow SETTINGS on the - // wire (see SendHttp2 remarks). Same CompareExchange guard as the origin path. - if (Interlocked.CompareExchange(ref connectionState.InitialClientWindowUpdateSent, 1, 0) == 0) - { - await lockedOutputWrite(() => SendWindowUpdateAsync(frameHeader, frameHeaderBuffer, 0, - ClientConnectionWindowIncrement, output)); - } - } - - // H2↔H2 MITM: after the browser's first non-ACK SETTINGS reaches the origin (RFC 7540 - // §3.5: SETTINGS must immediately follow the preface), enlarge the origin's connection - // send window to match Chrome. Emitting WINDOW_UPDATE before SETTINGS made strict origins - // (e.g. MSN, Wikipedia) close with PROTOCOL_ERROR; emitting a proxy SETTINGS instead - // produced an unexpected SETTINGS ACK when relayed to Chrome. - if (isClient && type == Http2FrameType.Settings && (flags & Http2FrameFlag.Ack) == 0 && - Interlocked.CompareExchange(ref connectionState.InitialOriginWindowUpdateSent, 1, 0) == 0) - { - await lockedOutputWrite(() => SendWindowUpdateAsync(frameHeader, frameHeaderBuffer, 0, - InitialConnectionWindowIncrement, output)); - } - } - - if (cancellationToken.IsCancellationRequested) - { - return; - } - - } - } - finally - { - // Flush any batched receive credit before tearing down so the peer is not left - // with a permanently shrunk window on a half-closed connection. - try - { - await FlushAllPendingReceiveCreditAsync(); - } - catch - { - // best-effort — the peer may already be gone - } - - // Ensure the other relay direction (and any synthetic task below still waiting on a - // cross-direction signal such as ServerSettingsRelayed) is unblocked before this method - // awaits tracked synthetic tasks. SendHttp2 only cancels the shared token once one of the - // two CopyHttp2FrameAsync tasks has *already completed*; without cancelling here first, a - // synthetic task on this direction that is still waiting on a signal only the other, - // still-running relay task can deliver would never observe cancellation, and this method - // would never complete for SendHttp2 to observe in the first place - a deadlock. - await cancellationTokenSource.CancelAsync(); - - if (!pendingSynthetics.IsEmpty) - { - await pendingSynthetics.WhenAllAsync(); - } - } - } - - [Conditional("DEBUG")] - private static void Breakpoint() - { - // when this method is called something received which is not yet implemented - } - - /// Cheap check avoiding a ToLowerInvariant() allocation for the common already-lowercase case. - private static bool HasUpperCaseAscii(ByteString name) - { - var span = name.Span; - for (var i = 0; i < span.Length; i++) - { - if (span[i] is >= (byte)'A' and <= (byte)'Z') - return true; - } - - return false; - } - - /// - /// ASCII lowercase copy for HPACK wire names — no / - /// encoding round-trip (those allocated under origin writeLock on H1→H2). - /// - private static ByteString AsciiToLowerByteString(ByteString name) - { - var span = name.Span; - var buf = new byte[span.Length]; - for (var i = 0; i < span.Length; i++) - { - var c = span[i]; - buf[i] = c is >= (byte)'A' and <= (byte)'Z' ? (byte)(c + 32) : c; - } - - return new ByteString(buf); - } - - private static readonly ByteString ViaHeaderLower = "via".GetByteString(); - - /// - /// Hop-by-hop / connection-specific names RFC 7540 §8.1.2.2 forbids on HTTP/2 (plus Host, which - /// becomes :authority). Compared on so EncodeHeaderBlock does not - /// force header.Name GetString under writeLock. - /// - private static bool ShouldOmitHttp2Header(ByteString name) - { - var span = name.Span; - return span.Length switch - { - 2 => EqualsAsciiIgnoreCase(span, "te"u8), - 4 => EqualsAsciiIgnoreCase(span, "host"u8), - 7 => EqualsAsciiIgnoreCase(span, "upgrade"u8), - 10 => EqualsAsciiIgnoreCase(span, "connection"u8) - || EqualsAsciiIgnoreCase(span, "keep-alive"u8), - 16 => EqualsAsciiIgnoreCase(span, "proxy-connection"u8), - 17 => EqualsAsciiIgnoreCase(span, "transfer-encoding"u8), - _ => false - }; - } - - private static bool EqualsAsciiIgnoreCase(ReadOnlySpan a, ReadOnlySpan b) - { - if (a.Length != b.Length) return false; - for (var i = 0; i < a.Length; i++) - { - var x = a[i]; - var y = b[i]; - if (x is >= (byte)'A' and <= (byte)'Z') x = (byte)(x + 32); - if (y is >= (byte)'A' and <= (byte)'Z') y = (byte)(y + 32); - if (x != y) return false; - } - - return true; - } - - // Common :status values (StaticTable also indexes several of these). - private static readonly ByteString Status200 = "200".GetByteString(); - private static readonly ByteString Status204 = "204".GetByteString(); - private static readonly ByteString Status206 = "206".GetByteString(); - private static readonly ByteString Status301 = "301".GetByteString(); - private static readonly ByteString Status302 = "302".GetByteString(); - private static readonly ByteString Status304 = "304".GetByteString(); - private static readonly ByteString Status400 = "400".GetByteString(); - private static readonly ByteString Status404 = "404".GetByteString(); - private static readonly ByteString Status500 = "500".GetByteString(); - private static readonly ByteString Status502 = "502".GetByteString(); - - private static ByteString StatusCodeBytes(int statusCode) => statusCode switch - { - 200 => Status200, - 204 => Status204, - 206 => Status206, - 301 => Status301, - 302 => Status302, - 304 => Status304, - 400 => Status400, - 404 => Status404, - 500 => Status500, - 502 => Status502, - _ => statusCode.ToString().GetByteString() - }; - - // Hot-path caches: avoid allocating ByteString for common :method / :scheme under writeLock. - private static readonly ByteString MethodGet = "GET".GetByteString(); - private static readonly ByteString MethodHead = "HEAD".GetByteString(); - private static readonly ByteString MethodPost = "POST".GetByteString(); - private static readonly ByteString MethodPut = "PUT".GetByteString(); - private static readonly ByteString MethodDelete = "DELETE".GetByteString(); - private static readonly ByteString MethodOptions = "OPTIONS".GetByteString(); - private static readonly ByteString MethodConnect = "CONNECT".GetByteString(); - private static readonly ByteString SchemeHttps = "https".GetByteString(); - private static readonly ByteString SchemeHttp = "http".GetByteString(); - - private static ByteString MethodBytes(string method) => method switch - { - "GET" => MethodGet, - "HEAD" => MethodHead, - "POST" => MethodPost, - "PUT" => MethodPut, - "DELETE" => MethodDelete, - "OPTIONS" => MethodOptions, - "CONNECT" => MethodConnect, - _ => method.GetByteString() - }; - - /// - /// HPACK-encodes into the direction's scratch stream. Must run on the - /// frame-read loop (or otherwise be serialized) so the dynamic table stays ordered. - /// - private static ReadOnlyMemory EncodeHeaderBlock(Http2Settings settings, RequestResponseBase rr) // NOSONAR S3776 -- Same encode path as SendHeader; keep logic together. - { - // Reuse one Encoder (and its HPACK dynamic table) per direction for the lifetime of the connection, - // mirroring how the Decoder is persisted below - the dynamic table is connection-scoped, not - // per-message, so recreating it on every call (as before) meant every header was encoded as a - // literal and repeated headers across streams/messages were never indexed. `settings` is one of - // the two Http2Settings instances created once in SendHttp2 and shared by both relay directions, - // so storing the encoder on it here gives every SendHeader call for this direction (including the - // one used for synthetic responses) the same encoder/table instance. - var encoder = settings.Encoder; - if (encoder == null) - { - encoder = new Encoder(RfcDefaultHeaderTableSize); - settings.Encoder = encoder; - } - - // Encode scratch is connection-direction scoped and only used under the write lock / frame loop. - var ms = settings.GetEncodeStream(); - var writer = settings.GetEncodeWriter(); - - // RFC 7540 ?6.2: the HEADERS frame payload is [Pad Length?] [E + Stream Dependency + Weight, if - // PRIORITY] [Header Block Fragment] [Padding?] - the priority fields (when present) are a - // frame-level prefix that comes strictly *before* the header block fragment, which is the HPACK - // byte sequence built below (dynamic table size update, if any, followed by the encoded - // pseudo-headers/headers). Writing the priority bytes after the size-update instruction (as a - // previous version of this code did) shifted every subsequent byte by 5, so the peer tried to - // HPACK-decode a header block that actually started with garbage priority bytes - corrupting - // this connection's HPACK state and manifesting as an intermittent, hard-to-reproduce - // net::ERR_HTTP2_COMPRESSION_ERROR in the browser whenever a priority-bearing request happened - // to coincide with a table-size change. - if (rr.Priority.HasValue) - { - long p = rr.Priority.Value; - writer.Write((byte)((p >> 32) & 0xff)); - writer.Write((byte)((p >> 24) & 0xff)); - writer.Write((byte)((p >> 16) & 0xff)); - writer.Write((byte)((p >> 8) & 0xff)); - writer.Write((byte)(p & 0xff)); - } - - // RFC 7541 §6.3: Dynamic Table Size Update(s) must appear at the beginning of the first - // header block following any change to the peer's advertised ceiling. - // - // When multiple SETTINGS_HEADER_TABLE_SIZE updates arrive between two header blocks the spec - // requires signalling the smallest value that occurred first so the peer's decoder can evict - // entries it could no longer keep, before the encoder expands back to the final size. - // (Example: Google sends size=0 then size=65536 during connection setup; omitting the - // intermediate 0 leaves the encoder with live table entries the decoder already evicted, - // causing indexed references to resolve to stale/wrong slots — manifesting as a - // RST_STREAM(PROTOCOL_ERROR) from strict origins on the very next H2-native-relay stream.) - var minSize = settings.MinHeaderTableSizeSinceLastEncode; - var curSize = settings.HeaderTableSize; - if (encoder.MaxHeaderTableSize != minSize) - encoder.SetMaxHeaderTableSize(writer, minSize); - if (encoder.MaxHeaderTableSize != curSize) - encoder.SetMaxHeaderTableSize(writer, curSize); - // Reset so only updates arriving *after* this encode are rolled into the next header block. - settings.NotifyHeaderBlockEncoded(); - - if (rr is Request request) - { - // Do NOT touch RequestUri/Url here: those allocate a Uri + string under writeLock - // (H1→H2 / H3→H2 origin SendAsync critical section). Prefer already-materialized - // Authority / IsHttps / RequestUriString8 from the bridge or HPACK decode. - encoder.EncodeHeader(writer, StaticTable.KnownHeaderMethod, MethodBytes(request.Method)); - var authorityValue = request.Authority.Length > 0 - ? request.Authority - : (request.Host ?? string.Empty).GetByteString(); - encoder.EncodeHeader(writer, StaticTable.KnownHeaderAuhtority, authorityValue); - encoder.EncodeHeader(writer, StaticTable.KnownHeaderScheme, - request.IsHttps ? SchemeHttps : SchemeHttp); - encoder.EncodeHeader(writer, StaticTable.KnownHeaderPath, request.RequestUriString8, false, - HpackUtil.IndexType.None, false); - // RFC 8441 §5: :protocol must appear after the other pseudo-headers. - if (request.ExtendedConnectProtocol != null) - encoder.EncodeHeader(writer, StaticTable.KnownHeaderProtocol, - request.ExtendedConnectProtocol.GetByteString()); - } - else - { - var response = (Response)rr; - encoder.EncodeHeader(writer, StaticTable.KnownHeaderStatus, StatusCodeBytes(response.StatusCode)); - } - - foreach (var header in rr.Headers) - { - // RFC 7540 §8.1.2: header field names MUST be lowercase on the wire. Bridge handlers - // normalize this up front (see LowercaseHeaderNames in Http2ToHttp11BridgeHandler / - // Http2ToHttp3BridgeHandler), but that pass can be silently undone by anything that - // re-adds a header afterwards using its canonical mixed-case name - e.g. - // RequestResponseBase.ContentLength's setter picks "Content-Length" whenever - // HttpVersion is below 2.0, which is exactly the state an H1/H3-origin-bridged - // response is still in when CompressBodyAndUpdateContentLength() re-sets it right - // before this loop runs. Enforcing lowercase here, at the single point where every - // header actually gets HPACK-encoded onto an h2 wire, closes that gap regardless of - // which upstream code path is responsible - a mixed-case name here reaches the peer - // verbatim and manifests as a client RST_STREAM(PROTOCOL_ERROR). - var nameData = header.NameData; - if (!rr.HeaderNamesAreHttp2Normalized && HasUpperCaseAscii(nameData)) - nameData = AsciiToLowerByteString(nameData); - - // Strip hop-by-hop / Host here so PrepareRequestForOrigin need not RemoveHeader seven - // times under the H1→H2 path (still strips Host for Authority capture separately). - if (ShouldOmitHttp2Header(nameData)) - continue; - - // Via is added by the proxy itself on every request and varies across hops; it must - // not enter the HPACK dynamic table. If it did, stream N would encode it as a - // single-byte dynamic-table reference, and strict H2 origins (Google's play.google.com - // included) respond with RST_STREAM(PROTOCOL_ERROR) on any stream that carries a - // Via header via an indexed reference rather than an explicit literal field. - // IndexType.None means "literal without indexing" — the encoder skips Add() so - // the entry never lands in the dynamic table, and every subsequent stream gets a - // fresh literal representation instead of a back-reference. - if (nameData.Equals(ViaHeaderLower) || nameData.EqualsIgnoreCaseAscii(ViaHeaderLower)) - encoder.EncodeHeader(writer, nameData, header.ValueData, false, - HpackUtil.IndexType.None); - else - encoder.EncodeHeader(writer, nameData, header.ValueData); - } - - writer.Flush(); - return GetMemoryStreamMemory(ms); - } - - // HPACK static table: :scheme http = 6, :scheme https = 7 → Indexed Header Field bytes. - private const byte IndexedSchemeHttp = 0x86; - private const byte IndexedSchemeHttps = 0x87; - - internal enum StaticSchemeOverrideResult - { - NeedFallback, - Patched, - AlreadyMatching, - } - - private static byte StaticIndexedSchemeByte(ByteString scheme) - { - if (scheme.Equals(ProxyServer.UriSchemeHttp8) || scheme.Equals(SchemeHttp)) - return IndexedSchemeHttp; - if (scheme.Equals(ProxyServer.UriSchemeHttps8) || scheme.Equals(SchemeHttps)) - return IndexedSchemeHttps; - return 0; - } - - /// - /// Fast mixed-transport path: walk HEADER_TABLE_SIZE=0 HPACK and rewrite Indexed - /// :scheme (0x86↔0x87) without a Decoder. Returns - /// when the block already carries the origin-transport scheme as a static index. - /// - internal static StaticSchemeOverrideResult TryApplyStaticIndexedSchemeOverride(byte[] block, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - ByteString toScheme, out byte[] patched) - { - patched = null!; - var to = StaticIndexedSchemeByte(toScheme); - if (to == 0) - return StaticSchemeOverrideResult.NeedFallback; - var from = to == IndexedSchemeHttps ? IndexedSchemeHttp : IndexedSchemeHttps; - - var i = 0; - var fromAt = -1; - var toCount = 0; - while (i < block.Length) - { - var b = block[i]; - if ((b & 0x80) != 0) - { - if ((b & 0x7f) == 0) - return StaticSchemeOverrideResult.NeedFallback; - if (b == from) - { - if (fromAt >= 0) - return StaticSchemeOverrideResult.NeedFallback; - fromAt = i; - } - else if (b == to) - { - toCount++; - } - - i++; - continue; - } - - if ((b & 0xe0) == 0x20) - { - if ((b & 0x1f) == 0x1f) - return StaticSchemeOverrideResult.NeedFallback; - i++; - continue; - } - - if (!TrySkipHpackLiteral(block, ref i)) - return StaticSchemeOverrideResult.NeedFallback; - } - - if (fromAt >= 0 && toCount == 0) - { - patched = new byte[block.Length]; - Buffer.BlockCopy(block, 0, patched, 0, block.Length); - patched[fromAt] = to; - return StaticSchemeOverrideResult.Patched; - } - - if (fromAt < 0 && toCount == 1) - return StaticSchemeOverrideResult.AlreadyMatching; - - return StaticSchemeOverrideResult.NeedFallback; - } - - /// - /// Walk a HEADER_TABLE_SIZE=0 HPACK block and rewrite a single Indexed Header Field for - /// :scheme (static indices 6/7). Returns false when scheme is not indexed that way - /// (literal encoding, absent, or ambiguous) so the caller can fall back to full re-encode. - /// - internal static bool TryPatchStaticIndexedScheme(byte[] block, ByteString fromScheme, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - ByteString toScheme, out byte[] patched) - { - patched = null!; - var from = StaticIndexedSchemeByte(fromScheme); - var to = StaticIndexedSchemeByte(toScheme); - if (from == 0 || to == 0 || from == to) - return false; - - var i = 0; - var foundAt = -1; - while (i < block.Length) - { - var b = block[i]; - if ((b & 0x80) != 0) - { - // Indexed Header Field — 7-bit index fits in one byte for static table (1–61). - if ((b & 0x7f) == 0) - return false; // 7-bit integer continuation; not used for indices 6/7 - if (b == from) - { - if (foundAt >= 0) - return false; - foundAt = i; - } - - i++; - continue; - } - - if ((b & 0xe0) == 0x20) - { - // Dynamic Table Size Update — skip 5-bit integer (always single-byte when size=0). - if ((b & 0x1f) == 0x1f) - return false; - i++; - continue; - } - - // Literal Header Field (with/without indexing / never indexed): skip name + value. - if (!TrySkipHpackLiteral(block, ref i)) - return false; - } - - if (foundAt < 0) - return false; - - patched = new byte[block.Length]; - Buffer.BlockCopy(block, 0, patched, 0, block.Length); - patched[foundAt] = to; - return true; - } - - private static bool TrySkipHpackLiteral(byte[] block, ref int i) - { - if (i >= block.Length) - return false; - var b = block[i]; - int nameIndex; - if ((b & 0xc0) == 0x40) - { - // Literal with incremental indexing — 6-bit name index - nameIndex = b & 0x3f; - i++; - if (nameIndex == 0x3f && !TrySkipHpackIntegerContinuation(block, ref i)) - return false; - } - else if ((b & 0xf0) == 0x00 || (b & 0xf0) == 0x10) - { - // Literal without indexing / never indexed — 4-bit name index - nameIndex = b & 0x0f; - i++; - if (nameIndex == 0x0f && !TrySkipHpackIntegerContinuation(block, ref i)) - return false; - } - else - { - return false; - } - - if (nameIndex == 0 && !TrySkipHpackString(block, ref i)) - return false; - - return TrySkipHpackString(block, ref i); - } - - private static bool TrySkipHpackString(byte[] block, ref int i) - { - if (i >= block.Length) - return false; - var len = block[i] & 0x7f; - i++; - if (len == 0x7f) - { - // RFC 7541 §5.1: value = (2^N - 1) + continuation - if (!TrySkipHpackIntegerContinuation(block, ref i, out var extra)) - return false; - len = 127 + extra; - } - - if (i + len > block.Length) - return false; - i += len; - return true; - } - - private static bool TrySkipHpackIntegerContinuation(byte[] block, ref int i) - => TrySkipHpackIntegerContinuation(block, ref i, out _); - - private static bool TrySkipHpackIntegerContinuation(byte[] block, ref int i, out int value) - { - value = 0; - var m = 0; - while (i < block.Length) - { - var b = block[i++]; - value += (b & 0x7f) << m; - if ((b & 0x80) == 0) - return true; - m += 7; - if (m > 28) - return false; - } - - return false; - } - - /// - /// Re-encodes a compressed-relay request header block with replacing - /// the client's ':scheme' - used on mixed-transport passthrough connections (inbound h2c client - /// with a TLS origin, or TLS-terminated client with a cleartext h2 origin) where relaying the - /// block verbatim makes strict origins reset every stream with PROTOCOL_ERROR because the scheme - /// does not match the origin transport. Compressed relay forces HEADER_TABLE_SIZE=0 on both - /// legs, so re-encoded blocks stay context-free and remain safe for any origin leg. - /// - private static byte[] ReencodeCompressedRequestBlock(Http2Settings settings, MyHeaderListener pseudo, - HeaderCollection headers, ByteString scheme) - { - // Same serialization contract as QueueSendHeader: encoder + scratch are direction-scoped. - lock (settings.Sync) - { - var encoder = settings.Encoder; - if (encoder == null) - { - encoder = new Encoder(RfcDefaultHeaderTableSize); - settings.Encoder = encoder; - } - - var ms = settings.GetEncodeStream(); - var writer = settings.GetEncodeWriter(); - - // Same RFC 7541 §6.3 dual-DTSU logic as EncodeHeaderBlock (see the detailed comment there). - var minSize = settings.MinHeaderTableSizeSinceLastEncode; - var curSize = settings.HeaderTableSize; - if (encoder.MaxHeaderTableSize != minSize) - encoder.SetMaxHeaderTableSize(writer, minSize); - if (encoder.MaxHeaderTableSize != curSize) - encoder.SetMaxHeaderTableSize(writer, curSize); - settings.NotifyHeaderBlockEncoded(); - - encoder.EncodeHeader(writer, StaticTable.KnownHeaderMethod, pseudo.Method); - if (pseudo.Authority.Length > 0) - encoder.EncodeHeader(writer, StaticTable.KnownHeaderAuhtority, pseudo.Authority); - encoder.EncodeHeader(writer, StaticTable.KnownHeaderScheme, scheme); - if (pseudo.Path.Length > 0) - encoder.EncodeHeader(writer, StaticTable.KnownHeaderPath, pseudo.Path, false, - HpackUtil.IndexType.None, false); - // RFC 8441 §5: :protocol must appear after the other pseudo-headers. - if (pseudo.Protocol.Length > 0) - encoder.EncodeHeader(writer, StaticTable.KnownHeaderProtocol, pseudo.Protocol); - - foreach (var header in headers) - { - // RFC 7540 §8.1.2: names must be lowercase on the wire (same guard as EncodeHeaderBlock). - var nameData = header.NameData; - if (HasUpperCaseAscii(nameData)) - nameData = AsciiToLowerByteString(nameData); - encoder.EncodeHeader(writer, nameData, header.ValueData); - } - - writer.Flush(); - return GetMemoryStreamMemory(ms).ToArray(); - } - } - - private static byte[]? BuildStaticLiteralAppendSuffix( - MitmCompressedRelayHelper.AddedHeaderBuffer added, string? extraName, string? extraValue) - { - var literalCount = added.Count + (extraName != null ? 1 : 0); - if (literalCount == 0) - return null; - - var extraSize = 0; - for (var i = 0; i < added.Count; i++) - { - var h = added[i]; - extraSize += GetStaticLiteralAppendSize(h.Name.Length, h.Value.Length); - } - - if (extraName != null) - extraSize += GetStaticLiteralAppendSize(extraName.Length, extraValue!.Length); - - var result = new byte[extraSize]; - var offset = 0; - for (var i = 0; i < added.Count; i++) - { - var h = added[i]; - offset = WriteStaticLiteralWithoutIndexing(result, offset, h.Name, h.Value); - } - - if (extraName != null) - WriteStaticLiteralWithoutIndexing(result, offset, extraName, extraValue!); - - return result; - } - - private static bool TryPrepareMitmStaticHpackRelay( - byte[] capturedBlock, - MitmCompressedRelayHelper.HeaderRelayBaseline baseline, - HeaderCollection after, - bool injectVia, - string? viaValue, - out byte[] blockToRelay, - out byte[]? appendSuffix) - { - blockToRelay = capturedBlock; - appendSuffix = null; - - if (!MitmStaticRebuildHelper.TryPrepareStaticHpackRelay( - capturedBlock, baseline, after, out blockToRelay, out var added)) - return false; - - var injectViaLiteral = injectVia && !after.HeaderExists("via"); - appendSuffix = BuildStaticLiteralAppendSuffix( - added, - injectViaLiteral && !added.ContainsName("via") ? "via" : null, - injectViaLiteral && !added.ContainsName("via") ? viaValue : null); - return true; - } - - private static int GetStaticLiteralAppendSize(int nameLength, int valueLength) => - 1 + GetHpackStringLiteralEncodedSize(nameLength) + GetHpackStringLiteralEncodedSize(valueLength); - - private static int GetHpackStringLiteralEncodedSize(int byteLength) => - byteLength < 127 ? 1 + byteLength : WriteHpackPrefixedIntSize(7, (ulong)byteLength) + byteLength; - - private static int WriteHpackPrefixedIntSize(int prefixBits, ulong value) - { - var mask = (uint)((1 << prefixBits) - 1); - if (value < mask) - return 1; - - var size = 1; - value -= mask; - while (value >= 0x80) - { - size++; - value >>= 7; - } - - return size + 1; - } - - private static int WriteStaticLiteralWithoutIndexing(byte[] dest, int offset, string name, string value) - { - dest[offset++] = 0x00; // Literal without indexing, new name (name index 0) - offset += WriteHpackAsciiStringLiteral(dest.AsSpan(offset), name); - offset += WriteHpackAsciiStringLiteral(dest.AsSpan(offset), value); - return offset; - } - - private static int WriteHpackAsciiStringLiteral(Span dest, string value) - { - var written = WriteHpackPrefixedInt(dest, 0x00, 7, (ulong)value.Length); - for (var i = 0; i < value.Length; i++) - dest[written + i] = (byte)value[i]; - return written + value.Length; - } - - private static int WriteHpackPrefixedInt(Span dest, byte patternByte, int prefixBits, ulong value) - { - var mask = (uint)((1 << prefixBits) - 1); - if (value < mask) - { - dest[0] = (byte)(patternByte | (byte)value); - return 1; - } - - dest[0] = (byte)(patternByte | (byte)mask); - var written = 1; - value -= mask; - while (value >= 0x80) - { - dest[written++] = (byte)((value & 0x7F) | 0x80); - value >>= 7; - } - - dest[written++] = (byte)value; - return written; - } - - internal static async Task SendHeader(Http2Settings settings, Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, RequestResponseBase rr, bool endStream, Stream output, bool pushPromise) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - { - // Same HPACK lock as QueueSendHeader: Encoder + encode scratch are connection-direction scoped. - ReadOnlyMemory block; - lock (settings.Sync) - block = EncodeHeaderBlock(settings, rr).ToArray(); - await WriteHeaderBlockAsync(frameHeader, frameHeaderBuffer, frameHeader.StreamId, - pushPromise ? Http2FrameType.PushPromise : Http2FrameType.Headers, endStream, - rr.Priority.HasValue, block, settings.MaxFrameSize, output); - } - - /// - /// Encodes HEADERS on the frame-read loop, copies the framed bytes, and queues the socket write - /// so the loop can admit the next stream without awaiting peer I/O (encode on the read loop, queue the write, continue). - /// DATA frames for the same direction must also go through - /// so they cannot overtake this HEADERS on the wire. - /// - private static void QueueSendHeader(Http2ConnectionState connectionState, bool towardServer, // NOSONAR S107 -- Parameters kept explicit to avoid allocating options bags on hot bridge/pool paths. - SemaphoreSlim writeLock, Http2Settings settings, Http2FrameHeader frameHeader, - byte[] frameHeaderBuffer, RequestResponseBase rr, bool endStream, Stream output, bool pushPromise) - { - // BeforeRequest dispatches may finish on different thread-pool threads. Keep HPACK encoding and - // write-chain admission atomic per direction so the connection-scoped dynamic table remains ordered. - lock (settings.Sync) - { - var block = EncodeHeaderBlock(settings, rr); - var framed = RentFramedHeaderBlock(frameHeader, frameHeaderBuffer, frameHeader.StreamId, - pushPromise ? Http2FrameType.PushPromise : Http2FrameType.Headers, endStream, - rr.Priority.HasValue, block, settings.MaxFrameSize); - connectionState.EnqueueWriteRented(towardServer, writeLock, output, framed.Array!, framed.Count); - } - } - - private static void QueueSendHeaderTowardServer(Http2ConnectionState connectionState, // NOSONAR S107 -- Parameters kept explicit to avoid allocating options bags on hot bridge/pool paths. - SemaphoreSlim serverWriteLock, Http2Settings settings, Http2FrameHeader frameHeader, - byte[] frameHeaderBuffer, RequestResponseBase rr, bool endStream, Stream output, bool pushPromise) => - QueueSendHeader(connectionState, towardServer: true, serverWriteLock, settings, frameHeader, - frameHeaderBuffer, rr, endStream, output, pushPromise); - - /// - /// Frames as one client-bound DATA frame into a rented buffer and - /// queues it on the dedicated client frame writer. The caller must already hold the - /// flow-control reservation for . Used by the synthetic/bridge - /// response paths so responses from many concurrent streams coalesce into few socket writes - /// instead of each taking per frame. - /// - private static void QueueDataFrame(Http2ConnectionState connectionState, Stream clientStream, - int streamId, ReadOnlyMemory payload, bool endStream) - { - var total = 9 + payload.Length; - var rented = ArrayPool.Shared.Rent(total); - var dataFrameHeader = new Http2FrameHeader - { - StreamId = streamId, - Type = Http2FrameType.Data, - Length = payload.Length, - Flags = endStream ? Http2FrameFlag.EndStream : 0 - }; - dataFrameHeader.CopyToBuffer(rented); - payload.Span.CopyTo(rented.AsSpan(9)); - connectionState.EnqueueWriteRented(towardServer: false, connectionState.ClientWriteLock, - clientStream, rented, total); - } - - /// - /// Queues a client-bound RST_STREAM through the same FIFO as the stream's queued HEADERS/DATA so - /// it cannot overtake them on the wire (a direct locked write could). - /// - private static void QueueRstStreamFrame(Http2ConnectionState connectionState, Stream clientStream, - int streamId, Http2ErrorCode errorCode) - { - const int frameSize = 9 + 4; - var rented = ArrayPool.Shared.Rent(frameSize); - var rstFrameHeader = new Http2FrameHeader - { - StreamId = streamId, - Type = Http2FrameType.RstStream, - Length = 4, - Flags = 0 - }; - rstFrameHeader.CopyToBuffer(rented); - BinaryPrimitives.WriteUInt32BigEndian(rented.AsSpan(9), (uint)errorCode); - connectionState.EnqueueWriteRented(towardServer: false, connectionState.ClientWriteLock, - clientStream, rented, frameSize); - } - - /// - /// Encodes and sends the given trailing headers (RFC 7230 ?4.1.2 / RFC 7540 ?8.1.2.1) as a - /// HEADERS frame carrying no pseudo-headers, using the same persistent per-direction HPACK - /// encoder as so the destination's dynamic table stays in sync - /// regardless of whether trailers are actually present on a given message. - /// - internal static async Task SendTrailer(Http2Settings settings, Http2FrameHeader frameHeader, - byte[] frameHeaderBuffer, int streamId, HeaderCollection trailingHeaders, bool endStream, Stream output) - { - ReadOnlyMemory block; - lock (settings.Sync) - { - var encoder = settings.Encoder; - if (encoder == null) - { - encoder = new Encoder(RfcDefaultHeaderTableSize); - settings.Encoder = encoder; - } - - var ms = settings.GetEncodeStream(); - var writer = settings.GetEncodeWriter(); - - // Same RFC 7541 §6.3 dual-DTSU logic as SendHeader (see the detailed comment there). - var minSizeT = settings.MinHeaderTableSizeSinceLastEncode; - var curSizeT = settings.HeaderTableSize; - if (encoder.MaxHeaderTableSize != minSizeT) - encoder.SetMaxHeaderTableSize(writer, minSizeT); - if (encoder.MaxHeaderTableSize != curSizeT) - encoder.SetMaxHeaderTableSize(writer, curSizeT); - settings.NotifyHeaderBlockEncoded(); - - foreach (var header in trailingHeaders) - { - // See the matching comment in SendHeader: field names must be lowercase on the wire. - var nameData = header.NameData; - if (HasUpperCaseAscii(nameData)) - nameData = AsciiToLowerByteString(nameData); - encoder.EncodeHeader(writer, nameData, header.ValueData); - } - - writer.Flush(); - // Encode scratch is reused; copy before releasing the HPACK lock. - block = GetMemoryStreamMemory(ms).ToArray(); - } - - await WriteHeaderBlockAsync(frameHeader, frameHeaderBuffer, streamId, Http2FrameType.Headers, - endStream, false, block, settings.MaxFrameSize, output); - } - - private static ReadOnlyMemory GetMemoryStreamMemory(MemoryStream ms) - { - if (ms.TryGetBuffer(out var segment)) - return segment.AsMemory(0, (int)ms.Length); - return ms.ToArray(); - } - - /// - /// Builds HEADERS/CONTINUATION wire bytes into an ArrayPool buffer (caller owns the rent). - /// - private static ArraySegment RentFramedHeaderBlock(Http2FrameHeader frameHeader, // NOSONAR S107 -- Frame fields stay explicit. - byte[] frameHeaderBuffer, int streamId, Http2FrameType type, bool endStream, bool hasPriority, - ReadOnlyMemory data, int maxFrameSize) => - RentFramedHeaderBlock(frameHeader, frameHeaderBuffer, streamId, type, endStream, hasPriority, data, - ReadOnlyMemory.Empty, maxFrameSize); - - private static ArraySegment RentFramedHeaderBlock(Http2FrameHeader frameHeader, // NOSONAR S107 -- Frame fields stay explicit. - byte[] frameHeaderBuffer, int streamId, Http2FrameType type, bool endStream, bool hasPriority, - ReadOnlyMemory data, ReadOnlyMemory append, int maxFrameSize) - { - if (maxFrameSize <= 0) maxFrameSize = 16384; - - var dataLen = data.Length + append.Length; - var frameCount = dataLen == 0 ? 1 : (dataLen + maxFrameSize - 1) / maxFrameSize; - var total = frameCount * 9 + dataLen; - var rented = ArrayPool.Shared.Rent(total); - var dest = rented.AsSpan(0, total); - var destPos = 0; - var pos = 0; - var first = true; - - frameHeader.StreamId = streamId; - - do - { - var chunkLength = Math.Min(maxFrameSize, dataLen - pos); - var isLast = pos + chunkLength >= dataLen; - - frameHeader.Type = first ? type : Http2FrameType.Continuation; - frameHeader.Length = chunkLength; - - var flags = (Http2FrameFlag)0; - if (isLast) - flags |= Http2FrameFlag.EndHeaders; - if (first) - { - if (endStream) flags |= Http2FrameFlag.EndStream; - if (hasPriority) flags |= Http2FrameFlag.Priority; - } - - frameHeader.Flags = flags; - frameHeader.CopyToBuffer(frameHeaderBuffer); - frameHeaderBuffer.AsSpan(0, 9).CopyTo(dest.Slice(destPos)); - destPos += 9; - if (chunkLength > 0) - { - CopyHeaderBlockSegment(data, append, pos, dest.Slice(destPos, chunkLength)); - destPos += chunkLength; - } - - pos += chunkLength; - first = false; - } while (pos < dataLen); - - return new ArraySegment(rented, 0, total); - } - - private static void CopyHeaderBlockSegment(ReadOnlyMemory data, ReadOnlyMemory append, int start, - Span dest) - { - if (start >= data.Length) - { - append.Span.Slice(start - data.Length, dest.Length).CopyTo(dest); - return; - } - - if (start + dest.Length <= data.Length) - { - data.Span.Slice(start, dest.Length).CopyTo(dest); - return; - } - - var fromData = data.Length - start; - data.Span.Slice(start, fromData).CopyTo(dest); - append.Span.Slice(0, dest.Length - fromData).CopyTo(dest.Slice(fromData)); - } - - /// - /// Writes one already-HPACK-encoded header block as a HEADERS (or PUSH_PROMISE) frame followed - /// by as many CONTINUATION frames as needed so that no single frame's payload exceeds the - /// destination's advertised SETTINGS_MAX_FRAME_SIZE (RFC 7540 ?4.2/?6.10). END_HEADERS is set - /// only on the last frame of the sequence; END_STREAM/PRIORITY (when applicable) are set only - /// on the first, matching the semantics of the frame types they belong to. HEADERS/CONTINUATION - /// frames are not subject to flow control (RFC 7540 ?6.9), so no reservation is made here. - /// - private static async Task WriteHeaderBlockAsync(Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, // NOSONAR S107 -- Frame fields are kept explicit in this low-level encoder helper. - int streamId, Http2FrameType type, bool endStream, bool hasPriority, ReadOnlyMemory data, - int maxFrameSize, Stream output) - { - if (maxFrameSize <= 0) maxFrameSize = 16384; - - frameHeader.StreamId = streamId; - - var pos = 0; - var first = true; - do - { - var chunkLength = Math.Min(maxFrameSize, data.Length - pos); - var isLast = pos + chunkLength >= data.Length; - - frameHeader.Type = first ? type : Http2FrameType.Continuation; - frameHeader.Length = chunkLength; - - var flags = (Http2FrameFlag)0; - if (isLast) - { - flags |= Http2FrameFlag.EndHeaders; - } - - if (first) - { - if (endStream) flags |= Http2FrameFlag.EndStream; - if (hasPriority) flags |= Http2FrameFlag.Priority; - } - - frameHeader.Flags = flags; - - frameHeader.CopyToBuffer(frameHeaderBuffer); - await output.WriteAsync(frameHeaderBuffer.AsMemory()); - await output.WriteAsync(data.Slice(pos, chunkLength)); - - pos += chunkLength; - first = false; - } while (pos < data.Length); - } - - internal static async Task SendBody(Http2Settings settings, RequestResponseBase rr, Http2FrameHeader frameHeader, // NOSONAR S107 -- Frame-writing state is kept explicit for this low-level helper. - byte[] frameHeaderBuffer, byte[] buffer, Http2FlowController flow, Stream output, - CancellationToken cancellationToken) - { - var body = rr.CompressBodyAndUpdateContentLength(); - await SendHeader(settings, frameHeader, frameHeaderBuffer, rr, !(rr.HasBody && rr.IsBodyRead), output, false); - - if (rr.HasBody && rr.IsBodyRead) - { - if (body == null) - throw new InvalidOperationException("An HTTP/2 body was marked as read but is unavailable."); - - int streamId = frameHeader.StreamId; - int pos = 0; - while (pos < body.Length) - { - int bodyFrameLength = Math.Min(buffer.Length, body.Length - pos); - Buffer.BlockCopy(body, pos, buffer, 0, bodyFrameLength); - pos += bodyFrameLength; - - await flow.ReserveAsync(streamId, bodyFrameLength, cancellationToken); - - frameHeader.Length = bodyFrameLength; - frameHeader.Type = Http2FrameType.Data; - frameHeader.Flags = pos < body.Length ? (Http2FrameFlag)0 : Http2FrameFlag.EndStream; - - frameHeader.CopyToBuffer(frameHeaderBuffer); - await output.WriteAsync(frameHeaderBuffer.AsMemory(), cancellationToken); - await output.WriteAsync(buffer.AsMemory(0, bodyFrameLength), cancellationToken); - } - } - } - - /// - /// Sends the given bytes as one or more HTTP/2 DATA frames on the specified stream, splitting on - /// the peer's max frame size. An END_STREAM flag is set on the final frame when endStream is true. - /// Each frame's payload is reserved against before being written, so - /// this never exceeds the destination's flow-control window (RFC 7540 ?6.9). - /// - /// - /// Optional socket write lock. When provided, runs - /// before the lock is taken so inbound WINDOW_UPDATE on the peer read loop can still be - /// processed while this writer is waiting for credit. Holding the write lock across - /// ReserveAsync deadlocks HTTP/2 clients (notably .NET HttpClient) once the 64 KiB - /// default window is exhausted — the peer cannot deliver WINDOW_UPDATE if the read loop is - /// blocked trying to take the same lock for control-frame replies. Matches the order used by - /// the main DATA relay. - /// - internal static async ValueTask SendData(Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, int streamId, // NOSONAR S107 -- Frame-writing state is kept explicit for this low-level helper. - ReadOnlyMemory data, bool endStream, int maxFrameSize, Http2FlowController flow, Stream output, - CancellationToken cancellationToken, SemaphoreSlim? writeLock = null) - { - if (maxFrameSize <= 0) maxFrameSize = 16384; - - frameHeader.StreamId = streamId; - frameHeader.Type = Http2FrameType.Data; - - if (data.Length == 0) - { - if (writeLock != null) await writeLock.WaitAsync(cancellationToken); - try - { - frameHeader.Length = 0; - frameHeader.Flags = endStream ? Http2FrameFlag.EndStream : (Http2FrameFlag)0; - frameHeader.CopyToBuffer(frameHeaderBuffer); - await output.WriteAsync(frameHeaderBuffer.AsMemory(), cancellationToken); - } - finally - { - writeLock?.Release(); - } - - return; - } - - var pos = 0; - while (pos < data.Length) - { - var frameLength = Math.Min(maxFrameSize, data.Length - pos); - var isLastFrame = pos + frameLength >= data.Length; - - // Always reserve outside writeLock (see parameter remarks). - await flow.ReserveAsync(streamId, frameLength, cancellationToken); - - if (writeLock != null) await writeLock.WaitAsync(cancellationToken); - try - { - frameHeader.Length = frameLength; - frameHeader.Flags = isLastFrame && endStream ? Http2FrameFlag.EndStream : (Http2FrameFlag)0; - frameHeader.CopyToBuffer(frameHeaderBuffer); - await output.WriteAsync(frameHeaderBuffer.AsMemory(), cancellationToken); - await output.WriteAsync(data.Slice(pos, frameLength), cancellationToken); - } - finally - { - writeLock?.Release(); - } - - pos += frameLength; - } - } - - /// Writes an RST_STREAM frame (RFC 7540 ?6.4) resetting the given stream with the given error code. - internal static ValueTask SendRstStreamAsync(Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, - int streamId, Http2ErrorCode errorCode, Stream output) - { - if (errorCode != Http2ErrorCode.NoError) ProxyMetrics.ParserError("http2"); - - frameHeader.StreamId = streamId; - frameHeader.Type = Http2FrameType.RstStream; - frameHeader.Flags = 0; - frameHeader.Length = 4; - frameHeader.CopyToBuffer(frameHeaderBuffer); - - var payload = new byte[4]; - BinaryPrimitives.WriteInt32BigEndian(payload, (int)errorCode); - return WriteTwoAsync(output, frameHeaderBuffer.AsMemory(0, 9), payload.AsMemory(0, 4)); - } - - /// Writes a GOAWAY frame (RFC 7540 ?6.8) announcing connection-level shutdown with the given error code. - internal static async ValueTask SendGoAwayAsync(Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, - int lastStreamId, Http2ErrorCode errorCode, Stream output) - { - if (errorCode != Http2ErrorCode.NoError) ProxyMetrics.ParserError("http2"); - - frameHeader.StreamId = 0; - frameHeader.Type = Http2FrameType.GoAway; - frameHeader.Flags = 0; - frameHeader.Length = 8; - frameHeader.CopyToBuffer(frameHeaderBuffer); - - var payload = new byte[8]; - BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(0, 4), lastStreamId & 0x7fffffff); - BinaryPrimitives.WriteInt32BigEndian(payload.AsSpan(4, 4), (int)errorCode); - await WriteTwoAsync(output, frameHeaderBuffer.AsMemory(0, 9), payload.AsMemory(0, 8)); - - // GOAWAY is often immediately followed by connection teardown (the sending relay returns - // and cancels its peer). Flushing here ensures the frame reaches the wire before the socket - // closes; otherwise clients can observe a TCP RST without ever seeing the error code. - await output.FlushAsync(); - } - - /// Writes a WINDOW_UPDATE frame (RFC 7540 ?6.9) granting the given amount of flow-control credit. - internal static ValueTask SendWindowUpdateAsync(Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, - int streamId, int increment, Stream output) - { - if (increment <= 0) return default; - - frameHeader.StreamId = streamId; - frameHeader.Type = Http2FrameType.WindowUpdate; - frameHeader.Flags = 0; - frameHeader.Length = 4; - frameHeader.CopyToBuffer(frameHeaderBuffer); - - var payload = new byte[4]; - BinaryPrimitives.WriteInt32BigEndian(payload, increment & 0x7fffffff); - return WriteTwoAsync(output, frameHeaderBuffer.AsMemory(0, 9), payload.AsMemory(0, 4)); - } - - /// - /// HPACK-encodes into a rented framed HEADERS/CONTINUATION block. - /// Takes lock(settings.Sync) around encode (same as / - /// ) so the connection Encoder and encode scratch stay - /// consistent even if a caller only holds the origin socket write lock. - /// - internal static ArraySegment RentFramedHeaders(Http2Settings settings, Http2FrameHeader frameHeader, - byte[] frameHeaderBuffer, RequestResponseBase rr, bool endStream, bool pushPromise = false) - { - lock (settings.Sync) - { - var block = EncodeHeaderBlock(settings, rr); - return RentFramedHeaderBlock(frameHeader, frameHeaderBuffer, frameHeader.StreamId, - pushPromise ? Http2FrameType.PushPromise : Http2FrameType.Headers, endStream, - rr.Priority.HasValue, block, settings.MaxFrameSize); - } - } - - /// - /// HPACK-encodes and enqueues the framed HEADERS/CONTINUATION bytes. - /// Encode is synchronized on ; enqueue may run after that lock. - /// - internal static void EnqueueHeader(Http2Settings settings, Http2FrameHeader frameHeader, - byte[] frameHeaderBuffer, RequestResponseBase rr, bool endStream, Http2FrameWriter writer, - bool pushPromise = false) - { - var framed = RentFramedHeaders(settings, frameHeader, frameHeaderBuffer, rr, endStream, pushPromise); - writer.EnqueueRented(framed.Array!, framed.Count); - } - - /// - /// HPACK-encodes trailing headers into a rented framed HEADERS block. - /// Takes lock(settings.Sync) for the same Encoder/scratch contract as - /// . - /// - internal static ArraySegment RentFramedTrailers(Http2Settings settings, Http2FrameHeader frameHeader, - byte[] frameHeaderBuffer, int streamId, HeaderCollection trailingHeaders, bool endStream) - { - lock (settings.Sync) - { - var encoder = settings.Encoder; - if (encoder == null) - { - encoder = new Encoder(RfcDefaultHeaderTableSize); - settings.Encoder = encoder; - } - - var ms = settings.GetEncodeStream(); - var writerBuf = settings.GetEncodeWriter(); - - var minSizeT = settings.MinHeaderTableSizeSinceLastEncode; - var curSizeT = settings.HeaderTableSize; - if (encoder.MaxHeaderTableSize != minSizeT) - encoder.SetMaxHeaderTableSize(writerBuf, minSizeT); - if (encoder.MaxHeaderTableSize != curSizeT) - encoder.SetMaxHeaderTableSize(writerBuf, curSizeT); - settings.NotifyHeaderBlockEncoded(); - - foreach (var header in trailingHeaders) - { - var nameData = header.NameData; - if (HasUpperCaseAscii(nameData)) - nameData = AsciiToLowerByteString(nameData); - encoder.EncodeHeader(writerBuf, nameData, header.ValueData); - } - - writerBuf.Flush(); - - return RentFramedHeaderBlock(frameHeader, frameHeaderBuffer, streamId, - Http2FrameType.Headers, endStream, false, GetMemoryStreamMemory(ms), settings.MaxFrameSize); - } - } - - internal static void EnqueueTrailer(Http2Settings settings, Http2FrameHeader frameHeader, - byte[] frameHeaderBuffer, int streamId, HeaderCollection trailingHeaders, bool endStream, - Http2FrameWriter writer) - { - var framed = RentFramedTrailers(settings, frameHeader, frameHeaderBuffer, streamId, - trailingHeaders, endStream); - writer.EnqueueRented(framed.Array!, framed.Count); - } - - /// - /// Frames as one or more DATA frames and enqueues them. Caller must - /// already have reserved flow-control credit for the payload. Does not take a lock — the - /// dedicated writer serializes bytes. - /// - internal static void EnqueueDataFrames(Http2FrameWriter writer, int streamId, ReadOnlyMemory data, - bool endStream, int maxFrameSize) - { - if (maxFrameSize <= 0) maxFrameSize = 16384; - - if (data.Length == 0) - { - EnqueueControlFrame(writer, Http2FrameType.Data, - endStream ? Http2FrameFlag.EndStream : 0, streamId, ReadOnlySpan.Empty); - return; - } - - var pos = 0; - while (pos < data.Length) - { - var frameLength = Math.Min(maxFrameSize, data.Length - pos); - var isLast = pos + frameLength >= data.Length; - var flags = isLast && endStream ? Http2FrameFlag.EndStream : (Http2FrameFlag)0; - EnqueueControlFrame(writer, Http2FrameType.Data, flags, streamId, - data.Span.Slice(pos, frameLength)); - pos += frameLength; - } - } - - internal static void EnqueueRstStream(Http2FrameWriter writer, int streamId, Http2ErrorCode errorCode) - { - if (errorCode != Http2ErrorCode.NoError) ProxyMetrics.ParserError("http2"); - - Span payload = stackalloc byte[4]; - BinaryPrimitives.WriteInt32BigEndian(payload, (int)errorCode); - EnqueueControlFrame(writer, Http2FrameType.RstStream, 0, streamId, payload); - } - - internal static void EnqueueWindowUpdate(Http2FrameWriter writer, int streamId, int increment) - { - if (increment <= 0) return; - - Span payload = stackalloc byte[4]; - BinaryPrimitives.WriteInt32BigEndian(payload, increment & 0x7fffffff); - EnqueueControlFrame(writer, Http2FrameType.WindowUpdate, 0, streamId, payload); - } - - internal static void EnqueueSettingsAck(Http2FrameWriter writer) - { - EnqueueControlFrame(writer, Http2FrameType.Settings, Http2FrameFlag.Ack, 0, ReadOnlySpan.Empty); - } - - internal static void EnqueuePingAck(Http2FrameWriter writer, ReadOnlySpan payload) - { - EnqueueControlFrame(writer, Http2FrameType.Ping, Http2FrameFlag.Ack, 0, payload); - } - - /// - /// Copies a fully-formed frame into a rented buffer and transfers ownership to - /// . Safe to call without the origin write lock — DATA and - /// control frames do not mutate the HPACK table. - /// - internal static void EnqueueControlFrame(Http2FrameWriter writer, Http2FrameType type, - Http2FrameFlag flags, int streamId, ReadOnlySpan payload) - { - var total = 9 + payload.Length; - var rented = ArrayPool.Shared.Rent(total); - var header = new Http2FrameHeader - { - Type = type, - Flags = flags, - StreamId = streamId, - Length = payload.Length - }; - header.CopyToBuffer(rented); - if (payload.Length > 0) - payload.CopyTo(rented.AsSpan(9)); - writer.EnqueueRented(rented, total); - } - - private static ValueTask AsValueTask(Task task) => new(task); - - /// - /// Writes two buffers back-to-back without an async state machine when both complete synchronously. - /// - private static ValueTask WriteTwoAsync(Stream output, ReadOnlyMemory first, ReadOnlyMemory second, - CancellationToken cancellationToken = default) - { - var firstVt = output.WriteAsync(first, cancellationToken); - if (!firstVt.IsCompletedSuccessfully) - return WriteTwoSlowAsync(output, firstVt, second, cancellationToken); - - return output.WriteAsync(second, cancellationToken); - } - - private static async ValueTask WriteTwoSlowAsync(Stream output, ValueTask firstVt, ReadOnlyMemory second, - CancellationToken cancellationToken) - { - await firstVt; - await output.WriteAsync(second, cancellationToken); - } - - /// - /// Relays a 1xx interim response (e.g. 103 Early Hints) from an external bridge (H2→H3) to the - /// client as a HEADERS frame without END_STREAM. Mirrors the native H2 interim path in - /// ProcessCompleteHeaderBlockAsync. Flushing after the write is required so Navigation - /// Timing responseStart can move before the final response arrives. - /// - internal static async Task EmitInterimResponseAsync(SessionEventArgs args, int streamId, - Http2ConnectionState connectionState, Stream clientStream, Response interim, - CancellationToken cancellationToken) - { - await connectionState.ServerSettingsRelayed.Task.WaitAsync(cancellationToken); - - interim.Headers.RemoveHeader(KnownHeaders.Connection); - interim.Headers.RemoveHeader("Keep-Alive"); - interim.Headers.RemoveHeader(KnownHeaders.ProxyConnection); - interim.Headers.RemoveHeader(KnownHeaders.TransferEncoding); - interim.Headers.RemoveHeader(KnownHeaders.Upgrade); - - var frameHeader = new Http2FrameHeader { StreamId = streamId }; - var frameHeaderBuffer = new byte[9]; - - // QueueSendHeader locks ClientSettings for HPACK and admits onto the client frame FIFO — - // same ordering as final responses. SendHeader under ClientWriteLock alone raced concurrent - // QueueSendHeader encodes on the shared dynamic table. - QueueSendHeader(connectionState, towardServer: false, connectionState.ClientWriteLock, - connectionState.ClientSettings, frameHeader, frameHeaderBuffer, interim, - endStream: false, clientStream, pushPromise: false); - - // Flush so Navigation Timing responseStart can move before the final response arrives. - await connectionState.ClientWriteLock.WaitAsync(cancellationToken); - try - { - await clientStream.FlushAsync(cancellationToken); - } - finally - { - connectionState.ClientWriteLock.Release(); - } - } - - /// - /// Emits a proxy-generated (synthetic) response to the client on the given stream without relaying - /// the corresponding server response - either because the request never reached the server (a - /// BeforeRequest-time Ok/GenericResponse/Redirect/Respond/ - /// RespondStreaming call) or because a real response was received and then replaced (a - /// BeforeResponse-time Respond call). Three body shapes are supported, mirroring the - /// buffered/streamed distinction SessionEventArgs already exposes for HTTP/1.x: - /// - /// StreamBodyWriter set (RespondStreaming) - the body is produced on the fly - /// and written as DATA frames without ever being buffered. - /// otherwise, a buffered body (Ok/GenericResponse/Redirect/buffered - /// Respond) - the already-in-memory bytes are compressed (if requested) and sent as DATA - /// frames. - /// otherwise, no body at all - END_STREAM is set directly on the HEADERS frame. - /// - /// HTTP/2 frames the body with DATA/END_STREAM (Transfer-Encoding is never used over h2), so the - /// chunked header is always stripped regardless of which shape applies. - /// - internal static async Task EmitSyntheticResponseAsync(SessionEventArgs args, int streamId, - Http2ConnectionState connectionState, Stream clientStream, CancellationToken cancellationToken, - Func? onAfterResponse = null, ILogger? logger = null) - { - var response = args.HttpClient.Response; - - var frameHeader = new Http2FrameHeader { StreamId = streamId }; - var frameHeaderBuffer = new byte[9]; - - // The client must receive the connection SETTINGS frame (relayed from the server) before any - // HEADERS frame, otherwise it treats the connection as a protocol error. Wait for that relay, - // but honor cancellation so we never hang if the server never sends SETTINGS / closes early. - // Steady-state: SETTINGS already relayed — skip WaitAsync Task machinery per synthetic emit. - var settingsTask = connectionState.ServerSettingsRelayed.Task; - if (!settingsTask.IsCompletedSuccessfully) - await settingsTask.WaitAsync(cancellationToken); - - var streamBodyWriter = response.StreamBodyWriter; - if (streamBodyWriter != null) - { - await EmitStreamedSyntheticResponseAsync(response, streamBodyWriter, connectionState, - frameHeader, frameHeaderBuffer, clientStream, cancellationToken); - } - else - { - await EmitBufferedSyntheticResponseAsync(response, streamId, connectionState, frameHeader, - frameHeaderBuffer, clientStream, cancellationToken); - } - - response.IsBodySent = true; - MarkSyntheticResponseClosed(streamId, connectionState, onAfterResponse, logger); - } - - private static async Task EmitStreamedSyntheticResponseAsync(Response response, - Func streamBodyWriter, - Http2ConnectionState connectionState, Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, - Stream clientStream, CancellationToken cancellationToken) - { - var streamId = frameHeader.StreamId; - var clientSendFlow = connectionState.ClientSendFlow; - - // HTTP/2 does not use chunked transfer-encoding; body framing is done via DATA frames + END_STREAM. - response.Headers.RemoveHeader(KnownHeaders.TransferEncoding); - - // Keep origin Content-Length when known. Short delivery used to END_STREAM with a - // truncated body and poison Chrome (YouTube blank tab); we now RST on length mismatch - // instead, so advertising CL is safe and matches Kestrel/YARP (and avoids an extra empty - // END_STREAM DATA when the last payload frame can carry the flag). - var advertisedLength = response.ContentLength; - - // HEADERS and every DATA frame for this stream flow through the dedicated client frame - // writer's FIFO (QueueSendHeader + Http2BodyStreamWriter), which guarantees both that this - // stream's DATA can never overtake its HEADERS and that no other stream's frames interleave - // bytes - the same guarantees the previous hold-ClientWriteLock-across-HEADERS+first-DATA - // approach provided, but without serializing every multiplexed stream's response emission on - // one semaphore with several small syscalls each (measured as the dominant cost on the - // h2→h1 bridge arms: 2:1 sys:user CPU with all in-flight streams parked on this path). - QueueSendHeader(connectionState, towardServer: false, connectionState.ClientWriteLock, - connectionState.ClientSettings, frameHeader, frameHeaderBuffer, response, false, - clientStream, false); - - var maxFrameSize = connectionState.ClientSettings.MaxFrameSize; - if (maxFrameSize <= 0) maxFrameSize = 16384; - - var bodyWriter = new Http2BodyStreamWriter(streamId, connectionState, clientStream, clientSendFlow, - cancellationToken, advertisedLength, maxFrameSize); - - await streamBodyWriter(bodyWriter, cancellationToken); - - // Origin advertised a length but delivered a different amount. Prefer RST over a - // successful-looking END_STREAM so the browser retries instead of caching/executing - // a truncated body. - if (advertisedLength >= 0 && bodyWriter.BytesWritten != advertisedLength) - { - QueueRstStreamFrame(connectionState, clientStream, streamId, Http2ErrorCode.InternalError); - } - else - { - await bodyWriter.CompleteAsync(); - } - } - - private static async Task EmitBufferedSyntheticResponseAsync(Response response, int streamId, - Http2ConnectionState connectionState, Http2FrameHeader frameHeader, byte[] frameHeaderBuffer, - Stream clientStream, CancellationToken cancellationToken) - { - var clientWriteLock = connectionState.ClientWriteLock; - var clientSendFlow = connectionState.ClientSendFlow; - - // buffered case (Ok/GenericResponse/Redirect/buffered Respond / H2→H3 bridge) - the whole - // body, if any, is already in memory. Compress WHILE Transfer-Encoding: chunked may still - // be present: Response.HasBody treats CL=-1 + chunked as "has body", and stripping TE - // first made HasBody false so CompressBodyAndUpdateContentLength zeroed Content-Length - // and dropped the buffered bytes (empty CDN JS/CSS through the H2→H3 bridge). - // Fast path: bridge already buffered a fixed-CL body that is ready for the wire - // (no content-encoding, or BodyIsWireEncoded from eager-buffer — do not re-compress). - byte[]? body; - if (response.IsBodyRead && response.BodyAvailable - && (response.ContentEncoding == null || response.BodyIsWireEncoded) - && !response.IsChunked && response.ContentLength >= 0) - { - body = response.Body; - if (body.Length != response.ContentLength) - body = response.CompressBodyAndUpdateContentLength(); - } - else - { - body = response.CompressBodyAndUpdateContentLength(); - } - - // HTTP/2 does not use chunked transfer-encoding; body framing is done via DATA frames. - response.Headers.RemoveHeader(KnownHeaders.TransferEncoding); - if (body is { Length: > 0 } && response.ContentLength < 0) - response.ContentLength = body.Length; - - var hasBody = body is { Length: > 0 }; - var maxFrameSize = connectionState.ClientSettings.MaxFrameSize; - if (maxFrameSize <= 0) maxFrameSize = 16384; - - // Queue HEADERS (+ DATA below) on the dedicated client frame writer instead of direct - // lock+write+flush: the FIFO preserves per-stream frame order, and the drain task coalesces - // frames from many concurrent bridge streams into few socket writes (see - // EmitStreamedSyntheticResponseAsync for the measurements behind this). No body at all: - // END_STREAM belongs on the HEADERS frame itself, there is no DATA frame to carry it. - QueueSendHeader(connectionState, towardServer: false, clientWriteLock, - connectionState.ClientSettings, frameHeader, frameHeaderBuffer, response, !hasBody, - clientStream, false); - - if (!hasBody) - return; - - // Reserve flow-control credit per frame before queueing so queued-but-unsent DATA can never - // exceed the client's advertised windows. - var bodyPos = 0; - while (bodyPos < body!.Length) - { - var frameLength = Math.Min(maxFrameSize, body.Length - bodyPos); - await clientSendFlow.ReserveAsync(streamId, frameLength, cancellationToken); - QueueDataFrame(connectionState, clientStream, streamId, - body.AsMemory(bodyPos, frameLength), endStream: bodyPos + frameLength >= body.Length); - bodyPos += frameLength; - } - } - - private static void MarkSyntheticResponseClosed(int streamId, Http2ConnectionState connectionState, - Func? onAfterResponse, ILogger? logger) - { - // Synthetic writes never produce an inbound END_STREAM for the response half, so mark - // ResponseClosed here. Finalize only when the request half is already done — do not force - // RequestClosed while the client may still be uploading (would race Dispose with the frame loop). - if (!connectionState.Streams.TryGetValue(streamId, out var streamState)) - return; - - streamState.ResponseClosed = true; - if (!streamState.IsClosed || onAfterResponse == null || logger == null) - return; - - connectionState.RemoveStream(streamId); - ScheduleFinalize(streamState, onAfterResponse, logger, connectionState); - } - - private static async Task ForceRead(Stream input, byte[] buffer, int offset, int bytesToRead, - CancellationToken cancellationToken) - { - int totalRead = 0; - while (bytesToRead > 0) - { - int read = await input.ReadAsync(buffer.AsMemory(offset, bytesToRead), cancellationToken); - if (read == 0) - { - break; - } - - totalRead += read; - bytesToRead -= read; - offset += read; - } - - return totalRead; - } - - /// - /// Best-effort drain of a rejected frame's still-incoming payload before this connection is torn down - /// in response to it (e.g. a declared length over - see the - /// FRAME_SIZE_ERROR checks above). The peer typically has already written (or is still writing) that - /// payload; if this leg's socket is closed while those bytes are still sitting unread in the OS - /// receive buffer, some platforms/stacks perform an abortive RST close instead of a graceful one, - /// which can also swallow the GOAWAY/RST_STREAM frame just flushed to the peer - turning an - /// intentionally clean protocol-error response into what looks like an unrelated connection failure. - /// Bounded by and a short timeout so a peer that declares a huge length and - /// then stalls cannot use this to hang the relay. - /// - private static async Task DiscardRejectedFramePayloadAsync(Stream input, int length, // NOSONAR S1144 -- reflection test seam - CancellationToken cancellationToken) - { - try - { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromSeconds(2)); - - var remaining = Math.Min(length, 1024 * 1024); - var buffer = new byte[Math.Min(remaining, MaxAcceptableFrameSize)]; - while (remaining > 0) - { - var read = await ForceRead(input, buffer, 0, Math.Min(remaining, buffer.Length), cts.Token); - if (read <= 0) break; - remaining -= read; - } - } - catch - { - // best-effort only - if the peer is already gone or this times out, there is nothing further to - // do; the caller proceeds to tear down the connection either way. - } - } - - /// - /// A write-only stream handed to consumers of RespondStreaming over HTTP/2. Each write is emitted as - /// one or more DATA frames on the given stream (split at the guaranteed-safe 16384 byte frame size). - /// The terminating empty END_STREAM DATA frame is sent by . - /// Frames are flow-reserved by the producing task and then queued on the connection's dedicated - /// client frame writer (same FIFO as the HEADERS queued by ), so - /// per-stream frame order is preserved, no other stream's bytes can interleave, and the drain task - /// coalesces frames across streams into single socket writes instead of serializing every response - /// on with one small syscall per frame. - /// - internal sealed class Http2BodyStreamWriter : Stream - { - private readonly int streamId; - private readonly Http2ConnectionState connectionState; - private readonly Stream clientStream; - private readonly Http2FlowController flow; - private readonly CancellationToken cancellationToken; - private readonly long expectedLength; - private readonly int maxFrameSize; - private bool endStreamSent; - private bool completed; - - /// - /// Known Content-Length (≥0) so the last DATA can carry END_STREAM; −1 for - /// chunked/close-delimited bodies that need an empty END_STREAM DATA after the pump. - /// - internal Http2BodyStreamWriter(int streamId, Http2ConnectionState connectionState, Stream clientStream, - Http2FlowController flow, CancellationToken cancellationToken, long expectedLength = -1, - int maxFrameSize = 16384) - { - this.streamId = streamId; - this.connectionState = connectionState; - this.clientStream = clientStream; - this.flow = flow; - this.cancellationToken = cancellationToken; - this.expectedLength = expectedLength; - this.maxFrameSize = maxFrameSize > 0 ? maxFrameSize : 16384; - } - - /// Total body octets written as DATA (excludes any empty END_STREAM frame). - internal long BytesWritten { get; private set; } - - public override bool CanRead => false; - - public override bool CanSeek => false; - - public override bool CanWrite => true; - - public override long Length => throw new NotSupportedException(); - - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public override void Flush() - { - } - - public override Task FlushAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - public override int Read(byte[] buffer, int offset, int count) - { - throw new NotSupportedException(); - } - - public override long Seek(long offset, SeekOrigin origin) - { - throw new NotSupportedException(); - } - - public override void SetLength(long value) - { - throw new NotSupportedException(); - } - - public override void Write(byte[] buffer, int offset, int count) - { - throw new NotSupportedException("Use WriteAsync."); - } - - public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - return WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); - } - - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, - CancellationToken cancellationToken = default) - { - if (buffer.IsEmpty) return; - - // Reserve flow-control credit per frame BEFORE queueing so queued-but-unsent DATA can - // never exceed the client's advertised windows (bounds writer-queue memory too). - var pos = 0; - while (pos < buffer.Length) - { - var frameLength = Math.Min(maxFrameSize, buffer.Length - pos); - var endStream = false; - if (expectedLength >= 0) - { - var remaining = expectedLength - BytesWritten - pos; - if (remaining <= 0) - break; - if (frameLength > remaining) - frameLength = (int)remaining; - endStream = BytesWritten + pos + frameLength >= expectedLength; - } - - await flow.ReserveAsync(streamId, frameLength, this.cancellationToken); - QueueDataFrame(connectionState, clientStream, streamId, buffer.Slice(pos, frameLength), - endStream); - if (endStream) - endStreamSent = true; - pos += frameLength; - } - - BytesWritten += pos; - } - - /// - /// Reads origin bytes directly into pre-sized DATA frame buffers (header + payload), - /// reserves flow-control credit, and enqueues for the client frame writer. - /// - internal async Task CopyFromAsync(Func, CancellationToken, ValueTask> readAsync, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - CancellationToken cancellationToken) - { - while (true) - { - var remaining = expectedLength >= 0 - ? expectedLength - BytesWritten - : maxFrameSize; - if (expectedLength >= 0 && remaining <= 0) - break; - - var payloadCap = (int)Math.Min(maxFrameSize, remaining); - var rented = ArrayPool.Shared.Rent(9 + payloadCap); - var read = 0; - try - { - while (read < payloadCap) - { - var n = await readAsync(rented.AsMemory(9 + read, payloadCap - read), cancellationToken) - .ConfigureAwait(false); - if (n == 0) - break; - read += n; - } - - if (read == 0) - { - ArrayPool.Shared.Return(rented); - rented = null!; - break; - } - - var endStream = expectedLength >= 0 && BytesWritten + read >= expectedLength; - // Prefer non-blocking reserve when the peer window already has room (typical - // after SETTINGS / WINDOW_UPDATE); avoid a Task alloc per 16 KiB frame. - if (!flow.TryReserve(streamId, read)) - await flow.ReserveAsync(streamId, read, this.cancellationToken).ConfigureAwait(false); - var dataFrameHeader = new Http2FrameHeader - { - StreamId = streamId, - Type = Http2FrameType.Data, - Length = read, - Flags = endStream ? Http2FrameFlag.EndStream : 0 - }; - dataFrameHeader.CopyToBuffer(rented); - connectionState.EnqueueWriteRented(towardServer: false, connectionState.ClientWriteLock, - clientStream, rented, 9 + read); - rented = null!; // ownership transferred - BytesWritten += read; - if (endStream) - { - endStreamSent = true; - break; - } - } - finally - { - if (rented != null) - ArrayPool.Shared.Return(rented); - } - } - } - - internal Task CompleteAsync() - { - if (completed) return Task.CompletedTask; - completed = true; - - // Known-CL path already put END_STREAM on the last payload DATA. - if (endStreamSent) - return Task.CompletedTask; - - // Empty END_STREAM needs no flow-control credit (chunked / unknown length / empty body). - QueueDataFrame(connectionState, clientStream, streamId, ReadOnlyMemory.Empty, - endStream: true); - endStreamSent = true; - return Task.CompletedTask; - } - } - - /// - /// HPACK listener that discards decoded headers. Used on the compressed-relay path so the - /// connection-scoped dynamic table stays in sync without allocating a . - /// - private sealed class NoOpHeaderListener : IHeaderListener - { - public static readonly NoOpHeaderListener Instance = new(); // NOSONAR S1144 -- reserved singleton for compressed-relay decode - - public void AddHeader(ByteString name, ByteString value, bool sensitive) - { - } - } - - // internal for unit tests that assert RFC 7540/8441 header-block validation contracts - internal class MyHeaderListener : IHeaderListener - { - private readonly Action addHeaderFunc; - - /// - /// when this block is for a request (client→proxy direction). - /// Used to enforce the RFC 7540 §8.1.2.3 pseudo-header allow-lists: request fields - /// (:method, :authority, :scheme, :path, :protocol) are forbidden in response blocks and - /// :status is forbidden in request blocks. - /// - private readonly bool isRequest; - - // Per-pseudo-header "seen" flags for duplicate detection (RFC 7540 §8.1.2.1). - private bool sawMethod, sawStatus, sawAuthority, sawScheme, sawPath, sawProtocol; - - // RFC 7540 §8.1.2.1: pseudo-header fields MUST NOT appear after a regular header field. - private bool seenRegularHeader; - - public ByteString Method { get; private set; } - - public ByteString Status { get; private set; } - - public ByteString Authority { get; private set; } - - private ByteString scheme; - - public ByteString Path { get; private set; } - - /// RFC 8441 §5: the :protocol pseudo-header value for an extended CONNECT request. - public ByteString Protocol { get; private set; } - - /// - /// Set when this header block contained an unknown pseudo-header field, a field name with - /// uppercase characters, a duplicate pseudo-header, a pseudo-header that belongs to the - /// wrong message direction, or a pseudo-header that appears after a regular header field. - /// All are malformed per RFC 7540 §8.1.2 and the block's stream must be reset. - /// - public bool HasMalformedHeader { get; private set; } - - public string? MalformedReason { get; private set; } - - /// Raw ':scheme' value bytes as sent by the peer (empty when the block has none). - public ByteString RawScheme => scheme; - - public string Scheme - { - get - { - if (scheme.Equals(ProxyServer.UriSchemeHttp8)) - { - return ProxyServer.UriSchemeHttp; - } - - if (scheme.Equals(ProxyServer.UriSchemeHttps8)) - { - return ProxyServer.UriSchemeHttps; - } - - return string.Empty; - } - } - - public MyHeaderListener(Action addHeaderFunc, bool isRequest) - { - this.addHeaderFunc = addHeaderFunc; - this.isRequest = isRequest; - } - - public void AddHeader(ByteString name, ByteString value, bool sensitive) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - { - if (name.Length > 0 && name.Span[0] == ':') - { - // RFC 7540 §8.1.2.1: pseudo-header fields MUST NOT appear after a regular header field. - if (seenRegularHeader) - { - if (!HasMalformedHeader) - { - HasMalformedHeader = true; - MalformedReason = "pseudo-header field after a regular header field"; - } - return; - } - - string nameStr = Encoding.ASCII.GetString(name.Span); - switch (nameStr) - { - case ":method": - if (!isRequest || sawMethod) - { - MarkMalformed(isRequest - ? "duplicate pseudo-header field ':method'" - : "request pseudo-header ':method' in a response block"); - return; - } - sawMethod = true; - Method = value; - return; - case ":authority": - if (!isRequest || sawAuthority) - { - MarkMalformed(isRequest - ? "duplicate pseudo-header field ':authority'" - : "request pseudo-header ':authority' in a response block"); - return; - } - sawAuthority = true; - Authority = value; - return; - case ":scheme": - if (!isRequest || sawScheme) - { - MarkMalformed(isRequest - ? "duplicate pseudo-header field ':scheme'" - : "request pseudo-header ':scheme' in a response block"); - return; - } - sawScheme = true; - scheme = value; - return; - case ":path": - if (!isRequest || sawPath) - { - MarkMalformed(isRequest - ? "duplicate pseudo-header field ':path'" - : "request pseudo-header ':path' in a response block"); - return; - } - sawPath = true; - Path = value; - return; - case ":status": - if (isRequest || sawStatus) - { - MarkMalformed(!isRequest - ? "duplicate pseudo-header field ':status'" - : "response pseudo-header ':status' in a request block"); - return; - } - sawStatus = true; - Status = value; - return; - case ":protocol": - // RFC 8441 §5: only valid on CONNECT requests. - if (!isRequest || sawProtocol) - { - MarkMalformed(isRequest - ? "duplicate pseudo-header field ':protocol'" - : "request pseudo-header ':protocol' in a response block"); - return; - } - sawProtocol = true; - Protocol = value; - return; - default: - MarkMalformed($"unknown pseudo-header field '{nameStr}'"); - return; - } - } - - seenRegularHeader = true; - - if (!HasMalformedHeader) - { - foreach (var b in name.Span) - { - if (b is >= (byte)'A' and <= (byte)'Z') - { - HasMalformedHeader = true; - MalformedReason = "header field name contains uppercase characters"; - break; - } - } - } - - addHeaderFunc(name, value); - } - - private void MarkMalformed(string reason) - { - if (!HasMalformedHeader) - { - HasMalformedHeader = true; - MalformedReason = reason; - } - } - - public Uri GetUri() - { - if (Authority.Length == 0) - throw new InvalidOperationException( - "HTTP/2 request is missing the :authority pseudo-header."); - - var bytes = new byte[scheme.Length + 3 + Authority.Length + Path.Length]; - scheme.Span.CopyTo(bytes); - int idx = scheme.Length; - bytes[idx++] = (byte)':'; - bytes[idx++] = (byte)'/'; - bytes[idx++] = (byte)'/'; - Authority.Span.CopyTo(bytes.AsSpan(idx, Authority.Length)); - idx += Authority.Length; - Path.Span.CopyTo(bytes.AsSpan(idx, Path.Length)); - - return new Uri(HttpHeader.Encoding.GetString(bytes)); - } - } } } diff --git a/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs b/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs index 04694ec69..61e205aa9 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2OriginConnection.cs @@ -1,8 +1,8 @@ using System; using System.Buffers; using System.Buffers.Binary; -using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Threading; @@ -67,7 +67,9 @@ internal sealed class Http2OriginConnection : IDisposable private readonly SemaphoreSlim writeLock = new(1, 1); private readonly Http2FlowController sendFlow = new(); private readonly Http2Settings originSettings = new(); - private readonly ConcurrentDictionary streams = new(); + // Client-initiated stream ids are odd (1,3,5,…). Index = streamId >> 1. + // Volatile slot publishes replace ConcurrentDictionary on the origin ReadLoop / SendAsync path. + private PendingStream?[] streamTable = new PendingStream?[64]; private readonly CancellationTokenSource connectionCts = new(); private readonly TaskCompletionSource initialSettingsReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -79,6 +81,7 @@ internal sealed class Http2OriginConnection : IDisposable private SemaphoreSlim? concurrencyGate; private int concurrencyGateCapacity; private Decoder? decoder; + private readonly HeaderCollectorListener headerCollector = new(); private int lastStreamId = -1; private volatile bool faulted; private volatile bool goingAway; @@ -108,8 +111,8 @@ private Http2OriginConnection(TcpServerConnection connection, ILogger logger, lo internal bool IsUsable => !faulted && !goingAway && !connection.IsClosed; /// - /// In-flight streams currently registered on this connection. Interlocked — do not use - /// streams.Count (that takes every ConcurrentDictionary lock). + /// In-flight streams currently registered on this connection. Interlocked — do not scan + /// streamTable for the count. /// internal int ActiveStreamCount => Volatile.Read(ref activeStreamCount); @@ -117,24 +120,29 @@ private Http2OriginConnection(TcpServerConnection connection, ILogger logger, lo internal int LeaseCount => Volatile.Read(ref leaseCount); /// - /// Grow the origin pool once every member has this many active streams, well before - /// SETTINGS_MAX_CONCURRENT_STREAMS (common default 100). One connection serializes - /// encode+enqueue under writeLock; spreading across a few TLS+H2 sessions matches - /// SocketsHttpHandler EnableMultipleHttp2Connections. - /// Profiled at c=16 with threshold 16: dumpasync showed a single - /// ReadLoopAsync and hundreds of SemaphoreSlim waiters — grow earlier so - /// low concurrency is not pinned to one origin TLS+H2 session. - /// Soft=4 (was 1←2←4): grow once a member has 4 in-flight streams. Soft=1 opened a new - /// TLS+H2 session on the first concurrent stream and fanned out to MaxOrigin (=8) on CI - /// 4 vCPU — cool hid the cost; Windows H3→H2 CI sat ~0.94× YARP. Soft=4 still spreads - /// writeLocks before SETTINGS_MAX_CONCURRENT_STREAMS while keeping fewer ReadLoops. - /// Cap remains . + /// Historical SoftGrow=16 TLS constant. Live TLS grow uses — + /// long 20s Mac pairs: SoftGrow=16 ~0.90× H1 / ~0.89× H3; SoftGrow=SoftPick ~0.92× H1 / + /// ~0.96× H3. SoftGrow=8 peaked short-arm H1 ~0.83× but hurt H3. Cap remains + /// . /// - internal const int PoolGrowActiveStreamThreshold = 4; + internal const int PoolGrowActiveStreamThresholdTls = 16; /// - /// Soft multiplex capacity used by to decide when to - /// open another origin connection. Prefers filling existing connections before growing the pool. + /// Early-grow threshold for cleartext h2c. SoftGrow=4 was the Soft=16-as-pick-cap era; + /// with SoftPick=SETTINGS, SoftGrow=8 lifts Mac H3→h2c (~0.88→~0.94×). SoftGrow=12 + /// regresses (~0.88×). SoftGrow=16 on cleartext starved ReadLoops under Soft=16-as-pick. + /// + internal const int PoolGrowActiveStreamThresholdCleartext = 8; + + /// Alias — TLS SoftGrow (tests / wiki that reference the historical name). + internal const int PoolGrowActiveStreamThreshold = PoolGrowActiveStreamThresholdTls; + + /// + /// Soft multiplex pick capacity used by — + /// SETTINGS_MAX_CONCURRENT_STREAMS / concurrency gate (not the early-grow dial). + /// Prefer filling under this cap; grow is driven separately by + /// / + /// . /// internal int SoftStreamCapacity { @@ -143,10 +151,18 @@ internal int SoftStreamCapacity var cap = concurrencyGateCapacity; if (cap <= 0) cap = resourceLimits.MaxConcurrentStreamsPerConnection; - return Math.Max(1, Math.Min(cap, PoolGrowActiveStreamThreshold)); + return Math.Max(1, cap); } } + /// + /// Early-grow dial for TLS and cleartext: SoftGrow = SoftStreamCapacity (SETTINGS/gate). + /// Offer-once + MaxOrigin=1 SoftPick SoftGrow SoftCap: Mac H1 TLS→H2 ~0.91–0.95×; GHA + /// H3→H2 led ~1.15× (33990406830). SoftGrow=8 cleartext Offer-once MaxOrigin=8 regresses + /// local H3→h2c / H1plain (~0.84–0.89×). SoftGrow=16 Offer-once TLS rejected (~0.89×). + /// + internal int PoolGrowThreshold => SoftStreamCapacity; + /// True when the next odd stream id would approach int wraparound. internal bool IsNearStreamIdExhaustion => Volatile.Read(ref lastStreamId) >= StreamIdExhaustionThreshold; @@ -193,19 +209,77 @@ internal void ReleaseLease() private void RegisterOpenedStream(int streamId, PendingStream pending) { - if (!streams.TryAdd(streamId, pending)) + var idx = streamId >> 1; + EnsureStreamTable(idx); + // Odd client stream ids; one slot per id. CompareExchange publishes to the ReadLoop. + if (Interlocked.CompareExchange(ref streamTable[idx], pending, null) != null) throw new InvalidOperationException($"HTTP/2 stream {streamId} is already registered on this origin connection."); Interlocked.Increment(ref activeStreamCount); } private bool TryUnregisterStream(int streamId, out PendingStream? pending) { - if (!streams.TryRemove(streamId, out pending)) + var idx = streamId >> 1; + var table = streamTable; + if ((uint)idx >= (uint)table.Length) + { + pending = null; + return false; + } + + pending = Interlocked.Exchange(ref table[idx], null); + if (pending == null) return false; Interlocked.Decrement(ref activeStreamCount); return true; } + private bool TryGetStream(int streamId, [NotNullWhen(true)] out PendingStream? pending) + { + var idx = streamId >> 1; + var table = streamTable; + if ((uint)idx >= (uint)table.Length) + { + pending = null; + return false; + } + + pending = Volatile.Read(ref table[idx]); + return pending != null; + } + + private bool StreamTableContains(int streamId) + { + var idx = streamId >> 1; + var table = streamTable; + return (uint)idx < (uint)table.Length && Volatile.Read(ref table[idx]) != null; + } + + private void EnsureStreamTable(int idx) + { + var table = streamTable; + if ((uint)idx < (uint)table.Length) + return; + + // Resize under writeLock (RegisterOpenedStream only); ReadLoop may still see the old + // array until the new one is published — copy keeps live slots. + var newLen = Math.Max(table.Length * 2, idx + 1); + var next = new PendingStream?[newLen]; + Array.Copy(table, next, table.Length); + Volatile.Write(ref streamTable, next); + } + + private IEnumerable EnumerateLiveStreams() + { + var table = streamTable; + for (var i = 0; i < table.Length; i++) + { + var pending = Volatile.Read(ref table[i]); + if (pending != null) + yield return pending; + } + } + /// /// Stops handing this connection out. Does not fail in-flight streams. Dispose runs when the /// last lease/stream drains, so siblings on a shared connection survive GOAWAY/CloseServerConnection. @@ -334,7 +408,7 @@ internal static async Task CreateAsync(TcpServerConnectio var headersEndStream = !streamRequest && bufferedBody == null && !enqueueBufferedTrailers; Http2Helper.EnqueueHeader(originSettings, frameHeader, frameHeaderBuffer, request, - headersEndStream, Writer); + headersEndStream, Writer, encoderAlreadyExclusive: true); } finally { @@ -424,6 +498,8 @@ await EnqueueDataWithFlowAsync(streamId, ReadOnlyMemory.Empty, endStream: // writer as soon as final response headers arrive, so this loop exits cleanly before // body drainage begins. When on1xx is null (passthrough lite / no 1xx relay), wait on the // HeadersReceived TCS instead — otherwise we race ProcessHeaderBlock and synthesize 502. + // Inline tiny-CL delays HeadersReceived until END_STREAM; always await it after interims + // so the body buffer is complete before TakeInlineBody. if (on1xx != null) { var interimReader = pending.InterimChannel?.Reader @@ -431,10 +507,8 @@ await EnqueueDataWithFlowAsync(streamId, ReadOnlyMemory.Empty, endStream: await foreach (var interim in interimReader.ReadAllAsync(cancellationToken)) await on1xx(interim.StatusCode, interim.Headers, cancellationToken); } - else - { - await pending.HeadersReceived.Task.WaitAsync(cancellationToken); - } + + await pending.HeadersReceived.Task.WaitAsync(cancellationToken); var response = pending.Response ?? new Response @@ -450,10 +524,15 @@ await EnqueueDataWithFlowAsync(streamId, ReadOnlyMemory.Empty, endStream: // therefore misclassify a content-length-less h2 response as bodiless and silently drop its // DATA frames. Only the status/method exclusions and an explicit `content-length: 0` mean // "no body" here (1xx never reaches this point; the interim channel consumed those). + // Missing content-length is -1 (not 0) — do not treat omission as empty. + var hasExplicitZeroContentLength = + (response.Headers.TryGetUniqueHeader(KnownHeaders.ContentLength, out _) + || response.Headers.TryGetUniqueHeader(KnownHeaders.ContentLengthHttp2, out _)) + && response.ContentLength == 0; var noBody = response.StatusCode is 204 or 304 || request.Method == "HEAD" || (request.Method == "CONNECT" && response.StatusCode is >= 200 and < 300) - || response.ContentLength == 0; + || hasExplicitZeroContentLength; if (noBody) { response.IsBodyRead = true; @@ -461,13 +540,39 @@ await EnqueueDataWithFlowAsync(streamId, ReadOnlyMemory.Empty, endStream: return new Http2OriginExchange(response, Array.Empty(), pending.TrailingHeaders); } - var bodyPipe = pending.BodyPipe; var trailers = pending.TrailingHeaders; - // Tiny fixed-length bodies (probe GET ~56 B): buffer then return so H1 deliver can - // coalesce headers+body in one write. Streaming via StreamBodyWriter pays an extra - // pipe+async hop per request for these. Read into an exact-size buffer (no - // MemoryStream + ToArray double copy). + // Tiny fixed-length bodies (probe GET ~56 B): ReadLoop already filled InlineBody and + // delayed HeadersReceived until END_STREAM — skip Pipe + second byte[] alloc. + if (pending.InlineBody != null) + { + var body = pending.TakeInlineBody(); + response.IsBodyRead = true; + response.Body = body; + response.BodyIsWireEncoded = true; + if (trailers != null) + { + foreach (var header in trailers) + response.TrailingHeaders.AddHeader(header); + } + + return new Http2OriginExchange(response, body, trailers); + } + + // Inbound may have finished (HEADERS+END_STREAM or DATA already drained) before we attach. + // Never allocate a pipe whose writer will never run — that hangs CopyToAsync. + if (pending.IsInboundComplete && pending.BodyPipeOrNull == null) + { + response.IsBodyRead = true; + response.Body = Array.Empty(); + return new Http2OriginExchange(response, Array.Empty(), trailers); + } + + var bodyPipe = pending.EnsureBodyPipe(); + + // Known-CL bodies that exceeded the inline threshold still buffer then return so H1 + // deliver can coalesce headers+body. Streaming via StreamBodyWriter pays an extra + // pipe+async hop per request for these. if (response.ContentLength is >= 0 and <= 8 * 1024) { var expected = (int)response.ContentLength; @@ -604,7 +709,7 @@ internal async Task OpenTunnelAsync(Request request, // Must use SendHeader with endStream=false: SendBody derives END_STREAM from the body // and would half-close a bodiless CONNECT before the first tunnel byte. Http2Helper.EnqueueHeader(originSettings, frameHeader, frameHeaderBuffer, request, - endStream: false, Writer); + endStream: false, Writer, encoderAlreadyExclusive: true); } finally { @@ -651,6 +756,7 @@ internal async Task OpenTunnelAsync(Request request, if (response.StatusCode is < 200 or >= 300) { + // Enqueue-only; hang protection is Http2FrameWriter.DisposeAsync drain timeout (2s+1s). await ResetStreamAsync(streamId, Http2ErrorCode.Cancel, CancellationToken.None); leaseOwned = false; ReleaseTunnelBookkeeping(streamId, pending, gate); @@ -674,6 +780,7 @@ internal async Task OpenTunnelAsync(Request request, tunnelEx); try { + // Enqueue-only RST; Writer.DisposeAsync already bounds drain on a dead origin. await ResetStreamAsync(streamId, Http2ErrorCode.Cancel, CancellationToken.None); } catch (Exception resetEx) @@ -697,7 +804,7 @@ internal async Task OpenTunnelAsync(Request request, private async Task WriteTunnelDataAsync(int streamId, ReadOnlyMemory payload, bool endStream, CancellationToken cancellationToken) { - if (!streams.ContainsKey(streamId) && !endStream) + if (!StreamTableContains(streamId) && !endStream) throw new IOException($"HTTP/2 tunnel stream {streamId} is no longer open."); await EnqueueDataWithFlowAsync(streamId, payload, endStream, cancellationToken); @@ -789,7 +896,7 @@ private void ReleaseTunnelBookkeeping(int streamId, PendingStream pending, Semap if (connectionBytes <= 0 && streamBytes <= 0) return Task.CompletedTask; - var streamStillTracked = streamBytes > 0 && streams.ContainsKey(streamId); + var streamStillTracked = streamBytes > 0 && StreamTableContains(streamId); if (connectionBytes > 0) Http2Helper.EnqueueWindowUpdate(Writer, 0, connectionBytes); if (streamStillTracked) @@ -996,7 +1103,7 @@ private void ReleaseTunnelBookkeeping(int streamId, PendingStream pending, Semap byte[]? rented = null; try { - if (streams.TryGetValue(streamId, out var pendingData)) + if (TryGetStream(streamId, out var pendingData)) { if (pendingData.IsTunnel) { @@ -1030,6 +1137,15 @@ private void ReleaseTunnelBookkeeping(int streamId, PendingStream pending, Semap } } } + else if (pendingData.InlineBody != null) + { + // Known tiny CL: copy straight into the pre-sized buffer — no Pipe / + // ArrayPool Gen0 on the probe GET path (H1→H2 / H3→H2 Mac residual). + var bodyData = StripDataFramingSpan(payloadSpan, flags); + intake.Advance(length); + if (!bodyData.IsEmpty) + pendingData.TryWriteInline(bodyData); + } else { // Copy out of intake before Advance so BodyPipe may hold the memory @@ -1042,7 +1158,8 @@ private void ReleaseTunnelBookkeeping(int streamId, PendingStream pending, Semap { try { - var writeVt = pendingData.BodyPipe.WriteAsync(bodyData, cancellationToken); + var writeVt = pendingData.EnsureBodyPipe() + .WriteAsync(bodyData, cancellationToken); if (writeVt.IsCompletedSuccessfully) { writeVt.GetAwaiter().GetResult(); @@ -1122,13 +1239,17 @@ await GrantReceiveCreditAsync(streamId, length, forceFlush: false, intake.Advance(length); goAwayLastStreamId = lastId; goingAway = true; - foreach (var kvp in streams) + var table = streamTable; + for (var i = 0; i < table.Length; i++) { - if (kvp.Key > lastId) + var pendingGoAway = Volatile.Read(ref table[i]); + if (pendingGoAway == null) continue; + var sid = (i << 1) | 1; + if (sid > lastId) { var goAwayEx = new Http2OriginGoAwayException( - $"The origin sent GOAWAY ({errorCode}) before stream {kvp.Key} was processed; it is safe to retry."); - FailPending(kvp.Value, goAwayEx); + $"The origin sent GOAWAY ({errorCode}) before stream {sid} was processed; it is safe to retry."); + FailPending(pendingGoAway, goAwayEx); } } } @@ -1269,6 +1390,17 @@ private static ReadOnlySpan StripHeadersFraming(ReadOnlySpan payload private static byte[] StripHeadersFraming(byte[] payload, Http2FrameFlag flags) // NOSONAR S1144 -- reflection test seam => StripHeadersFraming(payload.AsSpan(), flags).ToArray(); + /// Strips DATA PADDED framing without allocating (inline-body hot path). + private static ReadOnlySpan StripDataFramingSpan(ReadOnlySpan payload, Http2FrameFlag flags) + { + if ((flags & Http2FrameFlag.Padded) == 0 || payload.Length == 0) + return payload; + + var padLength = payload[0]; + var end = Math.Max(1, payload.Length - padLength); + return payload.Slice(1, end - 1); + } + /// Strips DATA PADDED framing into a new array (tunnel channel ownership). private static byte[] StripDataFraming(ReadOnlySpan payload, Http2FrameFlag flags) { @@ -1309,60 +1441,14 @@ private static ReadOnlyMemory StripDataFramingMemory( { // Decode into the Response's own HeaderCollection (or a temporary for 1xx) so we do not // allocate a second HeaderCollection and copy every field — H3→H2 tiny-GET pays this - // once per request on the origin ReadLoop. - ByteString status = default; - Response? buildingResponse = null; - HeaderCollection? interimHeaders = null; - - var listener = new HeaderCollectorListener((name, value) => - { - if (name.Length > 0 && name.Span[0] == (byte)':') - { - if (name.Equals(StaticTable.KnownHeaderStatus)) status = value; - return; - } - - // Regular fields: after :status in response HEADERS, or with no :status in a trailer block. - if (status.Length == 0) - { - // Trailer HEADERS (RFC 9113 §8.1) — no :status. Park in interimHeaders as a trailer bag. - interimHeaders ??= new HeaderCollection(); - interimHeaders.AddHeader(new HttpHeader(name, value)); - return; - } - - if (interimHeaders != null) - { - interimHeaders.AddHeader(new HttpHeader(name, value)); - return; - } - - if (buildingResponse == null) - { - var statusCodeEarly = TryParseAsciiStatusCode(status.Span, out var early) ? early : 0; - if (statusCodeEarly is >= 100 and <= 199) - { - interimHeaders = new HeaderCollection(); - interimHeaders.AddHeader(new HttpHeader(name, value)); - return; - } - - buildingResponse = new Response - { - StatusCode = statusCodeEarly != 0 ? statusCodeEarly : 502, - StatusDescription = string.Empty, - HttpVersion = HttpHeader.Version11, - HeaderNamesAreHttp2Normalized = true - }; - } - - buildingResponse.Headers.AddHeader(new HttpHeader(name, value)); - }); + // once per request on the origin ReadLoop. Reuse the connection's HeaderCollectorListener + // (no per-HEADERS lambda/listener Gen0 on the shared ReadLoop). + headerCollector.Begin(); try { decoder ??= new Decoder(8192, 4096); - decoder.Decode(compressed, listener); + decoder.Decode(compressed, headerCollector); decoder.EndHeaderBlock(); } catch (Exception ex) @@ -1371,7 +1457,11 @@ private static ReadOnlyMemory StripDataFramingMemory( return; } - if (!streams.TryGetValue(streamId, out var pending)) return; + if (!TryGetStream(streamId, out var pending)) return; + + var status = headerCollector.Status; + var buildingResponse = headerCollector.BuildingResponse; + var interimHeaders = headerCollector.InterimHeaders; if (status.Length > 0) { @@ -1399,8 +1489,15 @@ private static ReadOnlyMemory StripDataFramingMemory( pending.Response = response; // Signal that no more interim responses will arrive; unblocks SendAsync's interim drain loop. pending.InterimChannel?.Writer.TryComplete(); - // Unblock OpenTunnelAsync / passthrough lite waiting on the final response headers. - pending.HeadersReceived.TrySetResult(true); + + // Probe-shaped tiny GET (known CL ≤ 8 KiB): buffer DATA into InlineBody and delay + // HeadersReceived until END_STREAM so SendAsync skips Pipe + second alloc. + // Unknown / large CL: signal headers immediately (streaming BodyPipe path). + var delayForInline = pending.TryPrepareInlineBody(response) && !endStream + && response.ContentLength > 0 + && statusCode is not (204 or 304); + if (!delayForInline) + pending.HeadersReceived.TrySetResult(true); } } else @@ -1444,7 +1541,7 @@ private static bool TryParseAsciiStatusCode(ReadOnlySpan digits, out int s private void CompleteStream(int streamId) { - if (!streams.TryGetValue(streamId, out var pending)) return; + if (!TryGetStream(streamId, out var pending)) return; if (pending.IsTunnel) { @@ -1455,7 +1552,10 @@ private void CompleteStream(int streamId) // Use TryRemove so subsequent DATA frames for this stream-id are ignored in the read loop. if (!TryUnregisterStream(streamId, out pending) || pending == null) return; - pending.BodyPipe.CompleteWriter(); + if (pending.InlineBody != null) + pending.HeadersReceived.TrySetResult(true); + else + pending.MarkInboundComplete(); TryDisposeIfRetiredAndIdle(); } @@ -1468,7 +1568,7 @@ private void FailStream(int streamId, Exception ex) private static void FailPending(PendingStream pending, Exception ex) { - pending.BodyPipe.CompleteWriter(ex); + pending.BodyPipeOrNull?.CompleteWriter(ex); pending.InterimChannel?.Writer.TryComplete(ex); pending.TunnelDataChannel?.Writer.TryComplete(ex); pending.HeadersReceived.TrySetException(ex); @@ -1505,8 +1605,8 @@ private void Fail(Exception ex, bool report = true) "The HTTP/1.1-to-HTTP/2 origin bridge connection failed.", wrapped); } - foreach (var kvp in streams) - FailPending(kvp.Value, ex); + foreach (var pendingFail in EnumerateLiveStreams()) + FailPending(pendingFail, ex); initialSettingsReceived.TrySetException(ex); } @@ -1555,6 +1655,17 @@ private ValueTask WaitWriteLockAsync(CancellationToken cancellationToken) if (writeLock.Wait(0, CancellationToken.None)) // NOSONAR S6966 -- intentional sync try-take before WaitAsync return default; + // Brief spin before WaitAsync: under SoftGrow=SoftPick a single TLS origin conn sees + // heavy writeLock convoy at c=64; WaitAsync alone allocates Task nodes per miss. + // spin64 (past yield) long A/B: H1 ~0.88× regress vs brief spin ~0.95× — keep brief. + var spinner = new SpinWait(); + while (!spinner.NextSpinWillYield) + { + spinner.SpinOnce(); + if (writeLock.Wait(0, CancellationToken.None)) // NOSONAR S6966 -- same sync try-take + return default; + } + return new ValueTask(writeLock.WaitAsync(cancellationToken)); } @@ -1628,7 +1739,29 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo private sealed class PendingStream : IDisposable { - internal readonly BoundedBodyPipe BodyPipe; + /// + /// Known Content-Length bodies ≤ 8 KiB are filled here on the ReadLoop (no ). + /// + internal const int InlineBodyThresholdBytes = 8 * 1024; + + private BoundedBodyPipe? bodyPipe; + private readonly long maxBodyBytes; + private byte[]? inlineBody; + private int inlineWritten; + private int inboundComplete; + + internal BoundedBodyPipe? BodyPipeOrNull => bodyPipe; + + /// + /// True after the ReadLoop observed END_STREAM (or equivalent) for this stream, even when no + /// had been attached yet. SendAsync must not allocate a pipe + /// whose writer will never complete. + /// + internal bool IsInboundComplete => Volatile.Read(ref inboundComplete) != 0; + + /// Pre-sized body for known tiny Content-Length; null when using . + internal byte[]? InlineBody => inlineBody; + internal readonly bool IsTunnel; /// @@ -1641,16 +1774,16 @@ private sealed class PendingStream : IDisposable internal readonly Channel<(int StatusCode, HeaderCollection Headers)>? InterimChannel; /// - /// Completed when the final (non-1xx) response HEADERS arrive. Used by - /// and by when on1xx is null - /// (no InterimChannel drain). + /// Completed when the final (non-1xx) response HEADERS arrive — or, for known tiny Content-Length + /// bodies, when the body has been fully buffered into (END_STREAM). + /// Used by and by when on1xx is null. /// internal readonly TaskCompletionSource HeadersReceived = new(TaskCreationOptions.RunContinuationsAsynchronously); /// /// Inbound DATA payloads for an RFC 8441 tunnel. Null for ordinary request/response streams, - /// which use instead (and enforce MaxBufferedBodyBytes). + /// which use or instead. /// internal readonly Channel? TunnelDataChannel; @@ -1662,7 +1795,7 @@ internal PendingStream(long maxBodyBytes) { } - /// Max buffered body bytes for . + /// Max buffered body bytes for . /// /// When , allocate the 1xx relay channel. Tests and interception paths /// that expect 1xx use this; passes when @@ -1671,7 +1804,8 @@ internal PendingStream(long maxBodyBytes) internal PendingStream(long maxBodyBytes, bool createInterimChannel) { IsTunnel = false; - BodyPipe = new BoundedBodyPipe(maxBodyBytes); + this.maxBodyBytes = maxBodyBytes; + // BodyPipe is lazy: probe tiny-GET uses InlineBody; streaming / unknown CL creates on demand. if (createInterimChannel) { InterimChannel = Channel.CreateUnbounded<(int, HeaderCollection)>( @@ -1682,9 +1816,8 @@ internal PendingStream(long maxBodyBytes, bool createInterimChannel) private PendingStream(bool isTunnel) { IsTunnel = isTunnel; - // Tunnel streams never buffer a finite HTTP body; BodyPipe is unused but kept non-null - // so FailPending can CompleteWriter unconditionally. - BodyPipe = new BoundedBodyPipe(0); + maxBodyBytes = 0; + // Tunnel streams never buffer a finite HTTP body; BodyPipe unused. TunnelDataChannel = Channel.CreateBounded(new BoundedChannelOptions(256) { SingleReader = true, @@ -1695,9 +1828,82 @@ private PendingStream(bool isTunnel) internal static PendingStream CreateTunnel() => new(true); + /// + /// When Content-Length is known and ≤ , allocate a + /// single body buffer for the ReadLoop. Returns true when inline mode is active. + /// + internal bool TryPrepareInlineBody(Response response) + { + if (IsTunnel || response.ContentLength is < 0 or > InlineBodyThresholdBytes) + return false; + + var expected = (int)response.ContentLength; + inlineBody = expected == 0 ? Array.Empty() : new byte[expected]; + inlineWritten = 0; + return true; + } + + internal void TryWriteInline(ReadOnlySpan data) + { + if (inlineBody == null || data.IsEmpty) return; + var space = inlineBody.Length - inlineWritten; + if (space <= 0) return; + var toCopy = Math.Min(space, data.Length); + data.Slice(0, toCopy).CopyTo(inlineBody.AsSpan(inlineWritten)); + inlineWritten += toCopy; + // Full buffer: unblock SendAsync even if END_STREAM is slightly delayed. + if (inlineWritten >= inlineBody.Length) + HeadersReceived.TrySetResult(true); + } + + internal byte[] TakeInlineBody() + { + var body = inlineBody ?? Array.Empty(); + if (inlineWritten > 0 && inlineWritten < body.Length) + Array.Resize(ref body, inlineWritten); + else if (inlineWritten == 0 && body.Length > 0) + body = Array.Empty(); + inlineBody = null; + return body; + } + + /// + /// Single-assignment body pipe shared by ReadLoop (DATA) and SendAsync (drain). CAS so a + /// concurrent attach never orphans the pipe the peer already wrote into. + /// + internal BoundedBodyPipe EnsureBodyPipe() + { + var existing = bodyPipe; + if (existing != null) return existing; + + var created = new BoundedBodyPipe(maxBodyBytes); + var prior = Interlocked.CompareExchange(ref bodyPipe, created, null); + if (prior != null) + { + created.Dispose(); + return prior; + } + + // END_STREAM raced ahead of pipe publish — complete immediately so CopyToAsync cannot hang. + if (Volatile.Read(ref inboundComplete) != 0) + created.CompleteWriter(); + + return created; + } + + /// + /// Records inbound END_STREAM and completes any attached body-pipe writer. Safe when no pipe + /// exists yet; SendAsync observes and skips a dead pipe. + /// + internal void MarkInboundComplete() + { + Volatile.Write(ref inboundComplete, 1); + bodyPipe?.CompleteWriter(); + } + public void Dispose() { - BodyPipe.Dispose(); + bodyPipe?.Dispose(); // Release any reader blocking on WaitToReadAsync if Dispose is called without a prior Complete. InterimChannel?.Writer.TryComplete(); TunnelDataChannel?.Writer.TryComplete(); @@ -1707,16 +1913,58 @@ public void Dispose() private sealed class HeaderCollectorListener : IHeaderListener { - private readonly Action addHeader; + internal ByteString Status; + internal Response? BuildingResponse; + internal HeaderCollection? InterimHeaders; - internal HeaderCollectorListener(Action addHeader) + internal void Begin() { - this.addHeader = addHeader; + Status = default; + BuildingResponse = null; + InterimHeaders = null; } public void AddHeader(ByteString name, ByteString value, bool sensitive) { - addHeader(name, value); + if (name.Length > 0 && name.Span[0] == (byte)':') + { + if (name.Equals(StaticTable.KnownHeaderStatus)) Status = value; + return; + } + + if (Status.Length == 0) + { + InterimHeaders ??= new HeaderCollection(); + InterimHeaders.AddHeader(new HttpHeader(name, value)); + return; + } + + if (InterimHeaders != null) + { + InterimHeaders.AddHeader(new HttpHeader(name, value)); + return; + } + + if (BuildingResponse == null) + { + var statusCodeEarly = TryParseAsciiStatusCode(Status.Span, out var early) ? early : 0; + if (statusCodeEarly is >= 100 and <= 199) + { + InterimHeaders = new HeaderCollection(); + InterimHeaders.AddHeader(new HttpHeader(name, value)); + return; + } + + BuildingResponse = new Response + { + StatusCode = statusCodeEarly != 0 ? statusCodeEarly : 502, + StatusDescription = string.Empty, + HttpVersion = HttpHeader.Version11, + HeaderNamesAreHttp2Normalized = true + }; + } + + BuildingResponse.Headers.AddHeader(new HttpHeader(name, value)); } } } diff --git a/src/Titanium.Web.Proxy/Http2/Http2OriginConnectionPool.cs b/src/Titanium.Web.Proxy/Http2/Http2OriginConnectionPool.cs index 056a2fab6..efb0ee1c2 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2OriginConnectionPool.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2OriginConnectionPool.cs @@ -100,7 +100,7 @@ internal static string BuildPoolKey( // NOSONAR S107 -- Parameters kept explicit /// only when the connection is known bad (GOAWAY/fault) or the user /// requested CloseServerConnection. /// - internal async ValueTask RentAsync( + internal async ValueTask RentAsync( // NOSONAR S3776 -- Pool pick stays one method so the lock-free probe path cannot regress. string poolKey, Func> openAsync, CancellationToken cancellationToken) @@ -116,19 +116,30 @@ internal async ValueTask RentAsync( // One ToArray: grow=1 makes every in-flight stream a TryPick miss, then the // at-max path used to snapshot again (dump: thousands of Http2OriginConnection[]). var snapshot = SnapshotMembers(entry); - var picked = TryPickFromSnapshot(snapshot, limits); - if (picked != null) - return picked; - - // Soft-miss. Skip CreationGate only when a Gate-held snapshot says the authority - // is already at max — open is impossible, so serializing on CreationGate cannot - // create and would only convoy oversubscribed rents (c=64). - if (!CanOpenAnother(entry, limits)) + // Early grow (SoftGrow) before PreferPick: SoftStreamCapacity is the SETTINGS/gate + // hard soft-pick so SoftGrow fan-out does not require SoftPick=SoftGrow (which caused + // TryPickAny oversubscribe once MaxOrigin×SoftGrow streams were in flight). + // SoftGrow is per-connection (TLS=SoftStreamCapacity / cleartext=8); empty snapshot uses TLS alias. + var growAt = snapshot.Length > 0 + ? snapshot[0].PoolGrowThreshold + : Http2OriginConnection.PoolGrowActiveStreamThreshold; + var earlyGrow = CanOpenAnother(entry, limits) && ShouldEarlyGrow(snapshot, growAt); + if (!earlyGrow) { - DiagPickStats.OnTryPickAny(); - picked = TryPickAnyFromSnapshot(snapshot); + var picked = TryPickFromSnapshot(snapshot, limits); if (picked != null) return picked; + + // Soft-miss. Skip CreationGate only when a Gate-held snapshot says the authority + // is already at max — open is impossible, so serializing on CreationGate cannot + // create and would only convoy oversubscribed rents (c=64). + if (!CanOpenAnother(entry, limits)) + { + DiagPickStats.OnTryPickAny(); + picked = TryPickAnyFromSnapshot(snapshot); + if (picked != null) + return picked; + } } DiagPickStats.OnCreationGate(); @@ -138,14 +149,28 @@ internal async ValueTask RentAsync( ObjectDisposedException.ThrowIf(draining, nameof(Http2OriginConnectionPool)); snapshot = SnapshotMembers(entry); - picked = TryPickFromSnapshot(snapshot, limits); - if (picked != null) - return picked; + growAt = snapshot.Length > 0 + ? snapshot[0].PoolGrowThreshold + : Http2OriginConnection.PoolGrowActiveStreamThreshold; + earlyGrow = CanOpenAnother(entry, limits) && ShouldEarlyGrow(snapshot, growAt); + if (!earlyGrow) + { + var picked = TryPickFromSnapshot(snapshot, limits); + if (picked != null) + return picked; - if (!CanOpenAnother(entry, limits)) + if (!CanOpenAnother(entry, limits)) + { + DiagPickStats.OnTryPickAny(); + picked = TryPickAnyFromSnapshot(snapshot); + if (picked != null) + return picked; + } + } + else if (!CanOpenAnother(entry, limits)) { - DiagPickStats.OnTryPickAny(); - picked = TryPickAnyFromSnapshot(snapshot); + var picked = TryPickFromSnapshot(snapshot, limits) + ?? TryPickAnyFromSnapshot(snapshot); if (picked != null) return picked; } @@ -181,6 +206,31 @@ internal async ValueTask RentAsync( } } + /// + /// True when this authority already holds at least one pooled origin connection. + /// + internal bool HasAny(string poolKey) + { + if (!pool.TryGetValue(poolKey, out var entry)) + return false; + + lock (entry.Gate) + return entry.Connections.Count > 0; + } + + /// + /// True when this authority already holds + /// members. + /// + internal bool IsAtMaxOriginCapacity(string poolKey) + { + if (!pool.TryGetValue(poolKey, out var entry)) + return false; + + lock (entry.Gate) + return entry.Connections.Count >= proxyServer.ResourceLimits.MaxOriginHttp2ConnectionsPerAuthority; + } + /// /// Offers an already-established connection (e.g. the H1 bridge seed from negotiation) into the /// pool for . Disposes it when the authority is already at capacity. @@ -286,6 +336,29 @@ internal async ValueTask DrainAsync() public async ValueTask DisposeAsync() => await DrainAsync().ConfigureAwait(false); + /// + /// True when every usable member is at/above active streams + /// (or the snapshot is empty). Empty → caller should open the first connection. + /// + private static bool ShouldEarlyGrow(Http2OriginConnection[] snapshot, int growAt) + { + if (snapshot.Length == 0) + return true; + + var usable = 0; + foreach (var c in snapshot) + { + if (!c.IsUsable || c.IsNearStreamIdExhaustion) + continue; + + usable++; + if (c.ActiveStreamCount < growAt) + return false; + } + + return usable > 0; + } + private static Http2OriginConnection? TryPickAnyUsable(AuthorityEntry entry) => TryPickAnyFromSnapshot(SnapshotMembers(entry)); @@ -447,7 +520,7 @@ private sealed class AuthorityEntry /// internal static class DiagPickStats { - private static bool Enabled = + private static bool Enabled => string.Equals(Environment.GetEnvironmentVariable("TWP_DIAG_POOL_PICK"), "1", StringComparison.Ordinal); @@ -488,7 +561,7 @@ private static void EnsureLogger() { while (true) { - await Task.Delay(2000).ConfigureAwait(false); + await Task.Delay(2000, CancellationToken.None).ConfigureAwait(false); // NOSONAR S8949 -- env-gated diag loop; no product CTS Emit("periodic"); } } diff --git a/src/Titanium.Web.Proxy/Http2/Http2OriginRelayPool.cs b/src/Titanium.Web.Proxy/Http2/Http2OriginRelayPool.cs index 9ba93370c..3faeaeb2d 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2OriginRelayPool.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2OriginRelayPool.cs @@ -127,7 +127,7 @@ private int SoftCapPerLeg() { // Spread streams across origin legs before any single connection saturates. Dividing by // MaxOrigin*4 (default 8) opens additional legs under typical RPS concurrency (32–128). - // Soft=1/2 fan-out was tried; cool remasure showed no gain vs Soft≈8 (extra legs tax + // Soft=1/2 fan-out was tried; cool remeasure showed no gain vs Soft≈8 (extra legs tax // cleartext connect without helping FrameWriter parallelism enough). return Math.Max(1, resourceLimits.MaxConcurrentStreamsPerConnection / Math.Max(1, resourceLimits.MaxOriginHttp2ConnectionsPerAuthority * 4)); diff --git a/src/Titanium.Web.Proxy/Http2/Http2StreamContext.cs b/src/Titanium.Web.Proxy/Http2/Http2StreamContext.cs index cb166f1cd..5dbcde25e 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2StreamContext.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2StreamContext.cs @@ -11,8 +11,9 @@ namespace Titanium.Web.Proxy.Http2; /// delegate can reach the connection-wide HPACK/flow-control/synchronization state /// () and the real client-facing transport () it /// needs to answer a stream on its own schedule, independently of the frame-relay loop that invoked it. +/// Readonly struct so same-protocol MITM (handlers ignore this) does not allocate per stream. /// -internal sealed class Http2StreamContext +internal readonly struct Http2StreamContext { internal Http2StreamContext(int streamId, Http2ConnectionState connectionState, Stream clientStream, CancellationToken cancellationToken) diff --git a/src/Titanium.Web.Proxy/Http2/Http2StreamStatePool.cs b/src/Titanium.Web.Proxy/Http2/Http2StreamStatePool.cs index 96b9d0630..f4434433f 100644 --- a/src/Titanium.Web.Proxy/Http2/Http2StreamStatePool.cs +++ b/src/Titanium.Web.Proxy/Http2/Http2StreamStatePool.cs @@ -46,13 +46,21 @@ public Http2StreamState RentSession(int streamId, SessionEventArgs sessionArgs) return new Http2StreamState(streamId, sessionArgs); } + internal int Retained => Volatile.Read(ref retained); + public void Return(Http2StreamState state) { state.PrepareForPool(); - if (Volatile.Read(ref retained) >= maxRetained) - return; - - pool.Add(state); - Interlocked.Increment(ref retained); + while (true) + { + var current = Volatile.Read(ref retained); + if (current >= maxRetained) + return; + if (Interlocked.CompareExchange(ref retained, current + 1, current) == current) + { + pool.Add(state); + return; + } + } } } diff --git a/src/Titanium.Web.Proxy/Http3/Http3Frame.cs b/src/Titanium.Web.Proxy/Http3/Http3Frame.cs index 2d0cc4d19..ced3ef0ad 100644 --- a/src/Titanium.Web.Proxy/Http3/Http3Frame.cs +++ b/src/Titanium.Web.Proxy/Http3/Http3Frame.cs @@ -1,6 +1,7 @@ using System; using System.Buffers; using System.IO; +using System.Net.Quic; using System.Threading; using System.Threading.Tasks; @@ -87,12 +88,16 @@ public void ReturnPayload() /// Writes a frame (type + length + payload) to . /// Coalesces the VarInt header (and small payloads) into a single socket write — /// single-span header flush pattern (VarInt header + small payload coalesced into one write). + /// When is true and is a + /// , STREAM data and FIN share one MsQuic write. Callers must still + /// FlushAsync (Darwin skip-Flush is banned). /// public static async ValueTask WriteAsync( Stream stream, ulong frameType, ReadOnlyMemory payload, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool completeWrites = false) { // Max VarInt is 8 bytes each for type + length. const int headerCap = 16; @@ -106,7 +111,8 @@ public static async ValueTask WriteAsync( var headerLen = typeLen + lengthLen; if (!payload.IsEmpty) payload.Span.CopyTo(rented.AsSpan(headerLen)); - await stream.WriteAsync(rented.AsMemory(0, headerLen + payload.Length), cancellationToken); + await WriteBufferAsync(stream, rented.AsMemory(0, headerLen + payload.Length), + completeWrites, cancellationToken); } finally { @@ -123,7 +129,7 @@ public static async ValueTask WriteAsync( var typeLen = Http3VarInt.Write(headerBytes, frameType); var lengthLen = Http3VarInt.Write(headerBytes.AsSpan(typeLen), (ulong)payload.Length); await stream.WriteAsync(headerBytes.AsMemory(0, typeLen + lengthLen), cancellationToken); - await stream.WriteAsync(payload, cancellationToken); + await WriteBufferAsync(stream, payload, completeWrites, cancellationToken); } finally { @@ -150,7 +156,8 @@ public static async ValueTask WriteHeadersAndDataAsync( Stream stream, ReadOnlyMemory headersPayload, ReadOnlyMemory dataPayload, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool completeWrites = false) { const int headerCap = 16; var total = headerCap + headersPayload.Length + headerCap + dataPayload.Length; @@ -174,11 +181,28 @@ public static async ValueTask WriteHeadersAndDataAsync( o += dataPayload.Length; } - await stream.WriteAsync(rented.AsMemory(0, o), cancellationToken); + await WriteBufferAsync(stream, rented.AsMemory(0, o), completeWrites, cancellationToken); } finally { ArrayPool.Shared.Return(rented); } } + +#pragma warning disable CA1416 // QuicStream.WriteAsync(completeWrites) is gated on the runtime stream type. + /// + /// When is set, pack STREAM payload + FIN on + /// ; other streams ignore the flag (unit tests use MemoryStream). + /// + private static ValueTask WriteBufferAsync( + Stream stream, + ReadOnlyMemory buffer, + bool completeWrites, + CancellationToken cancellationToken) + { + if (completeWrites && stream is QuicStream quic) + return quic.WriteAsync(buffer, completeWrites: true, cancellationToken); + return stream.WriteAsync(buffer, cancellationToken); + } +#pragma warning restore CA1416 } diff --git a/src/Titanium.Web.Proxy/Http3/Http3NativeBootstrap.cs b/src/Titanium.Web.Proxy/Http3/Http3NativeBootstrap.cs new file mode 100644 index 000000000..017fffb31 --- /dev/null +++ b/src/Titanium.Web.Proxy/Http3/Http3NativeBootstrap.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Quic; +using System.Reflection; +using System.Runtime.InteropServices; + +namespace Titanium.Web.Proxy.Http3; + +/// +/// Ensures app-local MsQuic natives are visible to on macOS +/// framework-dependent hosts (typical Debug / dotnet run). +/// +/// +/// On non-Windows, loads MsQuic by leaf name only +/// (libmsquic), not from . Self-contained +/// publishes place System.Net.Quic.dll next to the bundled dylibs so AssemblyDirectory +/// search works. Framework-dependent builds keep Quic in the shared framework directory, so +/// copying dylibs beside the app is not enough unless DYLD_FALLBACK_LIBRARY_PATH +/// (or DYLD_LIBRARY_PATH) includes that folder — set before process start. +/// +/// Re-launch uses a child process. The parent must forward POSIX termination/reload signals +/// to that child (and cancel the parent's default terminate) so kill -HUP <started-pid>, +/// SIGTERM from a service manager, and Ctrl+C reach the process that actually runs the proxy. +/// +/// +public static class Http3NativeBootstrap +{ + internal const string ReexecMarkerEnv = "TWP_HTTP3_REEXEC"; + internal const string SkipReexecEnv = "TWP_SKIP_HTTP3_REEXEC"; + + /// + /// When macOS app-local libmsquic.dylib is present but dyld cannot see it yet, + /// re-launches the current process with DYLD_FALLBACK_LIBRARY_PATH pointing at + /// . No-ops on Windows/Linux, self-contained layouts, + /// when natives are missing, or when the library path already includes the app directory. + /// + /// Application arguments (as passed to Main), used when relaunching. + public static void EnsureAppLocalMsQuicVisible(string[]? args = null) + { + if (!OperatingSystem.IsMacOS()) + { + return; + } + + if (string.Equals(Environment.GetEnvironmentVariable(SkipReexecEnv), "1", StringComparison.Ordinal)) + { + return; + } + + if (string.Equals(Environment.GetEnvironmentVariable(ReexecMarkerEnv), "1", StringComparison.Ordinal)) + { + return; + } + + var baseDir = NormalizeDir(AppContext.BaseDirectory); + if (string.IsNullOrEmpty(baseDir)) + { + return; + } + + var msquic = Path.Combine(baseDir, "libmsquic.dylib"); + if (!File.Exists(msquic)) + { + return; + } + + // Self-contained / app-local framework: System.Net.Quic lives next to the dylibs. + if (IsQuicAssemblyBesideApp(baseDir)) + { + return; + } + + if (DyldSearchPathContains(baseDir)) + { + return; + } + + RelaunchWithDyldFallback(baseDir, args ?? Array.Empty()); + } + + private static bool IsQuicAssemblyBesideApp(string baseDir) + { + try + { + var location = typeof(QuicListener).Assembly.Location; + if (string.IsNullOrEmpty(location)) + { + // Single-file: Quic is embedded; BaseDirectory natives are the intended probe path. + return true; + } + + var quicDir = NormalizeDir(Path.GetDirectoryName(location)); + if (string.IsNullOrEmpty(quicDir)) + { + return false; + } + + return PathsEqual(quicDir, baseDir) + || quicDir.StartsWith(baseDir + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + private static bool DyldSearchPathContains(string baseDir) + { + foreach (var key in new[] { "DYLD_FALLBACK_LIBRARY_PATH", "DYLD_LIBRARY_PATH" }) + { + var value = Environment.GetEnvironmentVariable(key); + if (string.IsNullOrEmpty(value)) + { + continue; + } + + if (value.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Any(p => PathsEqual(NormalizeDir(p), baseDir))) + { + return true; + } + } + + return false; + } + + private static void RelaunchWithDyldFallback(string baseDir, string[] appArgs) + { + var processPath = Environment.ProcessPath; + if (string.IsNullOrEmpty(processPath) || !File.Exists(processPath)) + { + return; + } + + var psi = new ProcessStartInfo + { + FileName = processPath, + UseShellExecute = false, + WorkingDirectory = Directory.GetCurrentDirectory(), + }; + + AppendRelaunchArguments(psi, processPath, appArgs); + + var existingFallback = Environment.GetEnvironmentVariable("DYLD_FALLBACK_LIBRARY_PATH"); + psi.Environment["DYLD_FALLBACK_LIBRARY_PATH"] = string.IsNullOrEmpty(existingFallback) + ? baseDir + : baseDir + ":" + existingFallback; + psi.Environment[ReexecMarkerEnv] = "1"; + + try + { + using var child = Process.Start(psi); + if (child is null) + { + return; + } + + // Parent keeps the original PID that launchd/shell/probes signal. Forward those + // signals to the child that actually hosts Quic + the proxy, and cancel the parent's + // default terminate so SIGHUP (reload) does not exit 129 before the child sees it. + using var signalForwarders = ForwardUnixSignalsToChild(child); + child.WaitForExit(); + Environment.Exit(child.ExitCode); + } + catch + { + // Leave the original process running; Quic may stay unsupported. + } + } + + /// + /// Registers parent-side POSIX handlers that forward SIGHUP/SIGINT/SIGTERM to + /// and cancel the parent's default terminate action. + /// + internal static IDisposable ForwardUnixSignalsToChild(Process child) + { + if (OperatingSystem.IsWindows()) + { + return EmptyDisposable.Instance; + } + + var registrations = new List(3); + void Forward(PosixSignal signal, int signo) + { + registrations.Add(PosixSignalRegistration.Create(signal, ctx => + { + ctx.Cancel = true; + try + { + if (!child.HasExited) + { + _ = NativeKill(child.Id, signo); + } + } + catch + { + // Child may have exited between HasExited and kill. + } + })); + } + + // SIGHUP=1, SIGINT=2, SIGTERM=15 on Linux and macOS. + Forward(PosixSignal.SIGHUP, 1); + Forward(PosixSignal.SIGINT, 2); + Forward(PosixSignal.SIGTERM, 15); + return new SignalForwarderLease(registrations); + } + + [DllImport("libc", EntryPoint = "kill", SetLastError = true)] + private static extern int NativeKill(int pid, int sig); + + private static void AppendRelaunchArguments(ProcessStartInfo psi, string processPath, string[] appArgs) + { + var hostName = Path.GetFileNameWithoutExtension(processPath); + var isDotnetHost = hostName.Equals("dotnet", StringComparison.OrdinalIgnoreCase); + if (isDotnetHost) + { + // `dotnet path/to/app.dll …args` — keep the entry assembly path then app args. + var entry = Assembly.GetEntryAssembly()?.Location; + if (!string.IsNullOrEmpty(entry) && File.Exists(entry)) + { + psi.ArgumentList.Add(entry); + foreach (var a in appArgs) + { + psi.ArgumentList.Add(a); + } + + return; + } + } + + // AppHost: argv is just the application arguments. + foreach (var a in appArgs) + { + psi.ArgumentList.Add(a); + } + } + + private static string NormalizeDir(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + try + { + return Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + catch + { + return path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + } + + private static bool PathsEqual(string a, string b) => + string.Equals(a, b, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + + private sealed class SignalForwarderLease : IDisposable + { + private readonly List _registrations; + + public SignalForwarderLease(List registrations) => + _registrations = registrations; + + public void Dispose() + { + foreach (var reg in _registrations) + { + reg.Dispose(); + } + + _registrations.Clear(); + } + } + + private sealed class EmptyDisposable : IDisposable + { + public static readonly EmptyDisposable Instance = new(); + public void Dispose() + { + } + } +} diff --git a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Headers.cs b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Headers.cs new file mode 100644 index 000000000..67af344fd --- /dev/null +++ b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Headers.cs @@ -0,0 +1,101 @@ +#pragma warning disable CA1416 +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Quic; +using System.Net.Security; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Exceptions; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Http.Responses; +using Titanium.Web.Proxy.Http2; +using Titanium.Web.Proxy.Http3.Qpack; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network; +using Titanium.Web.Proxy.Network.Quic; +using Titanium.Web.Proxy.Network.Tcp; +using Titanium.Web.Proxy.Options; +using Titanium.Web.Proxy.StreamExtended.Network; + +namespace Titanium.Web.Proxy.Http3; + +/// +/// Handles forwarding an already-decoded inbound HTTP/3 request to the origin server, implementing +/// all necessary protocol bridges: +/// +/// H3→H3: QUIC origin via . +/// H3→H2: TCP origin via Http2OriginConnection. +/// H3→H1.1: TCP origin via the normal HTTP/1.1 server pipeline. +/// +/// Protocol selection is delegated entirely to ; +/// callers that have a pre-resolved should use the route-based +/// overload to avoid redundant cache/DNS lookups. +/// +internal static partial class Http3OriginBridge +{ + private static bool ResponseMayHaveBody( + int statusCode, string method, long contentLength, bool isChunked, bool connectionClose) + { + if (statusCode is >= 100 and < 200) return false; + if (statusCode is 204 or 304) return false; + if (string.Equals(method, "HEAD", StringComparison.OrdinalIgnoreCase)) return false; + if (contentLength == 0) return false; + if (contentLength > 0) return true; + if (isChunked || connectionClose) return true; + return false; + } + + private static void PrepareH2OriginRequestHeaders(Request request) + { + if (request.Authority.Length == 0) + { + var hostHeader = request.Host; + if (!string.IsNullOrEmpty(hostHeader)) + request.Authority = hostHeader.GetByteString(); + } + + request.Headers.RemoveHeader(KnownHeaders.Connection); + request.Headers.RemoveHeader("Keep-Alive"); + request.Headers.RemoveHeader(KnownHeaders.ProxyConnection); + request.Headers.RemoveHeader(KnownHeaders.TransferEncoding); + request.Headers.RemoveHeader(KnownHeaders.Upgrade); + request.Headers.RemoveHeader("TE"); + request.Headers.RemoveHeader(KnownHeaders.Host); + + // Fast path when names are already lowercase (QPACK); otherwise rename in place. + if (request.Headers.Any(h => + { + for (var i = 0; i < h.Name.Length; i++) + { + var c = h.Name[i]; + if (c is >= 'A' and <= 'Z') return true; + } + + return false; + })) + { + var renamed = request.Headers + .Select(h => (Name: h.Name.ToLowerInvariant(), h.Value)) + .ToList(); + request.Headers.Clear(); + foreach (var (name, value) in renamed) + request.Headers.AddHeader(name, value); + } + + request.HeaderNamesAreHttp2Normalized = true; + } + + /// + /// Frame types that RFC 9114 forbids on request streams (must not be silently ignored). + /// + private static bool IsForbiddenOnRequestStream(ulong frameType) => + frameType is Http3FrameType.Settings or Http3FrameType.GoAway + or Http3FrameType.MaxPushId or Http3FrameType.CancelPush; +} diff --git a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Http2.cs b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Http2.cs new file mode 100644 index 000000000..0fad1c336 --- /dev/null +++ b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Http2.cs @@ -0,0 +1,460 @@ +#pragma warning disable CA1416 +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Quic; +using System.Net.Security; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Exceptions; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Http.Responses; +using Titanium.Web.Proxy.Http2; +using Titanium.Web.Proxy.Http3.Qpack; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network; +using Titanium.Web.Proxy.Network.Quic; +using Titanium.Web.Proxy.Network.Tcp; +using Titanium.Web.Proxy.Options; +using Titanium.Web.Proxy.StreamExtended.Network; + +namespace Titanium.Web.Proxy.Http3; + +/// +/// Handles forwarding an already-decoded inbound HTTP/3 request to the origin server, implementing +/// all necessary protocol bridges: +/// +/// H3→H3: QUIC origin via . +/// H3→H2: TCP origin via Http2OriginConnection. +/// H3→H1.1: TCP origin via the normal HTTP/1.1 server pipeline. +/// +/// Protocol selection is delegated entirely to ; +/// callers that have a pre-resolved should use the route-based +/// overload to avoid redundant cache/DNS lookups. +/// +internal static partial class Http3OriginBridge +{ + internal static async Task ForwardOverHttp2FastAsync( + H3H2FastForward fwd, + ProxyServer server, + ILogger logger, + CancellationToken cancellationToken, + Func coldOpenSessionFactory) + { + var request = fwd.Request; + var clientHttpVersion = request.HttpVersion; + request.HttpVersion = HttpHeader.Version20; + + if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint { ForwardCleartext: true }) + request.IsHttps = false; + + if (request.Authority.Length == 0 && !string.IsNullOrEmpty(request.Host)) + request.Authority = request.Host.GetByteString(); + + string? connectHost = null; + int? connectPort = null; + if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint transparent + && !string.IsNullOrEmpty(transparent.ForwardHost)) + { + connectHost = transparent.ForwardHost; + connectPort = transparent.ForwardPort; + } + + string host; + int port; + string poolKey; + if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint fastEp + && fastEp.CachedH2OriginPoolKey != null + && request.Authority.Equals(fastEp.CachedH2OriginAuthority)) + { + host = fastEp.CachedH2OriginHost!; + port = fastEp.CachedH2OriginPort; + poolKey = fastEp.CachedH2OriginPoolKey; + } + else + { + (host, port) = ResolveH2OriginAuthority(request); + poolKey = Http2OriginConnectionPool.BuildPoolKey( + server, fwd.ProxyEndPoint, fwd.CustomUpStreamProxy, fwd.UpStreamEndPoint, + host, port, connectHost, connectPort); + if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint cacheEp) + { + cacheEp.CachedH2OriginAuthority = request.Authority; + cacheEp.CachedH2OriginHost = host; + cacheEp.CachedH2OriginPort = port; + cacheEp.CachedH2OriginPoolKey = poolKey; + } + } + + try + { + var target = new Http2OriginTarget(host, port, connectHost, connectPort, poolKey); + var exchange = await SendHttp2OriginFastWithGoAwayRetryAsync( + server, logger, fwd, target, coldOpenSessionFactory, cancellationToken); + + var response = exchange.Response; + response.HttpVersion = HttpHeader.Version30; + response.RequestMethod = request.Method; + if (response.StreamBodyWriter == null) + { + response.IsBodyRead = true; + response.Body = exchange.Body; + // Http2OriginConnection materializes H2 DATA wire bytes. + response.BodyIsWireEncoded = true; + } + + if (exchange.TrailingHeaders != null && !response.HasTrailingHeaders) + { + foreach (var header in exchange.TrailingHeaders) + response.TrailingHeaders.AddHeader(header); + } + + fwd.Response = response; + } + finally + { + request.HttpVersion = clientHttpVersion; + } + } + + private static async Task SendHttp2OriginFastWithGoAwayRetryAsync( + ProxyServer server, ILogger logger, H3H2FastForward fwd, + Http2OriginTarget target, + Func coldOpenSessionFactory, + CancellationToken cancellationToken) + { + Http2OriginConnection? h2 = null; + try + { + h2 = await LeaseHttp2OriginFastAsync(server, logger, fwd, target, coldOpenSessionFactory, + cancellationToken); + return await h2.SendAsync(fwd.Request, on1xx: null, cancellationToken); + } + catch (Exception ex) when (ex is Http2OriginGoAwayException + || (ex is IOException && h2 is { IsUsable: false })) + { + if (h2 != null) + server.Http2OriginConnectionPool.Invalidate(target.PoolKey, h2); + + if (!CanReplayHttp2OriginRequest(fwd.Request, copyRequestBody: null)) + throw; + + h2 = await LeaseHttp2OriginFastAsync(server, logger, fwd, target, coldOpenSessionFactory, + cancellationToken); + return await h2.SendAsync(fwd.Request, on1xx: null, cancellationToken); + } + } + + private static async Task LeaseHttp2OriginFastAsync( // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + ProxyServer server, ILogger logger, H3H2FastForward fwd, + Http2OriginTarget target, + Func coldOpenSessionFactory, + CancellationToken cancellationToken) + { + return await server.Http2OriginConnectionPool.RentAsync(target.PoolKey, async ct => + { + // Cold open only: build a throwaway SessionEventArgs for TcpConnectionFactory cert hooks. + var sessionArgs = coldOpenSessionFactory(); + try + { + var originIsHttps = fwd.ProxyEndPoint is not TransparentBaseProxyEndPoint { ForwardCleartext: true }; + var upStreamProxy = fwd.CustomUpStreamProxy + ?? (originIsHttps ? server.UpStreamHttpsProxy : server.UpStreamHttpProxy); + + var tcp = await server.TcpConnectionFactory.GetServerConnection( + server, target.Host, target.Port, HttpHeader.Version20, originIsHttps, + originIsHttps ? SslExtensions.Http2ProtocolAsList : null, + false, sessionArgs, fwd.UpStreamEndPoint ?? server.UpStreamEndPoint, + upStreamProxy, + true, false, ct, target.ConnectHost, target.ConnectPort); + + if (tcp != null && !originIsHttps) + tcp.Http2Cleartext = true; + + if (tcp == null || + (originIsHttps + ? tcp.NegotiatedApplicationProtocol != SslApplicationProtocol.Http2 + : !tcp.Http2Cleartext)) + { + if (tcp != null) + await server.TcpConnectionFactory.Release(tcp, true); + var how = originIsHttps ? "did not negotiate HTTP/2 via ALPN" : "did not accept cleartext HTTP/2 (h2c)"; + throw new ProxyHttpException( + $"The origin '{target.Host}:{target.Port}' {how} for the H3→H2 bridge.", + null, sessionArgs); + } + + return await Http2OriginConnection.CreateAsync(tcp, logger, + fwd.MaxBufferedBodyBytes, ct, server.ResourceLimits); + } + finally + { + sessionArgs.CancellationTokenSource.Dispose(); + sessionArgs.Dispose(); + } + }, cancellationToken); + } + + /// + /// Session-less H3→H3 forward for the interception-off bodiless path. + /// Request: QPACK encode from the Request bag (authority rewrite for ForwardHost). + /// Response when is non-null: verbatim frame relay + /// (H2 compressed-relay analogue) — no response QPACK decode/re-encode / graph. + /// When is null (MITM unchanged-lite): capture the first + /// HEADERS QPACK block (+ tiny DATA) into + /// and populate so BeforeResponse can run before emit. + /// Returns when the client response is already on the wire, or when + /// MITM capture succeeded and the caller should SendPreencodedResponseAsync. + /// + private static async Task ForwardOverHttp2Async( // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + SessionEventArgs sessionArgs, + ProxyServer server, + ILogger logger, + CancellationToken cancellationToken, + Func? onInterimResponse = null) + { + var request = sessionArgs.HttpClient.Request; + + // Stream when possible; only force a full buffer if a handler already started GetRequestBody + // or no live pump is available. + var copyRequestBody = sessionArgs.Http3RequestBodyPump; + if (copyRequestBody == null) + await EnsureHttp3BufferedBodyAsync(sessionArgs, cancellationToken); + else if (!request.HasBody && !request.IsBodyReceived) + { + // Bodiless H3 (GET): drain client FIN via the pump, then send origin HEADERS+END_STREAM. + // Leaving the pump set forces HEADERS without END_STREAM plus an empty DATA frame under + // writeLock — profiled as wasted origin-write serialization under multiplex. + await copyRequestBody(static (_, _) => default, cancellationToken); + copyRequestBody = null; + request.IsBodyReceived = true; + } + + var clientHttpVersion = request.HttpVersion; + request.HttpVersion = HttpHeader.Version20; + + // Prefer :authority for origin resolve; Host string is only needed when handlers / H1 + // fallback read Request.Host. Skip the GetString alloc on the H3→H2 fast path. + if (!sessionArgs.IsFastPath + && string.IsNullOrEmpty(request.Host) + && request.Authority.Length > 0) + request.Host = request.Authority.GetString(); + + // TLS-terminate → h2c: origin expects :scheme http. + if (sessionArgs.ProxyEndPoint is TransparentBaseProxyEndPoint { ForwardCleartext: true }) + request.IsHttps = false; + + // QPACK decode already produces lowercase names and no hop-by-hop headers on the probe + // fast path — skip the RemoveHeader/Any scan that dominates Prepare for tiny GETs. + if (!sessionArgs.IsFastPath) + PrepareH2OriginRequestHeaders(request); + else if (request.Authority.Length == 0 && !string.IsNullOrEmpty(request.Host)) + request.Authority = request.Host.GetByteString(); + + var (connectHost, connectPort) = ResolveTransparentForwardTarget(sessionArgs); + + string host; + int port; + string poolKey; + if (sessionArgs.IsFastPath + && sessionArgs.ProxyEndPoint is TransparentBaseProxyEndPoint fastEp + && fastEp.CachedH2OriginPoolKey != null + && request.Authority.Equals(fastEp.CachedH2OriginAuthority)) + { + host = fastEp.CachedH2OriginHost!; + port = fastEp.CachedH2OriginPort; + poolKey = fastEp.CachedH2OriginPoolKey; + } + else + { + (host, port) = ResolveH2OriginAuthority(request); + poolKey = Http2OriginConnectionPool.BuildPoolKey(server, sessionArgs, host, port, connectHost, + connectPort); + if (sessionArgs.IsFastPath && sessionArgs.ProxyEndPoint is TransparentBaseProxyEndPoint cacheEp) + { + cacheEp.CachedH2OriginAuthority = request.Authority; + cacheEp.CachedH2OriginHost = host; + cacheEp.CachedH2OriginPort = port; + cacheEp.CachedH2OriginPoolKey = poolKey; + } + } + + try + { + var on1xx = CreateInterimResponseAdapter(onInterimResponse); + var exchange = await SendHttp2OriginWithGoAwayRetryAsync( + server, logger, sessionArgs, + new Http2OriginTarget(host, port, connectHost, connectPort, poolKey), + on1xx, cancellationToken, copyRequestBody); + + var response = exchange.Response; + response.HttpVersion = HttpHeader.Version30; + response.RequestMethod = request.Method; + if (response.StreamBodyWriter == null) + { + response.IsBodyRead = true; + response.Body = exchange.Body; + // Http2OriginConnection materializes H2 DATA wire bytes. + response.BodyIsWireEncoded = true; + } + + if (exchange.TrailingHeaders != null && !response.HasTrailingHeaders) + { + foreach (var header in exchange.TrailingHeaders) + response.TrailingHeaders.AddHeader(header); + } + + sessionArgs.HttpClient.Response = response; + } + finally + { + request.HttpVersion = clientHttpVersion; + } + } + + private static async Task EnsureHttp3BufferedBodyAsync( + SessionEventArgs sessionArgs, CancellationToken cancellationToken) + { + var request = sessionArgs.HttpClient.Request; + if (request.IsBodyReceived || sessionArgs.Http3BufferedBodyReader == null) + return; + + if (request.HasBody) + { + await sessionArgs.GetRequestBody(cancellationToken); + return; + } + + _ = await sessionArgs.Http3BufferedBodyReader(cancellationToken); + sessionArgs.Http3BufferedBodyReader = null; + request.IsBodyReceived = true; + } + + private static (string Host, int Port) ResolveH2OriginAuthority(Request request) + => request.GetOriginHostPort(443); + + private static (string? ConnectHost, int? ConnectPort) ResolveTransparentForwardTarget( + SessionEventArgs sessionArgs) + { + if (sessionArgs.UpstreamConnectHost is { Length: > 0 } routedHost) + return (routedHost, sessionArgs.UpstreamConnectPort); + + if (sessionArgs.ProxyEndPoint is TransparentBaseProxyEndPoint transparent + && !string.IsNullOrEmpty(transparent.ForwardHost)) + return (transparent.ForwardHost, transparent.ForwardPort); + + return (null, null); + } + + private static Func? CreateInterimResponseAdapter( + Func? onInterimResponse) + { + if (onInterimResponse == null) + return null; + + return async (status, headers, ct) => + { + var interim = new Response + { + HttpVersion = HttpHeader.Version30, + StatusCode = status, + IsBodyRead = true, + Body = Array.Empty() + }; + foreach (var header in headers) + interim.Headers.AddHeader(header); + await onInterimResponse(interim, ct); + }; + } + + private static async Task SendHttp2OriginWithGoAwayRetryAsync( + ProxyServer server, ILogger logger, SessionEventArgs sessionArgs, + Http2OriginTarget target, + Func? on1xx, + CancellationToken cancellationToken, + Func, CancellationToken, ValueTask>, CancellationToken, Task>? copyRequestBody = + null) + { + Http2OriginConnection? h2 = null; + try + { + h2 = await LeaseHttp2OriginAsync(server, logger, sessionArgs, target, cancellationToken); + sessionArgs.HttpClient.BindUpstreamConnection(h2.ServerConnection); + return await h2.SendAsync(sessionArgs.HttpClient.Request, on1xx, cancellationToken, + copyRequestBody); + } + catch (Exception ex) when (ex is Http2OriginGoAwayException + || (ex is IOException && h2 is { IsUsable: false })) + { + // Stop new leases on this member; do not Dispose — siblings below last-stream-id + // must finish. Retry once on another pooled connection when the body is replayable. + // H3 GET still has a pump delegate even after a zero-DATA FIN, so do not treat + // "copyRequestBody != null" as "body was consumed and cannot be replayed". + if (h2 != null) + server.Http2OriginConnectionPool.Invalidate(target.PoolKey, h2); + + if (!CanReplayHttp2OriginRequest(sessionArgs.HttpClient.Request, copyRequestBody)) + throw; + + h2 = await LeaseHttp2OriginAsync(server, logger, sessionArgs, target, cancellationToken); + sessionArgs.HttpClient.BindUpstreamConnection(h2.ServerConnection); + return await h2.SendAsync(sessionArgs.HttpClient.Request, on1xx, cancellationToken); + } + } + + private static bool CanReplayHttp2OriginRequest(Request request, + Func, CancellationToken, ValueTask>, CancellationToken, Task>? copyRequestBody) => + copyRequestBody == null + || request.IsBodyRead + || request.IsBodyReceived + || !request.HasBody; + + private readonly record struct Http2OriginTarget( + string Host, int Port, string? ConnectHost, int? ConnectPort, string PoolKey); + + private static async Task LeaseHttp2OriginAsync( // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + ProxyServer server, ILogger logger, SessionEventArgs sessionArgs, + Http2OriginTarget target, CancellationToken cancellationToken) + { + return await server.Http2OriginConnectionPool.RentAsync(target.PoolKey, async ct => + { + var originIsHttps = sessionArgs.ProxyEndPoint is not TransparentBaseProxyEndPoint { ForwardCleartext: true }; + var upStreamProxy = sessionArgs.CustomUpStreamProxyUsed + ?? (originIsHttps ? server.UpStreamHttpsProxy : server.UpStreamHttpProxy); + + var tcp = await server.TcpConnectionFactory.GetServerConnection( + server, target.Host, target.Port, HttpHeader.Version20, originIsHttps, + originIsHttps ? SslExtensions.Http2ProtocolAsList : null, + false, sessionArgs, sessionArgs.HttpClient.UpStreamEndPoint ?? server.UpStreamEndPoint, + upStreamProxy, + true, false, ct, target.ConnectHost, target.ConnectPort); + + if (tcp != null && !originIsHttps) + tcp.Http2Cleartext = true; + + if (tcp == null || + (originIsHttps + ? tcp.NegotiatedApplicationProtocol != SslApplicationProtocol.Http2 + : !tcp.Http2Cleartext)) + { + if (tcp != null) + await server.TcpConnectionFactory.Release(tcp, true); + var how = originIsHttps ? "did not negotiate HTTP/2 via ALPN" : "did not accept cleartext HTTP/2 (h2c)"; + throw new ProxyHttpException( + $"The origin '{target.Host}:{target.Port}' {how} for the H3→H2 bridge.", + null, sessionArgs); + } + + return await Http2OriginConnection.CreateAsync(tcp, logger, + sessionArgs.MaxBufferedBodyBytes ?? server.MaxBufferedBodyBytes, ct, + server.ResourceLimits); + }, cancellationToken); + } + +} diff --git a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Quic.cs b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Quic.cs new file mode 100644 index 000000000..67d0d7028 --- /dev/null +++ b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Quic.cs @@ -0,0 +1,901 @@ +#pragma warning disable CA1416 +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Quic; +using System.Net.Security; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Exceptions; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Http.Responses; +using Titanium.Web.Proxy.Http2; +using Titanium.Web.Proxy.Http3.Qpack; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network; +using Titanium.Web.Proxy.Network.Quic; +using Titanium.Web.Proxy.Network.Tcp; +using Titanium.Web.Proxy.Options; +using Titanium.Web.Proxy.StreamExtended.Network; + +namespace Titanium.Web.Proxy.Http3; + +/// +/// Handles forwarding an already-decoded inbound HTTP/3 request to the origin server, implementing +/// all necessary protocol bridges: +/// +/// H3→H3: QUIC origin via . +/// H3→H2: TCP origin via Http2OriginConnection. +/// H3→H1.1: TCP origin via the normal HTTP/1.1 server pipeline. +/// +/// Protocol selection is delegated entirely to ; +/// callers that have a pre-resolved should use the route-based +/// overload to avoid redundant cache/DNS lookups. +/// +internal static partial class Http3OriginBridge +{ + // H3 → H3 (QUIC) + // ──────────────────────────────────────────────────────────────────────────────────────── + + /// + /// Sends the request to the origin over QUIC. + /// + /// + /// The DNS name or IP used for the QUIC UDP socket. May be a SVCB TargetName distinct from + /// the origin authority. + /// + /// + /// The TLS SNI hostname and HTTP/3 :authority value — always the origin authority. + /// + /// The QUIC port (may be an alternative port from Alt-Svc or SVCB). + /// + /// When , QUIC failures are terminal (return 502); no TCP fallback. + /// When (Auto policy), evict the stale cache entry and fall back to TCP. + /// + private static async Task ForwardOverQuicAsync( // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + SessionEventArgs sessionArgs, + ProxyServer server, + string connectHost, + string sniHost, + int port, + bool isForcedH3, + ILogger logger, + CancellationToken cancellationToken, + Func? onInterimResponse = null, + Func? copyRequestBody = null) + { + var request = sessionArgs.HttpClient.Request; + var upStreamEndPoint = sessionArgs.HttpClient.UpStreamEndPoint ?? server.UpStreamEndPoint; + + // Mirror TcpConnectionFactory proxy-resolution logic. + var upstreamProxy = sessionArgs.CustomUpStreamProxy; + if (upstreamProxy == null && server.GetCustomUpStreamProxyFunc != null) + upstreamProxy = await server.GetCustomUpStreamProxyFunc(sessionArgs); + + // Set BOTH fields so the TCP fallback path does not re-invoke GetCustomUpStreamProxyFunc. + sessionArgs.CustomUpStreamProxy = upstreamProxy; + sessionArgs.CustomUpStreamProxyUsed = upstreamProxy; + upstreamProxy ??= server.UpStreamHttpsProxy; + + QuicServerConnection? quicConn = null; + // When true, StreamBodyWriter owns originStream + quicConn release (do not dispose/release here). + var streamHandedOff = false; + // A pooled connection can go stale between requests: MsQuic's own (server-negotiated) idle + // timeout is often shorter than QuicConnectionPool's bookkeeping window, and a silently + // dead connection isn't reflected by QuicServerConnection.IsClosed until it's actually used. + // If OpenRequestStreamAsync/write fails on a *reused* connection before anything has been + // sent to the client, retrying with another connection is safe and avoids needlessly evicting + // the H3 capability (and downgrading the origin to TCP) over a stale pooled connection. + // Several retries may be needed: QuicConnectionPool can hand out more than one *different* + // pooled connection before it is forced to fall through to a guaranteed-fresh one, and if a + // whole browsing-idle gap elapsed, all of them may have gone stale together. + var reused = false; + var staleConnectionRetries = 0; + var requestSent = false; + + try + { + while (true) + { + QuicStream? originStream = null; + try + { + // Pass the session so ServerCertificateValidationCallback is honoured. The factory's + // default path supplies sessionArgs: null, which skips the user callback and rejects + // any chain that is not already trusted by the OS (breaking MITM-test and custom-CA + // deployments for every H3→H3 origin connect). + quicConn = await server.QuicConnectionPool.GetOrCreateAsync( + connectHost, port, upStreamEndPoint, upstreamProxy, + (sender, certificate, chain, errors) => + server.ValidateServerCertificate(sender, sessionArgs, certificate, chain, errors), + cancellationToken, + sniHost: sniHost, + failFastHandshake: !isForcedH3); + + reused = !quicConn.ClaimFirstUse(); + sessionArgs.Timing?.MarkConnectionReady(quicConn.Id, reused); + // Multiplexed QUIC origin: bind metadata without SetConnection (TCP-only ownership API). + // SetConnection on TCP fallback overwrites it if QUIC fails later in the loop. + sessionArgs.HttpClient.BindUpstreamConnection(quicConn); + + originStream = await quicConn.OpenRequestStreamAsync(cancellationToken); + + // Do not start reading client DATA until the origin stream is open (stale-pool retry). + Func? pendingCopy = null; + byte[]? body = null; + if (copyRequestBody != null && !request.IsBodyRead && !request.BodyAvailable) + { + pendingCopy = copyRequestBody; + } + else + { + // GetRequestBody() leaves plain bytes (EnsurePlainBodyAsync); CompressBody respects + // BodyIsWireEncoded so eager wire buffers are not double-compressed. + body = request.HasBody || request.BodyAvailable + ? request.CompressBodyAndUpdateContentLength() + : null; + } + + // Use the origin authority (sniHost) for the :authority pseudo-header, not the connect host. + var encodedHeaders = EncodeOriginRequestHeaders(quicConn, request, sniHost); + var finOnHeaders = pendingCopy == null && body is not { Length: > 0 }; + await Http3Frame.WriteAsync(originStream, Http3FrameType.Headers, encodedHeaders, + cancellationToken, completeWrites: finOnHeaders); + // HEADERS are on the wire — client DATA may be consumed next; retry is no longer safe. + requestSent = true; + + if (pendingCopy != null) + { + await pendingCopy(originStream, cancellationToken); + } + else if (body is { Length: > 0 }) + { + await Http3Frame.WriteAsync(originStream, Http3FrameType.Data, body, cancellationToken, + completeWrites: true); + } + + // QuicStream WriteAsync may buffer; without Flush the peer can see the request hundreds of + // ms late (observed ~450ms Cloudflare HTML TTFB with inFlight=1 after request "sent"). + // Always Flush on all OS (Darwin skip-Flush A/B regressed Mac÷YARP). When HEADERS/DATA + // already packed STREAM+FIN via completeWrites:true, CompleteWrites is a no-op / throw — + // only finish the write side if it is still open (body copy path). + await originStream.FlushAsync(cancellationToken); + if (originStream.CanWrite) + originStream.CompleteWrites(); + sessionArgs.Timing?.MarkRequestSent(); + + const int maxInterimResponses = 20; + int interimCount = 0; + + Http3Frame? responseHeadersFrame; + List<(string Name, string Value)> decodedResponseHeaders; + int finalStatus; + + while (true) + { + responseHeadersFrame = await Http3Frame.ReadAsync(originStream, + maxPayloadBytes: server.MaxDecodedHeaderListBytes, cancellationToken); + + if (responseHeadersFrame == null) + throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, + "Expected HEADERS frame as first frame on origin response stream."); + + // RFC 9114 §9: ignore unknown/GREASE frames. DATA before HEADERS is a protocol error. + if (responseHeadersFrame.Type != Http3FrameType.Headers) + { + if (responseHeadersFrame.Type == Http3FrameType.Data) + throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, + "DATA frame received before response HEADERS."); + if (IsForbiddenOnRequestStream(responseHeadersFrame.Type)) + throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, + $"Frame type 0x{responseHeadersFrame.Type:X} not permitted on request stream."); + continue; // GREASE / unknown / PRIORITY_UPDATE etc. + } + + decodedResponseHeaders = QpackDecoder.Decode(responseHeadersFrame.Payload.Span); + finalStatus = ParseStatusCode(decodedResponseHeaders); + + if (finalStatus is >= 100 and < 200) + { + if (++interimCount > maxInterimResponses) + throw new Http3StreamException(Http3ErrorCode.InternalError, + $"Origin sent more than {maxInterimResponses} interim responses."); + + if (onInterimResponse != null) + { + var interim = BuildResponseFromHeaders(decodedResponseHeaders, HttpHeader.Version30); + await onInterimResponse(interim, cancellationToken); + } + continue; + } + + break; + } + + sessionArgs.Timing?.MarkResponseHeadersReceived(); + + var response = BuildResponseFromHeaders(decodedResponseHeaders, HttpHeader.Version30); + response.RequestMethod = request.Method; + + // Cache Alt-Svc from response headers immediately (no need to wait for the body). + var altSvc = response.Headers.GetHeaderValueOrNull("Alt-Svc"); + if (!string.IsNullOrEmpty(altSvc)) + { + var entries = AltSvcParser.Parse(altSvc); + if (entries.Count > 0 && entries[0].MaxAgeSeconds > 0) + { + var originPort = request.GetOriginHostPort(port).Port; + var ttlSeconds = Math.Min(entries[0].MaxAgeSeconds, Http3OriginCapabilityCache.DefaultTtl.TotalSeconds * 2); + var ttl = TimeSpan.FromSeconds(ttlSeconds); + server.Http3OriginCapabilityCache.Set($"{sniHost}:{originPort}", + entries[0].Port == originPort ? int.MinValue : entries[0].Port, ttl); + } + } + + var maxPayload = sessionArgs.MaxBufferedBodyBytes ?? server.MaxBufferedBodyBytes; + + // Stream large / unknown-length bodies as DATA arrives (TTFB on big HTML). Tiny known-CL + // must materialize first: H1 WriteResponseAsync + StreamBodyWriter emits a header-only + // TLS record then body (lossy H1 dig / compare-bridges H1→H3). Same ≤64 KiB budget as + // H1 terminate coalesce and H3→H1 ForwardOverTcpFastAsync. + if (!response.HasBody) + { + response.IsBodyRead = true; + sessionArgs.HttpClient.Response = response; + await originStream.DisposeAsync(); + originStream = null; + break; + } + + // H1 clients need chunked framing when Content-Length is absent; H2/H3 strip TE later. + if (response.ContentLength < 0 && !response.IsChunked) + response.Headers.AddHeader(KnownHeaders.TransferEncoding, KnownHeaders.TransferEncodingChunked); + + if (originStream is null || quicConn is null) + throw new InvalidOperationException("HTTP/3 origin stream or connection missing after response headers."); + + const int eagerBodyThreshold = 64 * 1024; + if (!response.IsChunked + && response.ContentLength >= 0 + && response.ContentLength <= eagerBodyThreshold + && !server.HasOnResponseBodyWriteSubscribers) + { + var bodyBytes = response.ContentLength == 0 + ? Array.Empty() + : new byte[response.ContentLength]; + var offset = 0; + while (offset < bodyBytes.Length) + { + var frame = await Http3Frame.ReadAsync(originStream, maxPayloadBytes: maxPayload, + cancellationToken); + if (frame == null) + break; + try + { + if (frame.Type == Http3FrameType.Headers) + break; // trailers + if (frame.Type != Http3FrameType.Data || frame.Payload.Length == 0) + continue; + var toCopy = Math.Min(frame.Payload.Length, bodyBytes.Length - offset); + frame.Payload.Span[..toCopy].CopyTo(bodyBytes.AsSpan(offset)); + offset += toCopy; + } + finally + { + frame.ReturnPayload(); + } + } + + // Drain to FIN so Dispose does not RST a live H3 request stream (pool poison → + // handshake-per-request under load; cool H1→H3 fell ~1.16× → ~0.7×). + while (true) + { + var frame = await Http3Frame.ReadAsync(originStream, maxPayloadBytes: maxPayload, + cancellationToken); + if (frame == null) + break; + frame.ReturnPayload(); + } + + if (offset != bodyBytes.Length) + Array.Resize(ref bodyBytes, offset); + + response.Body = bodyBytes; + response.BodyIsWireEncoded = true; + response.IsBodyRead = true; + response.ContentLength = bodyBytes.Length; + response.Headers.RemoveHeader(KnownHeaders.TransferEncoding); + sessionArgs.HttpClient.Response = response; + await originStream.DisposeAsync(); + originStream = null; + break; + } + + QuicStream streamToClient = originStream; + QuicServerConnection connToRelease = quicConn; + originStream = null; + quicConn = null; + streamHandedOff = true; + + var hasBodyWriteHook = server.HasOnResponseBodyWriteSubscribers; + + response.StreamBodyWriter = async (clientBodyStream, ct) => + { + try + { + if (!hasBodyWriteHook) + { + while (true) + { + var frame = await Http3Frame.ReadAsync(streamToClient, maxPayloadBytes: maxPayload, ct); + if (frame == null) break; + try + { + if (frame.Type == Http3FrameType.Headers) + break; // trailers — ignored for now + if (frame.Type != Http3FrameType.Data || frame.Payload.Length == 0) + continue; + + await clientBodyStream.WriteAsync(frame.Payload, ct); + } + finally + { + frame.ReturnPayload(); + } + } + } + else + { + var current = await Http3Frame.ReadAsync(streamToClient, maxPayloadBytes: maxPayload, ct); + while (current != null) + { + var next = await Http3Frame.ReadAsync(streamToClient, maxPayloadBytes: maxPayload, ct); + var isLast = next == null || next.Type == Http3FrameType.Headers; + + try + { + if (current.Type == Http3FrameType.Data) + { + var hookArgs = new BeforeBodyWriteEventArgs( + sessionArgs, current.Payload.ToArray(), isChunked: true, isLastChunk: isLast); + await server.OnBeforeResponseBodyWrite(hookArgs); + + if (hookArgs.BodyBytes is { Length: > 0 }) + await clientBodyStream.WriteAsync(hookArgs.BodyBytes, ct); + + if (hookArgs.IsLastChunk && next is { } toRelease + && toRelease.Type != Http3FrameType.Headers) + { + streamToClient.Abort(QuicAbortDirection.Read, (long)Http3ErrorCode.RequestCancelled); + toRelease.ReturnPayload(); + break; + } + } + } + finally + { + current.ReturnPayload(); + } + + current = next; + } + } + } + finally + { + try { await streamToClient.DisposeAsync(); } catch { /* best effort */ } + try { await QuicConnectionPool.ReleaseAsync(connToRelease); } catch { /* best effort */ } + } + }; + + sessionArgs.HttpClient.Response = response; + break; // success — exit the retry loop; body drains when the client emit path runs StreamBodyWriter + } + catch (QuicProxyNotSupportedException ex) + { + // System.Net.Quic cannot route via a proxy. + // For Auto policy: fall back to TCP so proxy rules are honoured. + // For forced H3: a proxy was explicitly configured but cannot carry QUIC — return 502. + if (logger.IsEnabled(LogLevel.Debug)) + logger.LogDebug(ex, + "QUIC cannot route via proxy; {Behavior} for {Host}:{Port}", + isForcedH3 ? "returning 502 (forced H3)" : "falling back to TCP", + sniHost, port); + + quicConn = null; // GetOrCreateAsync threw before creating a connection + + if (!isForcedH3) + { + try + { + await ForwardOverTcpAsync(sessionArgs, server, cancellationToken, onInterimResponse); + } + catch (Exception tcpEx) when (tcpEx is not OperationCanceledException) + { + sessionArgs.HttpClient.Response = MakeBadGatewayResponse(tcpEx.Message); + } + + return; + } + + sessionArgs.HttpClient.Response = MakeBadGatewayResponse("QUIC cannot be routed via the configured upstream proxy (forced Http3)."); + return; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + if (logger.IsEnabled(LogLevel.Debug)) + logger.LogDebug(ex, "H3→H3 origin forwarding failed for {Host}:{Port}", sniHost, port); + + if (originStream != null) + { + try { await originStream.DisposeAsync(); } catch { /* best effort */ } + } + + if (quicConn != null) + { + // Any exception while using the request stream makes the connection suspect. + // In particular, a peer-closed connection is not reflected by + // QuicServerConnection.IsClosed, which only tracks local disposal state. + // Leaving it shared causes every later request to retry the same dead QUIC + // connection and produces intermittent 502s after an otherwise healthy H3 run. + // Invalidate rather than dispose: other requests may still be streaming over this + // connection, and they get to finish even though no new request will join them. + await server.QuicConnectionPool.InvalidateAsync(quicConn); + quicConn = null; + } + + // The failure happened while acquiring/opening the stream on a *pooled* connection and + // nothing was written to the origin yet (see requestSent) — most likely the connection + // silently went idle-dead between requests (MsQuic's idle timeout tends to be shorter than + // QuicConnectionPool's bookkeeping window; see QuicServerConnection.IsClosed remarks). + // A single retry with a freshly created connection is safe (no request bytes were sent) + // and avoids evicting the H3 capability / downgrading the origin to TCP for what is really + // just a stale pooled connection, not a genuine H3 unreachability. + if (reused && !requestSent && staleConnectionRetries < QuicConnectionPool.MaxStaleConnectionRetries) + { + staleConnectionRetries++; + if (logger.IsEnabled(LogLevel.Debug)) + logger.LogDebug( + "Pooled QUIC connection to {Host}:{Port} was stale ({ExceptionType}); retrying (attempt {Attempt}/{Max}).", + sniHost, port, ex.GetType().Name, staleConnectionRetries, QuicConnectionPool.MaxStaleConnectionRetries); + continue; + } + + if (!isForcedH3) + { + // Auto policy: the cached H3 capability is stale or unusable — evict and fall back to TCP. + // Evict by origin identity (request URI port), not the QUIC connect port, which may + // differ when Alt-Svc / SVCB advertised an alternative port. + var originPort = request.GetOriginHostPort(port).Port; + var hostAndPort = $"{sniHost}:{originPort}"; + server.Http3OriginCapabilityCache.Evict(hostAndPort); + if (logger.IsEnabled(LogLevel.Debug)) + logger.LogDebug("Evicted stale H3 capability for {HostAndPort}; falling back to TCP.", hostAndPort); + try + { + await ForwardOverTcpAsync(sessionArgs, server, cancellationToken, onInterimResponse); + } + catch (Exception tcpEx) when (tcpEx is not OperationCanceledException) + { + if (logger.IsEnabled(LogLevel.Debug)) + logger.LogDebug(tcpEx, "TCP fallback after H3 failure also failed for {Host}:{Port}", + sniHost, originPort); + sessionArgs.HttpClient.Response = MakeBadGatewayResponse( + $"QUIC failed: {ex.Message}; TCP fallback failed: {tcpEx.Message}"); + } + + return; + } + + // Forced H3: surface as a 502 — never fall back silently. + sessionArgs.HttpClient.Response = MakeBadGatewayResponse(ex.Message); + return; + } + } // end retry loop + } + finally + { + // When StreamBodyWriter owns the stream/connection, it releases on completion. + // Otherwise give up this request's stream so idle eviction is not blocked forever. + if (!streamHandedOff && quicConn != null) + await QuicConnectionPool.ReleaseAsync(quicConn); + } + } + + // ──────────────────────────────────────────────────────────────────────────────────────── + // H3 → H2 (TLS ALPN h2, or cleartext h2c when ForwardCleartext) + // ──────────────────────────────────────────────────────────────────────────────────────── + + /// + /// H3 → H2 via . Uses TLS ALPN h2 unless the + /// transparent endpoint has , + /// in which case the origin is cleartext HTTP/2 prior-knowledge (h2c). + /// + /// + /// Session-less H3→H2 forward for the interception-off bodiless path. + /// is invoked only when the shared H2 origin pool + /// must open a new TCP+H2 session (warm-pool RPS never hits it). + /// + internal static async Task ForwardOverQuicFastAsync( // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + H3H2FastForward fwd, + ProxyServer server, + ILogger logger, + CancellationToken cancellationToken, + Func coldOpenSessionFactory, + QuicStream? clientStream) + { + var request = fwd.Request; + var sniHost = fwd.OriginAuthorityHost ?? "localhost"; + var colon = sniHost.LastIndexOf(':'); + if (colon > 0 && int.TryParse(sniHost.AsSpan(colon + 1), out _)) + sniHost = sniHost[..colon]; + + string connectHost = sniHost; + var port = request.IsHttps ? 443 : 80; + if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint + { + ForwardHost: { Length: > 0 } forwardHost, + ForwardPort: { } forwardPort + }) + { + connectHost = forwardHost; + port = forwardPort; + } + else if (request.Authority.Length > 0) + { + var authority = request.Authority.GetString(); + var idx = authority.LastIndexOf(':'); + if (idx > 0 && int.TryParse(authority.AsSpan(idx + 1), out var parsedPort)) + { + connectHost = authority[..idx]; + port = parsedPort; + sniHost = connectHost; + } + else + { + connectHost = authority; + sniHost = authority; + } + } + + if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint { ForwardCleartext: true }) + request.IsHttps = false; + + var upStreamEndPoint = fwd.UpStreamEndPoint ?? server.UpStreamEndPoint; + var upstreamProxy = fwd.CustomUpStreamProxy ?? server.UpStreamHttpsProxy; + + QuicServerConnection? quicConn = null; + var reused = false; + var staleConnectionRetries = 0; + var requestSent = false; + SessionEventArgs? certSession = null; + + try + { + while (true) + { + QuicStream? originStream = null; + try + { + quicConn = await server.QuicConnectionPool.GetOrCreateAsync( + connectHost, port, upStreamEndPoint, upstreamProxy, + (sender, certificate, chain, errors) => + { + certSession ??= coldOpenSessionFactory(); + return server.ValidateServerCertificate( + sender, certSession, certificate, chain, errors); + }, + cancellationToken, + sniHost: sniHost); + + reused = !quicConn.ClaimFirstUse(); + originStream = await quicConn.OpenRequestStreamAsync(cancellationToken); + + var encodedHeaders = EncodeOriginRequestHeaders(quicConn, request, sniHost); + await Http3Frame.WriteAsync(originStream, Http3FrameType.Headers, encodedHeaders, + cancellationToken, completeWrites: true); + requestSent = true; + // QuicStream WriteAsync may buffer; without Flush the peer can stall forever + // under multiplex (GHA Linux/Windows reverse H3→H3 @ c=64: 0 RPS / ~0% CPU). + // Darwin A/B skipping Flush (27d2967d) dropped Mac reverse RPS (~5k→~3–4k) and + // worsened H3÷YARP — keep Flush on all OS. completeWrites:true already FINs. + await originStream.FlushAsync(cancellationToken); + + // Verbatim origin→client frame copy, or MITM capture when clientStream is null. + // Tiny GET: coalesce HEADERS+DATA into one Quic write (origin probe sends both). + const int relayCoalesceMaxBytes = 16 * 1024; + var maxPayload = Math.Max(fwd.MaxBufferedBodyBytes, server.MaxDecodedHeaderListBytes); + var captureForMitm = clientStream is null; + var sawFinalHeaders = false; + while (true) + { + var frame = await Http3Frame.ReadAsync(originStream, maxPayloadBytes: maxPayload, + cancellationToken); + if (frame == null) + break; + try + { + if (frame.Type == Http3FrameType.Headers) + { + // Ignore interim 1xx on the fast path (probes never send them). + // Still forward/capture the first HEADERS block and any trailers. + if (!sawFinalHeaders) + { + var headersPayload = frame.Payload; + var next = await Http3Frame.ReadAsync(originStream, + maxPayloadBytes: maxPayload, cancellationToken); + if (next is { Type: Http3FrameType.Data } + && headersPayload.Length + next.Payload.Length <= relayCoalesceMaxBytes) + { + try + { + if (captureForMitm) + { + CaptureMitmQuicResponse(fwd, headersPayload, next.Payload); + } + else + { + await Http3Frame.WriteHeadersAndDataAsync(clientStream!, + headersPayload, next.Payload, cancellationToken); + } + } + finally + { + next.ReturnPayload(); + } + + sawFinalHeaders = true; + continue; + } + + if (next != null) + { + if (captureForMitm) + { + CaptureMitmQuicResponse(fwd, headersPayload, + next.Type == Http3FrameType.Data + ? next.Payload + : ReadOnlyMemory.Empty); + if (next.Type != Http3FrameType.Data + && IsForbiddenOnRequestStream(next.Type)) + throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, + $"Frame type 0x{next.Type:X} not permitted on request stream."); + } + else + { + await Http3Frame.WriteAsync(clientStream!, Http3FrameType.Headers, + headersPayload, cancellationToken); + if (next.Type == Http3FrameType.Data) + { + if (next.Payload.Length > 0) + await Http3Frame.WriteAsync(clientStream!, Http3FrameType.Data, + next.Payload, cancellationToken); + } + else if (IsForbiddenOnRequestStream(next.Type)) + throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, + $"Frame type 0x{next.Type:X} not permitted on request stream."); + } + + sawFinalHeaders = true; + next.ReturnPayload(); + continue; + } + } + + if (captureForMitm) + { + if (!sawFinalHeaders) + CaptureMitmQuicResponse(fwd, frame.Payload, ReadOnlyMemory.Empty); + // Trailers after final headers: MITM capture drops them for tiny GET + // (probe responses have no trailers). Mutating handlers fall back. + } + else + { + await Http3Frame.WriteAsync(clientStream!, Http3FrameType.Headers, + frame.Payload, cancellationToken); + } + + sawFinalHeaders = true; + continue; + } + + if (frame.Type == Http3FrameType.Data) + { + if (!sawFinalHeaders) + throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, + "DATA frame received before response HEADERS."); + if (frame.Payload.Length > 0) + { + if (captureForMitm) + AppendMitmQuicBody(fwd, frame.Payload); + else + await Http3Frame.WriteAsync(clientStream!, Http3FrameType.Data, + frame.Payload, cancellationToken); + } + + continue; + } + + if (IsForbiddenOnRequestStream(frame.Type)) + throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, + $"Frame type 0x{frame.Type:X} not permitted on request stream."); + // GREASE / unknown: drop + } + finally + { + frame.ReturnPayload(); + } + } + + if (!sawFinalHeaders) + throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, + "Expected HEADERS frame as first frame on origin response stream."); + + await originStream.DisposeAsync(); + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + if (logger.IsEnabled(LogLevel.Debug)) + logger.LogDebug(ex, "H3→H3 fast forward failed for {Host}:{Port}", connectHost, port); + + if (originStream != null) + { + try { await originStream.DisposeAsync(); } catch { /* best effort */ } + } + + if (quicConn != null) + { + await server.QuicConnectionPool.InvalidateAsync(quicConn); + quicConn = null; + } + + if (reused && !requestSent + && staleConnectionRetries < QuicConnectionPool.MaxStaleConnectionRetries) + { + staleConnectionRetries++; + requestSent = false; + continue; + } + + fwd.Response = MakeBadGatewayResponse(ex.Message); + return false; + } + } + } + finally + { + if (quicConn != null) + await QuicConnectionPool.ReleaseAsync(quicConn); + + if (certSession != null) + { + certSession.CancellationTokenSource.Dispose(); + certSession.Dispose(); + } + } + } + + /// + /// Session-lite H3→H1 forward: Request bag + stub for + /// only (no inbound H3 pumps, BeforeRequest, or Via). + /// Warm keep-alive still pools origin sockets. Does not allocate — + /// the socket is already leased; only a is needed for QPACK. + /// + private static void CaptureMitmQuicResponse(H3H2FastForward fwd, ReadOnlyMemory headersPayload, + ReadOnlyMemory bodyPayload) + { + var qpack = headersPayload.ToArray(); + fwd.PreencodedQpackHeaders = qpack; + if (fwd.Response != null) + PopulateResponseFromQpack(fwd.Response, qpack); + + if (bodyPayload.Length == 0) + { + fwd.PreencodedBody = null; + fwd.PreencodedBodyLength = 0; + fwd.PreencodedBodyRented = false; + if (fwd.Response != null) + { + fwd.Response.Body = Array.Empty(); + fwd.Response.BodyIsWireEncoded = true; + fwd.Response.IsBodyReceived = true; + fwd.Response.IsBodyRead = true; + } + + return; + } + + var body = bodyPayload.ToArray(); + fwd.PreencodedBody = body; + fwd.PreencodedBodyLength = body.Length; + fwd.PreencodedBodyRented = false; + if (fwd.Response != null) + { + var copy = new byte[body.Length]; + Buffer.BlockCopy(body, 0, copy, 0, body.Length); + fwd.Response.Body = copy; + fwd.Response.BodyIsWireEncoded = true; + fwd.Response.IsBodyReceived = true; + fwd.Response.IsBodyRead = true; + } + } + + private static void AppendMitmQuicBody(H3H2FastForward fwd, ReadOnlyMemory chunk) + { + if (chunk.Length == 0) + return; + + var existing = fwd.PreencodedBody; + var existingLen = fwd.PreencodedBodyLength > 0 + ? fwd.PreencodedBodyLength + : existing?.Length ?? 0; + var combined = new byte[existingLen + chunk.Length]; + if (existingLen > 0 && existing != null) + Buffer.BlockCopy(existing, 0, combined, 0, existingLen); + chunk.Span.CopyTo(combined.AsSpan(existingLen)); + fwd.PreencodedBody = combined; + fwd.PreencodedBodyLength = combined.Length; + fwd.PreencodedBodyRented = false; + if (fwd.Response != null) + { + var copy = new byte[combined.Length]; + Buffer.BlockCopy(combined, 0, copy, 0, combined.Length); + fwd.Response.Body = copy; + fwd.Response.BodyIsWireEncoded = true; + fwd.Response.IsBodyReceived = true; + fwd.Response.IsBodyRead = true; + } + } + + private static void PopulateResponseFromQpack(Response response, ReadOnlySpan qpack) + { + response.HttpVersion = HttpHeader.Version30; + response.Headers.Clear(); + foreach (var (name, value) in QpackDecoder.Decode(qpack)) + { + if (name == ":status" && int.TryParse(value, out var statusCode)) + response.StatusCode = statusCode; + else if (name.Length == 0 || name[0] != ':') + response.Headers.AddHeader(new HttpHeader(name, value)); + } + } + + private static int ParseStatusCode(List<(string Name, string Value)> headers) + { + foreach (var (name, value) in headers) + if (name == ":status" && int.TryParse(value, out var code)) + return code; + return 0; + } + + private static Response BuildResponseFromHeaders( + List<(string Name, string Value)> headers, Version httpVersion) + { + var response = new Response { HttpVersion = httpVersion }; + foreach (var (name, value) in headers) + { + if (name == ":status" && int.TryParse(value, out var statusCode)) + response.StatusCode = statusCode; + else if (!name.StartsWith(':')) + response.Headers.AddHeader(new HttpHeader(name, value)); + } + return response; + } + + private static Response MakeBadGatewayResponse(string detail) => new() + { + HttpVersion = HttpHeader.Version30, + StatusCode = 502, + StatusDescription = "Bad Gateway", + IsBodyRead = true, + Body = System.Text.Encoding.UTF8.GetBytes($"HTTP/3 origin forwarding error: {detail}") + }; + +} diff --git a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Tcp.cs b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Tcp.cs new file mode 100644 index 000000000..5c6a1c7a6 --- /dev/null +++ b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.Tcp.cs @@ -0,0 +1,869 @@ +#pragma warning disable CA1416 +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Quic; +using System.Net.Security; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Exceptions; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Http.Responses; +using Titanium.Web.Proxy.Http2; +using Titanium.Web.Proxy.Http3.Qpack; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network; +using Titanium.Web.Proxy.Network.Quic; +using Titanium.Web.Proxy.Network.Tcp; +using Titanium.Web.Proxy.Options; +using Titanium.Web.Proxy.StreamExtended.Network; + +namespace Titanium.Web.Proxy.Http3; + +/// +/// Handles forwarding an already-decoded inbound HTTP/3 request to the origin server, implementing +/// all necessary protocol bridges: +/// +/// H3→H3: QUIC origin via . +/// H3→H2: TCP origin via Http2OriginConnection. +/// H3→H1.1: TCP origin via the normal HTTP/1.1 server pipeline. +/// +/// Protocol selection is delegated entirely to ; +/// callers that have a pre-resolved should use the route-based +/// overload to avoid redundant cache/DNS lookups. +/// +internal static partial class Http3OriginBridge +{ + internal static async Task ForwardOverTcpFastAsync( // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + H3H2FastForward fwd, + ProxyServer server, + ILogger logger, + CancellationToken cancellationToken, + Func coldOpenSessionFactory, + QpackContext? qpackContext = null) + { + var request = fwd.Request; + request.HttpVersion = HttpHeader.Version11; + request.IsBodyReceived = true; + request.Locked = true; + if (string.IsNullOrEmpty(request.Host) && request.Authority.Length > 0) + request.Host = request.Authority.GetString(); + request.ApplyTransparentForwardCleartextHost(fwd.ProxyEndPoint); + + // Match H3→H2 / H3→H3: SNI / Host stay on client :authority (OriginAuthorityHost, + // typically "localhost"). ForwardHost is connect-only via connectHost/connectPort. + // Using ForwardHost (127.0.0.1) as SslStream.TargetHost fails name checks against a + // localhost leaf (integration TestCertificateAuthority; also macOS Network.framework). + var isHttps = request.IsHttps; + string? connectHost = null; + int? connectPort = null; + if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint ep) + { + if (ep.ForwardCleartext) + isHttps = false; + if (!string.IsNullOrEmpty(ep.ForwardHost)) + { + connectHost = ep.ForwardHost; + connectPort = ep.ForwardPort; + } + } + + string? poolKey = null; + if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint poolEp + && poolEp.CachedHttp11PoolKey != null + && poolEp.CachedHttp11PoolIsHttps == isHttps) + poolKey = poolEp.CachedHttp11PoolKey; + + TcpServerConnection? connection = null; + SessionEventArgs? openSession = null; + var closeConnection = false; + try + { + if (poolKey != null) + server.TcpConnectionFactory.TryRentPooled(server, poolKey, + SslExtensions.Http11ProtocolAsList, out connection); + + if (connection == null) + { + // Resolve SNI host/port only on pool miss — warm keep-alive hits skip GetOriginHostPort. + string host; + int port; + var sni = fwd.OriginAuthorityHost; + if (!string.IsNullOrEmpty(sni)) + { + var colon = sni.LastIndexOf(':'); + if (colon > 0 && int.TryParse(sni.AsSpan(colon + 1), out _)) + sni = sni[..colon]; + host = sni; + port = connectPort ?? (isHttps ? 443 : 80); + } + else + { + (host, port) = request.GetOriginHostPort(isHttps ? 443 : 80); + } + + openSession = coldOpenSessionFactory(); + connection = await server.TcpConnectionFactory.GetServerConnection( + server, host, port, HttpHeader.Version11, isHttps, + SslExtensions.Http11ProtocolAsList, false, openSession, + fwd.UpStreamEndPoint ?? server.UpStreamEndPoint, + fwd.CustomUpStreamProxy ?? (isHttps ? server.UpStreamHttpsProxy : server.UpStreamHttpProxy), + false, false, cancellationToken, connectHost, connectPort, + precomputedCacheKey: poolKey) + ?? throw new InvalidOperationException( + $"Failed to establish an HTTP/1.1 origin connection to '{host}:{port}'."); + + if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint store + && fwd.CustomUpStreamProxy == null + && (fwd.UpStreamEndPoint ?? server.UpStreamEndPoint) == null) + { + store.CachedHttp11PoolKey = connection.CacheKey; + store.CachedHttp11PoolIsHttps = isHttps; + } + } + + // Inline H1 exchange — skip HttpWebClient + InternalDataStore on the warm path. + request.Headers.RemoveHeader(KnownHeaders.Connection); + var headerBuilder = HeaderBuilder.Rent(); + try + { + headerBuilder.WriteRequestLine(request.Method, request.RequestUriString8, + HttpHeader.Version11); + headerBuilder.WriteHeaders(request.Headers, sendProxyAuthorization: false); + await connection.Stream.WriteHeadersAsync(headerBuilder, cancellationToken); + } + finally + { + HeaderBuilder.Return(headerBuilder); + } + + var httpStatus = await connection.Stream.ReadResponseStatus(cancellationToken); + if (httpStatus == null) + { + // Stale pooled keep-alive: no request body on this fast path → retryable. + throw new RetryableServerConnectionException( + "Server connection was closed before any response was received."); + } + + // One-pass H1 headers → QPACK (no Response/HeaderCollection) for the interception-off + // path. When fwd.Response is set (MITM unchanged-after-handlers), also seed that graph + // so BeforeResponse can inspect/mutate before Preencoded or EncodeResponse emit. + var populate = fwd.Response?.Headers; + var parsed = await H3H1QpackResponseReader.TryReadAsync( + connection.Stream, httpStatus.Value.StatusCode, qpackContext, cancellationToken, + populate); + if (parsed is null) + throw new OperationCanceledException(cancellationToken); + + var statusCode = httpStatus.Value.StatusCode; + if (fwd.Response != null) + { + fwd.Response.HttpVersion = HttpHeader.Version30; + fwd.Response.StatusCode = statusCode; + fwd.Response.StatusDescription = + GenericResponse.Get(statusCode) ?? string.Empty; + } + + var method = request.Method; + var contentLength = parsed.Value.ContentLength; + var isChunked = parsed.Value.IsChunked; + var connectionClose = parsed.Value.ConnectionClose; + var mayHaveBody = ResponseMayHaveBody(statusCode, method, contentLength, isChunked, + connectionClose); + + if (mayHaveBody) + { + if (!isChunked && contentLength >= 0 && contentLength <= 64 * 1024) + { + byte[] bodyBytes; + var bodyLength = (int)contentLength; + var rented = false; + if (contentLength == 0) + { + bodyBytes = []; + } + else if (connection.Stream.Available >= bodyLength) + { + bodyBytes = server.BufferPool.GetBuffer(bodyLength); + if (!connection.Stream.TryCopyAvailableExact(bodyBytes.AsSpan(0, bodyLength))) + { + server.BufferPool.ReturnBuffer(bodyBytes); + bodyBytes = new byte[bodyLength]; + var offset = 0; + while (offset < bodyBytes.Length) + { + var read = await connection.Stream.ReadAsync(bodyBytes.AsMemory(offset), + cancellationToken); + if (read == 0) + break; + offset += read; + } + + if (offset != bodyBytes.Length) + { + closeConnection = true; + Array.Resize(ref bodyBytes, offset); + bodyLength = offset; + } + } + else + { + rented = true; + } + } + else + { + bodyBytes = new byte[bodyLength]; + var offset = 0; + while (offset < bodyBytes.Length) + { + var read = await connection.Stream.ReadAsync(bodyBytes.AsMemory(offset), + cancellationToken); + if (read == 0) + break; + offset += read; + } + + if (offset != bodyBytes.Length) + { + closeConnection = true; + Array.Resize(ref bodyBytes, offset); + bodyLength = offset; + } + } + + fwd.PreencodedQpackHeaders = parsed.Value.QpackHeaders; + fwd.PreencodedBody = bodyBytes; + fwd.PreencodedBodyLength = bodyLength; + fwd.PreencodedBodyRented = rented; + if (fwd.Response != null) + { + // Copy for BeforeResponse; PreencodedBody remains the wire emit when unchanged. + var copy = bodyLength == 0 ? Array.Empty() : new byte[bodyLength]; + if (bodyLength > 0) + Buffer.BlockCopy(bodyBytes, 0, copy, 0, bodyLength); + fwd.Response.Body = copy; + fwd.Response.BodyIsWireEncoded = true; + fwd.Response.IsBodyReceived = true; + fwd.Response.IsBodyRead = true; + } + } + else + { + // Large / chunked / close-delimited: stream via PreencodedStreamBodyWriter. + var originConnection = connection; + var originIsChunked = isChunked; + var originContentLength = contentLength; + var trailingHeaders = new HeaderCollection(); + fwd.PreencodedQpackHeaders = parsed.Value.QpackHeaders; + fwd.PreencodedStreamBodyWriter = async (clientBodyStream, ct) => + { + IHttpStreamReader reader = originConnection.Stream; + using var limited = new LimitedStream(reader, server.BufferPool, originIsChunked, + originContentLength, trailingHeaders); + const int frameBytes = 16 * 1024; + var buffer = server.BufferPool.GetBuffer(frameBytes); + try + { + var filled = 0; + while (true) + { + var read = await limited.ReadAsync( + buffer.AsMemory(filled, frameBytes - filled), ct); + if (read == 0) + { + if (filled > 0) + await clientBodyStream.WriteAsync(buffer.AsMemory(0, filled), ct); + break; + } + + filled += read; + if (filled == frameBytes) + { + await clientBodyStream.WriteAsync(buffer.AsMemory(0, filled), ct); + filled = 0; + } + } + + await limited.Finish(); + } + finally + { + server.BufferPool.ReturnBuffer(buffer); + } + }; + if (fwd.Response != null) + fwd.Response.StreamBodyWriter = fwd.PreencodedStreamBodyWriter; + } + } + else + { + fwd.PreencodedQpackHeaders = parsed.Value.QpackHeaders; + fwd.PreencodedBody = null; + if (fwd.Response != null) + { + fwd.Response.IsBodyReceived = true; + fwd.Response.IsBodyRead = true; + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + closeConnection = true; + throw; + } + finally + { + if (connection != null) + { + // Stream body writer owns the socket until the client DATA copy finishes. + if (fwd.PreencodedStreamBodyWriter != null) + { + var owned = connection; + var shouldClose = closeConnection; + var inner = fwd.PreencodedStreamBodyWriter; + fwd.PreencodedStreamBodyWriter = async (dest, ct) => + { + var copyCompleted = false; + try + { + await inner(dest, ct); + copyCompleted = true; + } + finally + { + // Incomplete copy may leave unread CL bytes on the socket while + // HttpStream.Available is 0 (bytes already in the pump buffer). Pooling + // that connection poisons the next H3→H1 request into H3_INTERNAL_ERROR + // (GHA compare-arch slow-consumer after warmup cancel). + if (!copyCompleted + || (owned.Stream is Helpers.HttpStream residual && residual.DataAvailable)) + shouldClose = true; + await server.TcpConnectionFactory.Release(owned, shouldClose); + } + }; + } + else + { + await server.TcpConnectionFactory.Release(connection, closeConnection); + } + } + + if (openSession != null) + { + openSession.CancellationTokenSource.Dispose(); + openSession.Dispose(); + } + } + + _ = logger; + } + + private static async Task ForwardOverTcpAsync( // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + SessionEventArgs sessionArgs, + ProxyServer server, + CancellationToken cancellationToken, + Func? onInterimResponse = null) + { + var request = sessionArgs.HttpClient.Request; + + // Prefer live pump (H3 client DATA → H1 body). Fall back to full buffer only when a + // BeforeRequest handler already called GetRequestBody, or no pump is available. + var streamRequestBody = !request.IsBodyRead && sessionArgs.Http3RequestBodyPump != null; + if (!streamRequestBody && !request.IsBodyReceived && sessionArgs.Http3BufferedBodyReader != null) + { + if (request.HasBody) + { + await sessionArgs.GetRequestBody(cancellationToken); + } + else + { + // Consume FIN for bodiless requests (GET) without exposing a body. + _ = await sessionArgs.Http3BufferedBodyReader(cancellationToken); + sessionArgs.Http3BufferedBodyReader = null; + sessionArgs.Http3RequestBodyPump = null; + request.IsBodyReceived = true; + } + } + else if (!request.HasBody && !request.IsBodyReceived && sessionArgs.Http3RequestBodyPump != null) + { + // Drain client FIN with no body octets (GET) so MsQuic is not left with unread DATA. + await sessionArgs.Http3RequestBodyPump(static (_, _) => default, cancellationToken); + } + + // SendRequest uses HTTP/1.x framing. Translate H2/H3-shaped requests the same way + // Http2ToHttp11BridgeHandler does before hitting the wire. + var needsHttp11Wire = request.HttpVersion.Major >= 2; + var clientHttpVersion = request.HttpVersion; + byte[]? body = null; + if (needsHttp11Wire) + { + request.HttpVersion = HttpHeader.Version11; + if (string.IsNullOrEmpty(request.Host) && request.Authority.Length > 0) + request.Host = request.Authority.GetString(); + + var cookieHeaders = request.Headers.GetHeaders("Cookie"); + if (cookieHeaders is { Count: > 1 }) + { + var combined = string.Join("; ", cookieHeaders.Select(h => h.Value)); + request.Headers.RemoveHeader("Cookie"); + request.Headers.AddHeader("Cookie", combined); + } + + if (!streamRequestBody) + { + // GetRequestBody() leaves plain bytes; CompressBody respects BodyIsWireEncoded so + // any remaining wire buffer is not double-compressed onto the H1 origin. + body = request.BodyAvailable || request.HasBody + ? request.CompressBodyAndUpdateContentLength() + : null; + } + else if (request.ContentLength < 0 && !request.IsChunked) + { + // Unknown length over H3 → chunked on the H1 wire. + request.Headers.AddHeader(KnownHeaders.TransferEncoding, KnownHeaders.TransferEncodingChunked); + } + // else: client-declared content-length is already correct for the streamed body. + // UpdateContentLength() must NOT run here — it stamps BodyInternal?.Length ?? 0 and + // would rewrite content-length to 0 (same bug H2→H1 already documents). + } + + TcpServerConnection? connection = null; + var closeConnection = true; + try + { + var isHttps = sessionArgs.IsHttps; + if (sessionArgs.ProxyEndPoint is TransparentBaseProxyEndPoint { ForwardCleartext: true }) + isHttps = false; + + var (host, port) = request.GetOriginHostPort(isHttps ? 443 : 80); + + var (connectHost, connectPort) = ResolveTransparentForwardTarget(sessionArgs); + + // Shared pool under multiplexed H3 fan-out — same as H2→H1 (noCache caused port storms). + string? poolKey = null; + if (sessionArgs.ProxyEndPoint is TransparentBaseProxyEndPoint poolEp + && poolEp.CachedHttp11PoolKey != null + && poolEp.CachedHttp11PoolIsHttps == isHttps) + { + poolKey = poolEp.CachedHttp11PoolKey; + } + + // Phase 3: when streaming an upload, start reading client DATA into a channel in + // parallel with the origin TCP/TLS connect so MsQuic is not stalled on a full window. + Channel>? earlyBodyChannel = null; + Task? earlyBodyPump = null; + if (streamRequestBody && request.HasBody && sessionArgs.Http3RequestBodyPump != null) + { + earlyBodyChannel = Channel.CreateBounded>( + new BoundedChannelOptions(256) + { + SingleReader = true, + SingleWriter = true, + FullMode = BoundedChannelFullMode.Wait + }); + var pump = sessionArgs.Http3RequestBodyPump; + var writer = earlyBodyChannel.Writer; + earlyBodyPump = pump( + async (data, ct) => + { + if (data.IsEmpty) + return; + // Copy before enqueue: StreamRequestBodyToWriteAsync returns the frame's + // ArrayPool buffer after writeData completes — Channel.WriteAsync only + // queues the Memory, so returning early would corrupt the upload. + var owned = data.ToArray(); + await writer.WriteAsync(owned, ct); + }, + cancellationToken).ContinueWith(t => + { + writer.TryComplete(t.Exception?.GetBaseException()); + }, TaskScheduler.Default); + } + + try + { + connection = await server.TcpConnectionFactory.GetServerConnection( + server, host, port, HttpHeader.Version11, isHttps, SslExtensions.Http11ProtocolAsList, + false, sessionArgs, sessionArgs.HttpClient.UpStreamEndPoint ?? server.UpStreamEndPoint, + sessionArgs.CustomUpStreamProxyUsed ?? (isHttps ? server.UpStreamHttpsProxy : server.UpStreamHttpProxy), + false, false, cancellationToken, connectHost, connectPort, + precomputedCacheKey: poolKey); + } + catch + { + // Connect failed: stop the early pump so MsQuic is not left with unread DATA. + earlyBodyChannel?.Writer.TryComplete(); + if (earlyBodyPump != null) + { + try { await earlyBodyPump; } + catch { /* best effort */ } + } + + throw; + } + + if (poolKey == null + && sessionArgs.ProxyEndPoint is TransparentBaseProxyEndPoint storePoolEp + && sessionArgs.CustomUpStreamProxyUsed == null + && (sessionArgs.HttpClient.UpStreamEndPoint ?? server.UpStreamEndPoint) == null) + { + storePoolEp.CachedHttp11PoolKey = connection!.CacheKey; + storePoolEp.CachedHttp11PoolIsHttps = isHttps; + } + + sessionArgs.HttpClient.SetConnection(connection + ?? throw new InvalidOperationException( + $"Failed to establish an HTTP/1.1 origin connection to '{host}:{port}'.")); + sessionArgs.HttpClient.Request.ApplyTransparentForwardCleartextHost(sessionArgs.ProxyEndPoint); + await sessionArgs.HttpClient.SendRequest( + server.Enable100ContinueBehaviour, sessionArgs.IsTransparent, + sessionArgs.OriginHttpVersionPolicy ?? server.OriginHttpVersionPolicy, cancellationToken); + + // Streamed uploads: start the origin body write in parallel with ReceiveResponse so an + // early-responding origin (compare-arch) can push response headers/body while the + // remaining request bytes are still in flight — same duplex shape as YARP StreamCopier. + // Buffered bodies stay half-duplex (write then read). + Task? uploadTask = null; + if (needsHttp11Wire && request.HasBody && !request.ExpectationFailed) + { + if (streamRequestBody) + { + var bodyWriter = new Helpers.BodyStreamWriter(connection.Stream, request.IsChunked); + var earlyChannel = earlyBodyChannel; + var earlyPump = earlyBodyPump; + var bodyPump = sessionArgs.Http3RequestBodyPump; + var trailing = request.HasTrailingHeaders ? request.TrailingHeaders : null; + uploadTask = PumpUploadAsync(); + + async Task PumpUploadAsync() + { + try + { + if (earlyChannel != null) + { + await foreach (var chunk in earlyChannel.Reader.ReadAllAsync(cancellationToken)) + { + if (!chunk.IsEmpty) + await bodyWriter.WriteAsync(chunk, cancellationToken); + } + + if (earlyPump != null) + await earlyPump; + } + else if (bodyPump != null) + { + await bodyPump( + async (data, ct) => + { + if (!data.IsEmpty) + await bodyWriter.WriteAsync(data, ct); + }, + cancellationToken); + } + + await bodyWriter.CompleteAsync(trailing, cancellationToken); + } + catch (Exception ex) + { + earlyChannel?.Writer.TryComplete(ex); + throw; + } + } + } + else + { + await connection.Stream.WriteBodyAsync(body ?? Array.Empty(), request.IsChunked, + request.HasTrailingHeaders ? request.TrailingHeaders : null, cancellationToken); + } + } + + try + { + await sessionArgs.HttpClient.ReceiveResponse(cancellationToken); + + while (sessionArgs.HttpClient.Response.StatusCode is >= 100 and < 200) + { + if (onInterimResponse != null) + await onInterimResponse(sessionArgs.HttpClient.Response, cancellationToken); + + await sessionArgs.ClearResponse(cancellationToken); + await sessionArgs.HttpClient.ReceiveResponse(cancellationToken); + } + } + catch + { + if (uploadTask != null) + { + try { await uploadTask; } + catch { /* surface ReceiveResponse failure */ } + } + + throw; + } + + var response = sessionArgs.HttpClient.Response; + // Stream the response unless a handler already buffered it. H3 client emit path + // (SendResponseAsync) honours StreamBodyWriter the same way H2 EmitSynthetic does. + // Eager-buffer known-CL bodies up to min(64 KiB, MaxBufferedBodyBytes); larger stream + // (matches H2→H1 / ForwardOverTcpFastAsync and compare-bodies GET size). + var eagerBodyThreshold = Math.Min(64 * 1024, + Math.Max(0, sessionArgs.MaxBufferedBodyBytes ?? server.MaxBufferedBodyBytes)); + if (response.HasBody && !response.IsBodyRead + && !response.IsChunked + && response.ContentLength >= 0 + && response.ContentLength <= eagerBodyThreshold) + { + // Finish upload before draining a buffered response body (same socket). + if (uploadTask != null) + await uploadTask; + + byte[] bodyBytes; + if (response.ContentLength == 0) + { + bodyBytes = Array.Empty(); + } + else + { + // Read CL bytes directly — avoids LimitedStream wrapper for small known-CL bodies. + bodyBytes = new byte[response.ContentLength]; + var offset = 0; + while (offset < bodyBytes.Length) + { + var read = await connection.Stream.ReadAsync( + bodyBytes.AsMemory(offset), cancellationToken); + if (read == 0) + break; + offset += read; + } + + if (offset != bodyBytes.Length) + { + closeConnection = true; + Array.Resize(ref bodyBytes, offset); + } + } + + response.Body = bodyBytes; + response.BodyIsWireEncoded = true; + response.IsBodyRead = true; + response.ContentLength = bodyBytes.Length; + response.Headers.RemoveHeader(KnownHeaders.TransferEncoding); + response.StreamBodyWriter = null; + } + else if (response.HasBody && !response.IsBodyRead) + { + var originConnection = connection; + var originIsChunked = response.IsChunked; + var originContentLength = response.ContentLength; + var pendingUpload = uploadTask; + if (response.ContentLength < 0 && !response.IsChunked) + response.Headers.AddHeader(KnownHeaders.TransferEncoding, KnownHeaders.TransferEncodingChunked); + + response.StreamBodyWriter = async (clientBodyStream, ct) => + { + async Task CopyResponseAsync() + { + IHttpStreamReader reader = originConnection.Stream; + using var limited = new LimitedStream(reader, server.BufferPool, originIsChunked, + originContentLength, response.TrailingHeaders); + var buffer = server.BufferPool.GetBuffer(); + try + { + int read; + while ((read = await limited.ReadAsync(buffer.AsMemory(), ct)) > 0) + await clientBodyStream.WriteAsync(buffer.AsMemory(0, read), ct); + await limited.Finish(); + } + finally + { + server.BufferPool.ReturnBuffer(buffer); + } + } + + // Keep request upload live while copying the response (true duplex). + var copyTask = CopyResponseAsync(); + if (pendingUpload != null) + await Task.WhenAll(pendingUpload, copyTask); + else + await copyTask; + }; + } + else if (uploadTask != null) + { + await uploadTask; + } + + closeConnection = !response.KeepAlive; + + // Do not probe residual bytes while a stream body writer still owns the origin socket — + // buffered DATA after headers would look like leftover framing and force-close keep-alive + // under multiplexed POST. Probe only after the body drain (eager path below, or the + // stream-body wrapper in finally). + if (sessionArgs.HttpClient.Response.StreamBodyWriter == null + && connection?.Stream is Helpers.HttpStream httpStream && httpStream.DataAvailable) + closeConnection = true; + } + finally + { + // FinishSession only nulls the HttpClient reference. Without Release, every H3→H1 + // GET paid a new origin TLS handshake (Windows ~300 ms / tens of RPS). + // When StreamBodyWriter owns the body copy, delay release until after the client emit + // path finishes — mark closeConnection so keep-alive is not reused with unread bytes. + if (connection != null) + { + if (sessionArgs.HttpClient.Response.StreamBodyWriter != null && + !sessionArgs.HttpClient.Response.IsBodyRead) + { + // Hand off: StreamBodyWriter will finish the socket read; release after copy + // by wrapping the writer. + var owned = connection; + var shouldClose = closeConnection; + var inner = sessionArgs.HttpClient.Response.StreamBodyWriter; + sessionArgs.HttpClient.Response.StreamBodyWriter = async (dest, ct) => + { + var copyCompleted = false; + try + { + await inner(dest, ct); + copyCompleted = true; + } + finally + { + // Incomplete copy may leave unread CL bytes on the socket while + // HttpStream.Available is 0 (bytes already in the pump buffer). Never pool. + if (!copyCompleted + || (owned.Stream is Helpers.HttpStream residual && residual.DataAvailable)) + shouldClose = true; + await server.TcpConnectionFactory.Release(owned, shouldClose); + } + }; + } + else + { + await server.TcpConnectionFactory.Release(connection, closeConnection); + } + } + + // Translation is wire-local. Preserve the protocol observed from the client for + // downstream response handling, callbacks, and the traffic tape. + request.HttpVersion = clientHttpVersion; + } + } + + // ──────────────────────────────────────────────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────────────────────────────────────────────── + + /// + /// Builds the QPACK name/value list for an origin request (test seam + EncodeRequest source of truth). + /// + private static List<(string, string)> BuildRequestHeaders(Request request, string authorityHost) // NOSONAR S1144 -- reflection test seam + { + string authority; + if (request.Authority.Length > 0) + authority = request.Authority.GetString(); + else if (!string.IsNullOrEmpty(request.Host)) + authority = request.Host; + else + authority = authorityHost; + var path = request.RequestUriString8.Length > 0 + ? request.RequestUriString8.GetString() + : "/"; + if (UriExtensions.GetScheme(request.RequestUriString8).Length > 0) + { + try + { + var uri = request.RequestUri; + authority = uri.Authority; + path = uri.PathAndQuery; + } + catch + { + // Keep ByteString-derived authority/path. + } + } + + var headers = new List<(string, string)> + { + (":method", request.Method), + (":scheme", request.IsHttps ? "https" : "http"), + (":authority", authority), + (":path", path.Length > 0 ? path : "/") + }; + + foreach (var header in request.Headers.GetAllHeaders()) + { + var name = header.Name.ToLowerInvariant(); + if (name is "connection" or "keep-alive" or "proxy-connection" + or "transfer-encoding" or "upgrade" or "te" or "host" + or "http2-settings" or "proxy-authorization" or "proxy-authenticate") + continue; + headers.Add((name, header.Value)); + } + + return headers; + } + + + /// + /// QPACK-encode an origin request, reusing a connection-scoped block when the fingerprint + /// and identity match (identical reverse tiny-GET multiplex). + /// + private static byte[] EncodeOriginRequestHeaders( + QuicServerConnection quicConn, Request request, string sniHost) + { + var session = quicConn.Http3ClientSession; + var fingerprint = ComputeOriginRequestQpackFingerprint(request, sniHost); + var authority = OriginRequestAuthorityBytes(request, sniHost); + var path = OriginRequestPathBytes(request); + if (session != null) + { + var cached = session.TryGetCachedEncodedRequestHeaders( + fingerprint, request.Method, authority, path); + if (cached != null) + return cached; + } + + var encoded = QpackEncoder.EncodeRequest(request, sniHost); + session?.SetCachedEncodedRequestHeaders( + fingerprint, request.Method, authority, path, encoded); + return encoded; + } + + private static ReadOnlySpan OriginRequestAuthorityBytes(Request request, string sniHost) + { + if (request.Authority.Length > 0) + return request.Authority.Span; + if (!string.IsNullOrEmpty(request.Host)) + return System.Text.Encoding.ASCII.GetBytes(request.Host); + return System.Text.Encoding.ASCII.GetBytes(sniHost); + } + + private static ReadOnlySpan OriginRequestPathBytes(Request request) + => request.RequestUriString8.Length > 0 + ? request.RequestUriString8.Span + : "/"u8; + + private static int ComputeOriginRequestQpackFingerprint(Request request, string sniHost) + { + var hash = new HashCode(); + hash.Add(request.Method); + hash.Add(request.IsHttps); + hash.AddBytes(OriginRequestAuthorityBytes(request, sniHost)); + hash.AddBytes(OriginRequestPathBytes(request)); + foreach (var header in request.Headers.GetAllHeaders()) + { + hash.Add(header.Name); + hash.Add(header.Value); + } + return hash.ToHashCode(); + } + +} diff --git a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.cs b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.cs index e7c129925..b70899284 100644 --- a/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.cs +++ b/src/Titanium.Web.Proxy/Http3/Http3OriginBridge.cs @@ -38,7 +38,7 @@ namespace Titanium.Web.Proxy.Http3; /// callers that have a pre-resolved should use the route-based /// overload to avoid redundant cache/DNS lookups. /// -internal static class Http3OriginBridge +internal static partial class Http3OriginBridge { // ──────────────────────────────────────────────────────────────────────────────────────── // Public API @@ -135,1987 +135,5 @@ await ForwardAsync(sessionArgs, server, route, logger, cancellationToken, onInte } // ──────────────────────────────────────────────────────────────────────────────────────── - // H3 → H3 (QUIC) - // ──────────────────────────────────────────────────────────────────────────────────────── - - /// - /// Sends the request to the origin over QUIC. - /// - /// - /// The DNS name or IP used for the QUIC UDP socket. May be a SVCB TargetName distinct from - /// the origin authority. - /// - /// - /// The TLS SNI hostname and HTTP/3 :authority value — always the origin authority. - /// - /// The QUIC port (may be an alternative port from Alt-Svc or SVCB). - /// - /// When , QUIC failures are terminal (return 502); no TCP fallback. - /// When (Auto policy), evict the stale cache entry and fall back to TCP. - /// - private static async Task ForwardOverQuicAsync( // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - SessionEventArgs sessionArgs, - ProxyServer server, - string connectHost, - string sniHost, - int port, - bool isForcedH3, - ILogger logger, - CancellationToken cancellationToken, - Func? onInterimResponse = null, - Func? copyRequestBody = null) - { - var request = sessionArgs.HttpClient.Request; - var upStreamEndPoint = sessionArgs.HttpClient.UpStreamEndPoint ?? server.UpStreamEndPoint; - - // Mirror TcpConnectionFactory proxy-resolution logic. - var upstreamProxy = sessionArgs.CustomUpStreamProxy; - if (upstreamProxy == null && server.GetCustomUpStreamProxyFunc != null) - upstreamProxy = await server.GetCustomUpStreamProxyFunc(sessionArgs); - - // Set BOTH fields so the TCP fallback path does not re-invoke GetCustomUpStreamProxyFunc. - sessionArgs.CustomUpStreamProxy = upstreamProxy; - sessionArgs.CustomUpStreamProxyUsed = upstreamProxy; - upstreamProxy ??= server.UpStreamHttpsProxy; - - QuicServerConnection? quicConn = null; - // When true, StreamBodyWriter owns originStream + quicConn release (do not dispose/release here). - var streamHandedOff = false; - // A pooled connection can go stale between requests: MsQuic's own (server-negotiated) idle - // timeout is often shorter than QuicConnectionPool's bookkeeping window, and a silently - // dead connection isn't reflected by QuicServerConnection.IsClosed until it's actually used. - // If OpenRequestStreamAsync/write fails on a *reused* connection before anything has been - // sent to the client, retrying with another connection is safe and avoids needlessly evicting - // the H3 capability (and downgrading the origin to TCP) over a stale pooled connection. - // Several retries may be needed: QuicConnectionPool can hand out more than one *different* - // pooled connection before it is forced to fall through to a guaranteed-fresh one, and if a - // whole browsing-idle gap elapsed, all of them may have gone stale together. - var reused = false; - var staleConnectionRetries = 0; - var requestSent = false; - - try - { - while (true) - { - QuicStream? originStream = null; - try - { - // Pass the session so ServerCertificateValidationCallback is honoured. The factory's - // default path supplies sessionArgs: null, which skips the user callback and rejects - // any chain that is not already trusted by the OS (breaking MITM-test and custom-CA - // deployments for every H3→H3 origin connect). - quicConn = await server.QuicConnectionPool.GetOrCreateAsync( - connectHost, port, upStreamEndPoint, upstreamProxy, - (sender, certificate, chain, errors) => - server.ValidateServerCertificate(sender, sessionArgs, certificate, chain, errors), - cancellationToken, - sniHost: sniHost); - - reused = !quicConn.ClaimFirstUse(); - sessionArgs.Timing?.MarkConnectionReady(quicConn.Id, reused); - // Multiplexed QUIC origin: bind metadata without SetConnection (TCP-only ownership API). - // SetConnection on TCP fallback overwrites it if QUIC fails later in the loop. - sessionArgs.HttpClient.BindUpstreamConnection(quicConn); - - originStream = await quicConn.OpenRequestStreamAsync(cancellationToken); - - // Do not start reading client DATA until the origin stream is open (stale-pool retry). - Func? pendingCopy = null; - byte[]? body = null; - if (copyRequestBody != null && !request.IsBodyRead && !request.BodyAvailable) - { - pendingCopy = copyRequestBody; - } - else - { - // GetRequestBody() leaves plain bytes (EnsurePlainBodyAsync); CompressBody respects - // BodyIsWireEncoded so eager wire buffers are not double-compressed. - body = request.HasBody || request.BodyAvailable - ? request.CompressBodyAndUpdateContentLength() - : null; - } - - // Use the origin authority (sniHost) for the :authority pseudo-header, not the connect host. - var encodedHeaders = QpackEncoder.EncodeRequest(request, sniHost); - await Http3Frame.WriteAsync(originStream, Http3FrameType.Headers, encodedHeaders, cancellationToken); - // HEADERS are on the wire — client DATA may be consumed next; retry is no longer safe. - requestSent = true; - - if (pendingCopy != null) - { - await pendingCopy(originStream, cancellationToken); - } - else if (body is { Length: > 0 }) - { - await Http3Frame.WriteAsync(originStream, Http3FrameType.Data, body, cancellationToken); - } - - // QuicStream WriteAsync may buffer; without Flush the peer can see the request hundreds of - // ms late (observed ~450ms Cloudflare HTML TTFB with inFlight=1 after request "sent"). - // Fast-path loopback GETs skip Flush — CompleteWrites is enough and Flush costs RPS. - if (!sessionArgs.IsFastPath) - await originStream.FlushAsync(cancellationToken); - originStream.CompleteWrites(); - sessionArgs.Timing?.MarkRequestSent(); - - const int maxInterimResponses = 20; - int interimCount = 0; - - Http3Frame? responseHeadersFrame; - List<(string Name, string Value)> decodedResponseHeaders; - int finalStatus; - - while (true) - { - responseHeadersFrame = await Http3Frame.ReadAsync(originStream, - maxPayloadBytes: server.MaxDecodedHeaderListBytes, cancellationToken); - - if (responseHeadersFrame == null) - throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, - "Expected HEADERS frame as first frame on origin response stream."); - - // RFC 9114 §9: ignore unknown/GREASE frames. DATA before HEADERS is a protocol error. - if (responseHeadersFrame.Type != Http3FrameType.Headers) - { - if (responseHeadersFrame.Type == Http3FrameType.Data) - throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, - "DATA frame received before response HEADERS."); - if (IsForbiddenOnRequestStream(responseHeadersFrame.Type)) - throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, - $"Frame type 0x{responseHeadersFrame.Type:X} not permitted on request stream."); - continue; // GREASE / unknown / PRIORITY_UPDATE etc. - } - - decodedResponseHeaders = QpackDecoder.Decode(responseHeadersFrame.Payload.Span); - finalStatus = ParseStatusCode(decodedResponseHeaders); - - if (finalStatus is >= 100 and < 200) - { - if (++interimCount > maxInterimResponses) - throw new Http3StreamException(Http3ErrorCode.InternalError, - $"Origin sent more than {maxInterimResponses} interim responses."); - - if (onInterimResponse != null) - { - var interim = BuildResponseFromHeaders(decodedResponseHeaders, HttpHeader.Version30); - await onInterimResponse(interim, cancellationToken); - } - continue; - } - - break; - } - - sessionArgs.Timing?.MarkResponseHeadersReceived(); - - var response = BuildResponseFromHeaders(decodedResponseHeaders, HttpHeader.Version30); - response.RequestMethod = request.Method; - - // Cache Alt-Svc from response headers immediately (no need to wait for the body). - var altSvc = response.Headers.GetHeaderValueOrNull("Alt-Svc"); - if (!string.IsNullOrEmpty(altSvc)) - { - var entries = AltSvcParser.Parse(altSvc); - if (entries.Count > 0 && entries[0].MaxAgeSeconds > 0) - { - var originPort = request.GetOriginHostPort(port).Port; - var ttlSeconds = Math.Min(entries[0].MaxAgeSeconds, Http3OriginCapabilityCache.DefaultTtl.TotalSeconds * 2); - var ttl = TimeSpan.FromSeconds(ttlSeconds); - server.Http3OriginCapabilityCache.Set($"{sniHost}:{originPort}", - entries[0].Port == originPort ? int.MinValue : entries[0].Port, ttl); - } - } - - var maxPayload = sessionArgs.MaxBufferedBodyBytes ?? server.MaxBufferedBodyBytes; - - // Stream large / unknown-length bodies as DATA arrives (TTFB on big HTML). Tiny known-CL - // must materialize first: H1 WriteResponseAsync + StreamBodyWriter emits a header-only - // TLS record then body (lossy H1 dig / compare-bridges H1→H3). Same ≤64 KiB budget as - // H1 terminate coalesce and H3→H1 ForwardOverTcpFastAsync. - if (!response.HasBody) - { - response.IsBodyRead = true; - sessionArgs.HttpClient.Response = response; - await originStream.DisposeAsync(); - originStream = null; - break; - } - - // H1 clients need chunked framing when Content-Length is absent; H2/H3 strip TE later. - if (response.ContentLength < 0 && !response.IsChunked) - response.Headers.AddHeader(KnownHeaders.TransferEncoding, KnownHeaders.TransferEncodingChunked); - - if (originStream is null || quicConn is null) - throw new InvalidOperationException("HTTP/3 origin stream or connection missing after response headers."); - - const int eagerBodyThreshold = 64 * 1024; - if (!response.IsChunked - && response.ContentLength >= 0 - && response.ContentLength <= eagerBodyThreshold - && !server.HasOnResponseBodyWriteSubscribers) - { - var bodyBytes = response.ContentLength == 0 - ? Array.Empty() - : new byte[response.ContentLength]; - var offset = 0; - while (offset < bodyBytes.Length) - { - var frame = await Http3Frame.ReadAsync(originStream, maxPayloadBytes: maxPayload, - cancellationToken); - if (frame == null) - break; - try - { - if (frame.Type == Http3FrameType.Headers) - break; // trailers - if (frame.Type != Http3FrameType.Data || frame.Payload.Length == 0) - continue; - var toCopy = Math.Min(frame.Payload.Length, bodyBytes.Length - offset); - frame.Payload.Span[..toCopy].CopyTo(bodyBytes.AsSpan(offset)); - offset += toCopy; - } - finally - { - frame.ReturnPayload(); - } - } - - // Drain to FIN so Dispose does not RST a live H3 request stream (pool poison → - // handshake-per-request under load; cool H1→H3 fell ~1.16× → ~0.7×). - while (true) - { - var frame = await Http3Frame.ReadAsync(originStream, maxPayloadBytes: maxPayload, - cancellationToken); - if (frame == null) - break; - frame.ReturnPayload(); - } - - if (offset != bodyBytes.Length) - Array.Resize(ref bodyBytes, offset); - - response.Body = bodyBytes; - response.BodyIsWireEncoded = true; - response.IsBodyRead = true; - response.ContentLength = bodyBytes.Length; - response.Headers.RemoveHeader(KnownHeaders.TransferEncoding); - sessionArgs.HttpClient.Response = response; - await originStream.DisposeAsync(); - originStream = null; - break; - } - - QuicStream streamToClient = originStream; - QuicServerConnection connToRelease = quicConn; - originStream = null; - quicConn = null; - streamHandedOff = true; - - var hasBodyWriteHook = server.HasOnResponseBodyWriteSubscribers; - - response.StreamBodyWriter = async (clientBodyStream, ct) => - { - try - { - if (!hasBodyWriteHook) - { - while (true) - { - var frame = await Http3Frame.ReadAsync(streamToClient, maxPayloadBytes: maxPayload, ct); - if (frame == null) break; - try - { - if (frame.Type == Http3FrameType.Headers) - break; // trailers — ignored for now - if (frame.Type != Http3FrameType.Data || frame.Payload.Length == 0) - continue; - - await clientBodyStream.WriteAsync(frame.Payload, ct); - } - finally - { - frame.ReturnPayload(); - } - } - } - else - { - var current = await Http3Frame.ReadAsync(streamToClient, maxPayloadBytes: maxPayload, ct); - while (current != null) - { - var next = await Http3Frame.ReadAsync(streamToClient, maxPayloadBytes: maxPayload, ct); - var isLast = next == null || next.Type == Http3FrameType.Headers; - - try - { - if (current.Type == Http3FrameType.Data) - { - var hookArgs = new BeforeBodyWriteEventArgs( - sessionArgs, current.Payload.ToArray(), isChunked: true, isLastChunk: isLast); - await server.OnBeforeResponseBodyWrite(hookArgs); - - if (hookArgs.BodyBytes is { Length: > 0 }) - await clientBodyStream.WriteAsync(hookArgs.BodyBytes, ct); - - if (hookArgs.IsLastChunk && next is { } toRelease - && toRelease.Type != Http3FrameType.Headers) - { - streamToClient.Abort(QuicAbortDirection.Read, (long)Http3ErrorCode.RequestCancelled); - toRelease.ReturnPayload(); - break; - } - } - } - finally - { - current.ReturnPayload(); - } - - current = next; - } - } - } - finally - { - try { await streamToClient.DisposeAsync(); } catch { /* best effort */ } - try { await QuicConnectionPool.ReleaseAsync(connToRelease); } catch { /* best effort */ } - } - }; - - sessionArgs.HttpClient.Response = response; - break; // success — exit the retry loop; body drains when the client emit path runs StreamBodyWriter - } - catch (QuicProxyNotSupportedException ex) - { - // System.Net.Quic cannot route via a proxy. - // For Auto policy: fall back to TCP so proxy rules are honoured. - // For forced H3: a proxy was explicitly configured but cannot carry QUIC — return 502. - if (logger.IsEnabled(LogLevel.Debug)) - logger.LogDebug(ex, - "QUIC cannot route via proxy; {Behavior} for {Host}:{Port}", - isForcedH3 ? "returning 502 (forced H3)" : "falling back to TCP", - sniHost, port); - - quicConn = null; // GetOrCreateAsync threw before creating a connection - - if (!isForcedH3) - { - try - { - await ForwardOverTcpAsync(sessionArgs, server, cancellationToken, onInterimResponse); - } - catch (Exception tcpEx) when (tcpEx is not OperationCanceledException) - { - sessionArgs.HttpClient.Response = MakeBadGatewayResponse(tcpEx.Message); - } - - return; - } - - sessionArgs.HttpClient.Response = MakeBadGatewayResponse("QUIC cannot be routed via the configured upstream proxy (forced Http3)."); - return; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - if (logger.IsEnabled(LogLevel.Debug)) - logger.LogDebug(ex, "H3→H3 origin forwarding failed for {Host}:{Port}", sniHost, port); - - if (originStream != null) - { - try { await originStream.DisposeAsync(); } catch { /* best effort */ } - } - - if (quicConn != null) - { - // Any exception while using the request stream makes the connection suspect. - // In particular, a peer-closed connection is not reflected by - // QuicServerConnection.IsClosed, which only tracks local disposal state. - // Leaving it shared causes every later request to retry the same dead QUIC - // connection and produces intermittent 502s after an otherwise healthy H3 run. - // Invalidate rather than dispose: other requests may still be streaming over this - // connection, and they get to finish even though no new request will join them. - await server.QuicConnectionPool.InvalidateAsync(quicConn); - quicConn = null; - } - - // The failure happened while acquiring/opening the stream on a *pooled* connection and - // nothing was written to the origin yet (see requestSent) — most likely the connection - // silently went idle-dead between requests (MsQuic's idle timeout tends to be shorter than - // QuicConnectionPool's bookkeeping window; see QuicServerConnection.IsClosed remarks). - // A single retry with a freshly created connection is safe (no request bytes were sent) - // and avoids evicting the H3 capability / downgrading the origin to TCP for what is really - // just a stale pooled connection, not a genuine H3 unreachability. - if (reused && !requestSent && staleConnectionRetries < QuicConnectionPool.MaxStaleConnectionRetries) - { - staleConnectionRetries++; - if (logger.IsEnabled(LogLevel.Debug)) - logger.LogDebug( - "Pooled QUIC connection to {Host}:{Port} was stale ({ExceptionType}); retrying (attempt {Attempt}/{Max}).", - sniHost, port, ex.GetType().Name, staleConnectionRetries, QuicConnectionPool.MaxStaleConnectionRetries); - continue; - } - - if (!isForcedH3) - { - // Auto policy: the cached H3 capability is stale or unusable — evict and fall back to TCP. - // Evict by origin identity (request URI port), not the QUIC connect port, which may - // differ when Alt-Svc / SVCB advertised an alternative port. - var originPort = request.GetOriginHostPort(port).Port; - var hostAndPort = $"{sniHost}:{originPort}"; - server.Http3OriginCapabilityCache.Evict(hostAndPort); - if (logger.IsEnabled(LogLevel.Debug)) - logger.LogDebug("Evicted stale H3 capability for {HostAndPort}; falling back to TCP.", hostAndPort); - try - { - await ForwardOverTcpAsync(sessionArgs, server, cancellationToken, onInterimResponse); - } - catch (Exception tcpEx) when (tcpEx is not OperationCanceledException) - { - if (logger.IsEnabled(LogLevel.Debug)) - logger.LogDebug(tcpEx, "TCP fallback after H3 failure also failed for {Host}:{Port}", - sniHost, originPort); - sessionArgs.HttpClient.Response = MakeBadGatewayResponse( - $"QUIC failed: {ex.Message}; TCP fallback failed: {tcpEx.Message}"); - } - - return; - } - - // Forced H3: surface as a 502 — never fall back silently. - sessionArgs.HttpClient.Response = MakeBadGatewayResponse(ex.Message); - return; - } - } // end retry loop - } - finally - { - // When StreamBodyWriter owns the stream/connection, it releases on completion. - // Otherwise give up this request's stream so idle eviction is not blocked forever. - if (!streamHandedOff && quicConn != null) - await QuicConnectionPool.ReleaseAsync(quicConn); - } - } - - // ──────────────────────────────────────────────────────────────────────────────────────── - // H3 → H2 (TLS ALPN h2, or cleartext h2c when ForwardCleartext) - // ──────────────────────────────────────────────────────────────────────────────────────── - - /// - /// H3 → H2 via . Uses TLS ALPN h2 unless the - /// transparent endpoint has , - /// in which case the origin is cleartext HTTP/2 prior-knowledge (h2c). - /// - /// - /// Session-less H3→H2 forward for the interception-off bodiless path. - /// is invoked only when the shared H2 origin pool - /// must open a new TCP+H2 session (warm-pool RPS never hits it). - /// - internal static async Task ForwardOverHttp2FastAsync( - H3H2FastForward fwd, - ProxyServer server, - ILogger logger, - CancellationToken cancellationToken, - Func coldOpenSessionFactory) - { - var request = fwd.Request; - var clientHttpVersion = request.HttpVersion; - request.HttpVersion = HttpHeader.Version20; - - if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint { ForwardCleartext: true }) - request.IsHttps = false; - - if (request.Authority.Length == 0 && !string.IsNullOrEmpty(request.Host)) - request.Authority = request.Host.GetByteString(); - - string? connectHost = null; - int? connectPort = null; - if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint transparent - && !string.IsNullOrEmpty(transparent.ForwardHost)) - { - connectHost = transparent.ForwardHost; - connectPort = transparent.ForwardPort; - } - - string host; - int port; - string poolKey; - if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint fastEp - && fastEp.CachedH2OriginPoolKey != null - && request.Authority.Equals(fastEp.CachedH2OriginAuthority)) - { - host = fastEp.CachedH2OriginHost!; - port = fastEp.CachedH2OriginPort; - poolKey = fastEp.CachedH2OriginPoolKey; - } - else - { - (host, port) = ResolveH2OriginAuthority(request); - poolKey = Http2OriginConnectionPool.BuildPoolKey( - server, fwd.ProxyEndPoint, fwd.CustomUpStreamProxy, fwd.UpStreamEndPoint, - host, port, connectHost, connectPort); - if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint cacheEp) - { - cacheEp.CachedH2OriginAuthority = request.Authority; - cacheEp.CachedH2OriginHost = host; - cacheEp.CachedH2OriginPort = port; - cacheEp.CachedH2OriginPoolKey = poolKey; - } - } - - try - { - var target = new Http2OriginTarget(host, port, connectHost, connectPort, poolKey); - var exchange = await SendHttp2OriginFastWithGoAwayRetryAsync( - server, logger, fwd, target, coldOpenSessionFactory, cancellationToken); - - var response = exchange.Response; - response.HttpVersion = HttpHeader.Version30; - response.RequestMethod = request.Method; - if (response.StreamBodyWriter == null) - { - response.IsBodyRead = true; - response.Body = exchange.Body; - // Http2OriginConnection materializes H2 DATA wire bytes. - response.BodyIsWireEncoded = true; - } - - if (exchange.TrailingHeaders != null && !response.HasTrailingHeaders) - { - foreach (var header in exchange.TrailingHeaders) - response.TrailingHeaders.AddHeader(header); - } - - fwd.Response = response; - } - finally - { - request.HttpVersion = clientHttpVersion; - } - } - - private static async Task SendHttp2OriginFastWithGoAwayRetryAsync( - ProxyServer server, ILogger logger, H3H2FastForward fwd, - Http2OriginTarget target, - Func coldOpenSessionFactory, - CancellationToken cancellationToken) - { - Http2OriginConnection? h2 = null; - try - { - h2 = await LeaseHttp2OriginFastAsync(server, logger, fwd, target, coldOpenSessionFactory, - cancellationToken); - return await h2.SendAsync(fwd.Request, on1xx: null, cancellationToken); - } - catch (Exception ex) when (ex is Http2OriginGoAwayException - || (ex is IOException && h2 is { IsUsable: false })) - { - if (h2 != null) - server.Http2OriginConnectionPool.Invalidate(target.PoolKey, h2); - - if (!CanReplayHttp2OriginRequest(fwd.Request, copyRequestBody: null)) - throw; - - h2 = await LeaseHttp2OriginFastAsync(server, logger, fwd, target, coldOpenSessionFactory, - cancellationToken); - return await h2.SendAsync(fwd.Request, on1xx: null, cancellationToken); - } - } - - private static async Task LeaseHttp2OriginFastAsync( // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - ProxyServer server, ILogger logger, H3H2FastForward fwd, - Http2OriginTarget target, - Func coldOpenSessionFactory, - CancellationToken cancellationToken) - { - return await server.Http2OriginConnectionPool.RentAsync(target.PoolKey, async ct => - { - // Cold open only: build a throwaway SessionEventArgs for TcpConnectionFactory cert hooks. - var sessionArgs = coldOpenSessionFactory(); - try - { - var originIsHttps = fwd.ProxyEndPoint is not TransparentBaseProxyEndPoint { ForwardCleartext: true }; - var upStreamProxy = fwd.CustomUpStreamProxy - ?? (originIsHttps ? server.UpStreamHttpsProxy : server.UpStreamHttpProxy); - - var tcp = await server.TcpConnectionFactory.GetServerConnection( - server, target.Host, target.Port, HttpHeader.Version20, originIsHttps, - originIsHttps ? SslExtensions.Http2ProtocolAsList : null, - false, sessionArgs, fwd.UpStreamEndPoint ?? server.UpStreamEndPoint, - upStreamProxy, - true, false, ct, target.ConnectHost, target.ConnectPort); - - if (tcp != null && !originIsHttps) - tcp.Http2Cleartext = true; - - if (tcp == null || - (originIsHttps - ? tcp.NegotiatedApplicationProtocol != SslApplicationProtocol.Http2 - : !tcp.Http2Cleartext)) - { - if (tcp != null) - await server.TcpConnectionFactory.Release(tcp, true); - var how = originIsHttps ? "did not negotiate HTTP/2 via ALPN" : "did not accept cleartext HTTP/2 (h2c)"; - throw new ProxyHttpException( - $"The origin '{target.Host}:{target.Port}' {how} for the H3→H2 bridge.", - null, sessionArgs); - } - - return await Http2OriginConnection.CreateAsync(tcp, logger, - fwd.MaxBufferedBodyBytes, ct, server.ResourceLimits); - } - finally - { - sessionArgs.CancellationTokenSource.Dispose(); - sessionArgs.Dispose(); - } - }, cancellationToken); - } - - /// - /// Session-less H3→H3 forward for the interception-off bodiless path. - /// Request: QPACK encode from the Request bag (authority rewrite for ForwardHost). - /// Response: verbatim frame relay to (H2 compressed-relay - /// analogue) — no response QPACK decode/re-encode / graph. - /// Returns when the client response is already on the wire. - /// - internal static async Task ForwardOverQuicFastAsync( // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - H3H2FastForward fwd, - ProxyServer server, - ILogger logger, - CancellationToken cancellationToken, - Func coldOpenSessionFactory, - QuicStream clientStream) - { - var request = fwd.Request; - var sniHost = fwd.OriginAuthorityHost ?? "localhost"; - var colon = sniHost.LastIndexOf(':'); - if (colon > 0 && int.TryParse(sniHost.AsSpan(colon + 1), out _)) - sniHost = sniHost[..colon]; - - string connectHost = sniHost; - var port = request.IsHttps ? 443 : 80; - if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint - { - ForwardHost: { Length: > 0 } forwardHost, - ForwardPort: { } forwardPort - }) - { - connectHost = forwardHost; - port = forwardPort; - } - else if (request.Authority.Length > 0) - { - var authority = request.Authority.GetString(); - var idx = authority.LastIndexOf(':'); - if (idx > 0 && int.TryParse(authority.AsSpan(idx + 1), out var parsedPort)) - { - connectHost = authority[..idx]; - port = parsedPort; - sniHost = connectHost; - } - else - { - connectHost = authority; - sniHost = authority; - } - } - - if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint { ForwardCleartext: true }) - request.IsHttps = false; - - var upStreamEndPoint = fwd.UpStreamEndPoint ?? server.UpStreamEndPoint; - var upstreamProxy = fwd.CustomUpStreamProxy ?? server.UpStreamHttpsProxy; - - QuicServerConnection? quicConn = null; - var reused = false; - var staleConnectionRetries = 0; - var requestSent = false; - SessionEventArgs? certSession = null; - - try - { - while (true) - { - QuicStream? originStream = null; - try - { - quicConn = await server.QuicConnectionPool.GetOrCreateAsync( - connectHost, port, upStreamEndPoint, upstreamProxy, - (sender, certificate, chain, errors) => - { - certSession ??= coldOpenSessionFactory(); - return server.ValidateServerCertificate( - sender, certSession, certificate, chain, errors); - }, - cancellationToken, - sniHost: sniHost); - - reused = !quicConn.ClaimFirstUse(); - originStream = await quicConn.OpenRequestStreamAsync(cancellationToken); - - var encodedHeaders = QpackEncoder.EncodeRequest(request, sniHost); - await Http3Frame.WriteAsync(originStream, Http3FrameType.Headers, encodedHeaders, cancellationToken); - requestSent = true; - originStream.CompleteWrites(); - - // Verbatim origin→client frame copy (HEADERS + DATA + trailers). Skip QPACK - // decode/re-encode — same idea as H2 compressed same-protocol relay. - // Tiny GET: coalesce HEADERS+DATA into one Quic write (origin probe sends both). - const int relayCoalesceMaxBytes = 16 * 1024; - var maxPayload = Math.Max(fwd.MaxBufferedBodyBytes, server.MaxDecodedHeaderListBytes); - var sawFinalHeaders = false; - while (true) - { - var frame = await Http3Frame.ReadAsync(originStream, maxPayloadBytes: maxPayload, - cancellationToken); - if (frame == null) - break; - try - { - if (frame.Type == Http3FrameType.Headers) - { - // Ignore interim 1xx on the fast path (probes never send them). - // Still forward the first HEADERS block and any trailers. - if (!sawFinalHeaders) - { - var headersPayload = frame.Payload; - var next = await Http3Frame.ReadAsync(originStream, - maxPayloadBytes: maxPayload, cancellationToken); - if (next is { Type: Http3FrameType.Data } - && headersPayload.Length + next.Payload.Length <= relayCoalesceMaxBytes) - { - try - { - await Http3Frame.WriteHeadersAndDataAsync(clientStream, - headersPayload, next.Payload, cancellationToken); - } - finally - { - next.ReturnPayload(); - } - - sawFinalHeaders = true; - continue; - } - - if (next != null) - { - await Http3Frame.WriteAsync(clientStream, Http3FrameType.Headers, - headersPayload, cancellationToken); - sawFinalHeaders = true; - if (next.Type == Http3FrameType.Data) - { - if (next.Payload.Length > 0) - await Http3Frame.WriteAsync(clientStream, Http3FrameType.Data, - next.Payload, cancellationToken); - } - else if (IsForbiddenOnRequestStream(next.Type)) - throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, - $"Frame type 0x{next.Type:X} not permitted on request stream."); - next.ReturnPayload(); - continue; - } - } - - await Http3Frame.WriteAsync(clientStream, Http3FrameType.Headers, - frame.Payload, cancellationToken); - sawFinalHeaders = true; - continue; - } - - if (frame.Type == Http3FrameType.Data) - { - if (!sawFinalHeaders) - throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, - "DATA frame received before response HEADERS."); - if (frame.Payload.Length > 0) - await Http3Frame.WriteAsync(clientStream, Http3FrameType.Data, - frame.Payload, cancellationToken); - continue; - } - - if (IsForbiddenOnRequestStream(frame.Type)) - throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, - $"Frame type 0x{frame.Type:X} not permitted on request stream."); - // GREASE / unknown: drop - } - finally - { - frame.ReturnPayload(); - } - } - - if (!sawFinalHeaders) - throw new Http3StreamException(Http3ErrorCode.FrameUnexpected, - "Expected HEADERS frame as first frame on origin response stream."); - - await originStream.DisposeAsync(); - return true; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - if (logger.IsEnabled(LogLevel.Debug)) - logger.LogDebug(ex, "H3→H3 fast forward failed for {Host}:{Port}", connectHost, port); - - if (originStream != null) - { - try { await originStream.DisposeAsync(); } catch { /* best effort */ } - } - - if (quicConn != null) - { - await server.QuicConnectionPool.InvalidateAsync(quicConn); - quicConn = null; - } - - if (reused && !requestSent - && staleConnectionRetries < QuicConnectionPool.MaxStaleConnectionRetries) - { - staleConnectionRetries++; - requestSent = false; - continue; - } - - fwd.Response = MakeBadGatewayResponse(ex.Message); - return false; - } - } - } - finally - { - if (quicConn != null) - await QuicConnectionPool.ReleaseAsync(quicConn); - - if (certSession != null) - { - certSession.CancellationTokenSource.Dispose(); - certSession.Dispose(); - } - } - } - - /// - /// Session-lite H3→H1 forward: Request bag + stub for - /// only (no inbound H3 pumps, BeforeRequest, or Via). - /// Warm keep-alive still pools origin sockets. Does not allocate — - /// the socket is already leased; only a is needed for QPACK. - /// - internal static async Task ForwardOverTcpFastAsync( // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - H3H2FastForward fwd, - ProxyServer server, - ILogger logger, - CancellationToken cancellationToken, - Func coldOpenSessionFactory, - QpackContext? qpackContext = null) - { - var request = fwd.Request; - request.HttpVersion = HttpHeader.Version11; - request.IsBodyReceived = true; - request.Locked = true; - if (string.IsNullOrEmpty(request.Host) && request.Authority.Length > 0) - request.Host = request.Authority.GetString(); - - // Match H3→H2 / H3→H3: SNI / Host stay on client :authority (OriginAuthorityHost, - // typically "localhost"). ForwardHost is connect-only via connectHost/connectPort. - // Using ForwardHost (127.0.0.1) as SslStream.TargetHost fails name checks against a - // localhost leaf (integration TestCertificateAuthority; also macOS Network.framework). - var isHttps = request.IsHttps; - string? connectHost = null; - int? connectPort = null; - if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint ep) - { - if (ep.ForwardCleartext) - isHttps = false; - if (!string.IsNullOrEmpty(ep.ForwardHost)) - { - connectHost = ep.ForwardHost; - connectPort = ep.ForwardPort; - } - } - - string? poolKey = null; - if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint poolEp - && poolEp.CachedHttp11PoolKey != null - && poolEp.CachedHttp11PoolIsHttps == isHttps) - poolKey = poolEp.CachedHttp11PoolKey; - - TcpServerConnection? connection = null; - SessionEventArgs? openSession = null; - var closeConnection = false; - try - { - if (poolKey != null) - server.TcpConnectionFactory.TryRentPooled(server, poolKey, - SslExtensions.Http11ProtocolAsList, out connection); - - if (connection == null) - { - // Resolve SNI host/port only on pool miss — warm keep-alive hits skip GetOriginHostPort. - string host; - int port; - var sni = fwd.OriginAuthorityHost; - if (!string.IsNullOrEmpty(sni)) - { - var colon = sni.LastIndexOf(':'); - if (colon > 0 && int.TryParse(sni.AsSpan(colon + 1), out _)) - sni = sni[..colon]; - host = sni; - port = connectPort ?? (isHttps ? 443 : 80); - } - else - { - (host, port) = request.GetOriginHostPort(isHttps ? 443 : 80); - } - - openSession = coldOpenSessionFactory(); - connection = await server.TcpConnectionFactory.GetServerConnection( - server, host, port, HttpHeader.Version11, isHttps, - SslExtensions.Http11ProtocolAsList, false, openSession, - fwd.UpStreamEndPoint ?? server.UpStreamEndPoint, - fwd.CustomUpStreamProxy ?? (isHttps ? server.UpStreamHttpsProxy : server.UpStreamHttpProxy), - false, false, cancellationToken, connectHost, connectPort, - precomputedCacheKey: poolKey) - ?? throw new InvalidOperationException( - $"Failed to establish an HTTP/1.1 origin connection to '{host}:{port}'."); - - if (fwd.ProxyEndPoint is TransparentBaseProxyEndPoint store - && fwd.CustomUpStreamProxy == null - && (fwd.UpStreamEndPoint ?? server.UpStreamEndPoint) == null) - { - store.CachedHttp11PoolKey = connection.CacheKey; - store.CachedHttp11PoolIsHttps = isHttps; - } - } - - // Inline H1 exchange — skip HttpWebClient + InternalDataStore on the warm path. - request.Headers.RemoveHeader(KnownHeaders.Connection); - var headerBuilder = HeaderBuilder.Rent(); - try - { - headerBuilder.WriteRequestLine(request.Method, request.RequestUriString8, - HttpHeader.Version11); - headerBuilder.WriteHeaders(request.Headers, sendProxyAuthorization: false); - await connection.Stream.WriteHeadersAsync(headerBuilder, cancellationToken); - } - finally - { - HeaderBuilder.Return(headerBuilder); - } - - var httpStatus = await connection.Stream.ReadResponseStatus(cancellationToken); - if (httpStatus == null) - { - // Stale pooled keep-alive: no request body on this fast path → retryable. - throw new RetryableServerConnectionException( - "Server connection was closed before any response was received."); - } - - // One-pass H1 headers → QPACK (no Response/HeaderCollection) for the interception-off - // path. When fwd.Response is set (MITM unchanged-after-handlers), also seed that graph - // so BeforeResponse can inspect/mutate before Preencoded or EncodeResponse emit. - var populate = fwd.Response?.Headers; - var parsed = await H3H1QpackResponseReader.TryReadAsync( - connection.Stream, httpStatus.Value.StatusCode, qpackContext, cancellationToken, - populate); - if (parsed is null) - throw new OperationCanceledException(cancellationToken); - - var statusCode = httpStatus.Value.StatusCode; - if (fwd.Response != null) - { - fwd.Response.HttpVersion = HttpHeader.Version30; - fwd.Response.StatusCode = statusCode; - fwd.Response.StatusDescription = - GenericResponse.Get(statusCode) ?? string.Empty; - } - - var method = request.Method; - var contentLength = parsed.Value.ContentLength; - var isChunked = parsed.Value.IsChunked; - var connectionClose = parsed.Value.ConnectionClose; - var mayHaveBody = ResponseMayHaveBody(statusCode, method, contentLength, isChunked, - connectionClose); - - if (mayHaveBody) - { - if (!isChunked && contentLength >= 0 && contentLength <= 64 * 1024) - { - byte[] bodyBytes; - var bodyLength = (int)contentLength; - var rented = false; - if (contentLength == 0) - { - bodyBytes = []; - } - else if (connection.Stream.Available >= bodyLength) - { - bodyBytes = server.BufferPool.GetBuffer(bodyLength); - if (!connection.Stream.TryCopyAvailableExact(bodyBytes.AsSpan(0, bodyLength))) - { - server.BufferPool.ReturnBuffer(bodyBytes); - bodyBytes = new byte[bodyLength]; - var offset = 0; - while (offset < bodyBytes.Length) - { - var read = await connection.Stream.ReadAsync(bodyBytes.AsMemory(offset), - cancellationToken); - if (read == 0) - break; - offset += read; - } - - if (offset != bodyBytes.Length) - { - closeConnection = true; - Array.Resize(ref bodyBytes, offset); - bodyLength = offset; - } - } - else - { - rented = true; - } - } - else - { - bodyBytes = new byte[bodyLength]; - var offset = 0; - while (offset < bodyBytes.Length) - { - var read = await connection.Stream.ReadAsync(bodyBytes.AsMemory(offset), - cancellationToken); - if (read == 0) - break; - offset += read; - } - - if (offset != bodyBytes.Length) - { - closeConnection = true; - Array.Resize(ref bodyBytes, offset); - bodyLength = offset; - } - } - - fwd.PreencodedQpackHeaders = parsed.Value.QpackHeaders; - fwd.PreencodedBody = bodyBytes; - fwd.PreencodedBodyLength = bodyLength; - fwd.PreencodedBodyRented = rented; - if (fwd.Response != null) - { - // Copy for BeforeResponse; PreencodedBody remains the wire emit when unchanged. - var copy = bodyLength == 0 ? Array.Empty() : new byte[bodyLength]; - if (bodyLength > 0) - Buffer.BlockCopy(bodyBytes, 0, copy, 0, bodyLength); - fwd.Response.Body = copy; - fwd.Response.BodyIsWireEncoded = true; - fwd.Response.IsBodyReceived = true; - fwd.Response.IsBodyRead = true; - } - } - else - { - // Large / chunked / close-delimited: stream via PreencodedStreamBodyWriter. - var originConnection = connection; - var originIsChunked = isChunked; - var originContentLength = contentLength; - var trailingHeaders = new HeaderCollection(); - fwd.PreencodedQpackHeaders = parsed.Value.QpackHeaders; - fwd.PreencodedStreamBodyWriter = async (clientBodyStream, ct) => - { - IHttpStreamReader reader = originConnection.Stream; - using var limited = new LimitedStream(reader, server.BufferPool, originIsChunked, - originContentLength, trailingHeaders); - const int frameBytes = 16 * 1024; - var buffer = server.BufferPool.GetBuffer(frameBytes); - try - { - var filled = 0; - while (true) - { - var read = await limited.ReadAsync( - buffer.AsMemory(filled, frameBytes - filled), ct); - if (read == 0) - { - if (filled > 0) - await clientBodyStream.WriteAsync(buffer.AsMemory(0, filled), ct); - break; - } - - filled += read; - if (filled == frameBytes) - { - await clientBodyStream.WriteAsync(buffer.AsMemory(0, filled), ct); - filled = 0; - } - } - - await limited.Finish(); - } - finally - { - server.BufferPool.ReturnBuffer(buffer); - } - }; - if (fwd.Response != null) - fwd.Response.StreamBodyWriter = fwd.PreencodedStreamBodyWriter; - } - } - else - { - fwd.PreencodedQpackHeaders = parsed.Value.QpackHeaders; - fwd.PreencodedBody = null; - if (fwd.Response != null) - { - fwd.Response.IsBodyReceived = true; - fwd.Response.IsBodyRead = true; - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - closeConnection = true; - throw; - } - finally - { - if (connection != null) - { - // Stream body writer owns the socket until the client DATA copy finishes. - if (fwd.PreencodedStreamBodyWriter != null) - { - var owned = connection; - var shouldClose = closeConnection; - var inner = fwd.PreencodedStreamBodyWriter; - fwd.PreencodedStreamBodyWriter = async (dest, ct) => - { - var copyCompleted = false; - try - { - await inner(dest, ct); - copyCompleted = true; - } - finally - { - // Incomplete copy may leave unread CL bytes on the socket while - // HttpStream.Available is 0 (bytes already in the pump buffer). Pooling - // that connection poisons the next H3→H1 request into H3_INTERNAL_ERROR - // (GHA compare-arch slow-consumer after warmup cancel). - if (!copyCompleted - || (owned.Stream is Helpers.HttpStream residual && residual.DataAvailable)) - shouldClose = true; - await server.TcpConnectionFactory.Release(owned, shouldClose); - } - }; - } - else - { - await server.TcpConnectionFactory.Release(connection, closeConnection); - } - } - - if (openSession != null) - { - openSession.CancellationTokenSource.Dispose(); - openSession.Dispose(); - } - } - - _ = logger; - } - - private static bool ResponseMayHaveBody( - int statusCode, string method, long contentLength, bool isChunked, bool connectionClose) - { - if (statusCode is >= 100 and < 200) return false; - if (statusCode is 204 or 304) return false; - if (string.Equals(method, "HEAD", StringComparison.OrdinalIgnoreCase)) return false; - if (contentLength == 0) return false; - if (contentLength > 0) return true; - if (isChunked || connectionClose) return true; - return false; - } - - private static async Task ForwardOverHttp2Async( // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - SessionEventArgs sessionArgs, - ProxyServer server, - ILogger logger, - CancellationToken cancellationToken, - Func? onInterimResponse = null) - { - var request = sessionArgs.HttpClient.Request; - - // Stream when possible; only force a full buffer if a handler already started GetRequestBody - // or no live pump is available. - var copyRequestBody = sessionArgs.Http3RequestBodyPump; - if (copyRequestBody == null) - await EnsureHttp3BufferedBodyAsync(sessionArgs, cancellationToken); - else if (!request.HasBody && !request.IsBodyReceived) - { - // Bodiless H3 (GET): drain client FIN via the pump, then send origin HEADERS+END_STREAM. - // Leaving the pump set forces HEADERS without END_STREAM plus an empty DATA frame under - // writeLock — profiled as wasted origin-write serialization under multiplex. - await copyRequestBody(static (_, _) => default, cancellationToken); - copyRequestBody = null; - request.IsBodyReceived = true; - } - - var clientHttpVersion = request.HttpVersion; - request.HttpVersion = HttpHeader.Version20; - - // Prefer :authority for origin resolve; Host string is only needed when handlers / H1 - // fallback read Request.Host. Skip the GetString alloc on the H3→H2 fast path. - if (!sessionArgs.IsFastPath - && string.IsNullOrEmpty(request.Host) - && request.Authority.Length > 0) - request.Host = request.Authority.GetString(); - - // TLS-terminate → h2c: origin expects :scheme http. - if (sessionArgs.ProxyEndPoint is TransparentBaseProxyEndPoint { ForwardCleartext: true }) - request.IsHttps = false; - - // QPACK decode already produces lowercase names and no hop-by-hop headers on the probe - // fast path — skip the RemoveHeader/Any scan that dominates Prepare for tiny GETs. - if (!sessionArgs.IsFastPath) - PrepareH2OriginRequestHeaders(request); - else if (request.Authority.Length == 0 && !string.IsNullOrEmpty(request.Host)) - request.Authority = request.Host.GetByteString(); - - var (connectHost, connectPort) = ResolveTransparentForwardTarget(sessionArgs); - - string host; - int port; - string poolKey; - if (sessionArgs.IsFastPath - && sessionArgs.ProxyEndPoint is TransparentBaseProxyEndPoint fastEp - && fastEp.CachedH2OriginPoolKey != null - && request.Authority.Equals(fastEp.CachedH2OriginAuthority)) - { - host = fastEp.CachedH2OriginHost!; - port = fastEp.CachedH2OriginPort; - poolKey = fastEp.CachedH2OriginPoolKey; - } - else - { - (host, port) = ResolveH2OriginAuthority(request); - poolKey = Http2OriginConnectionPool.BuildPoolKey(server, sessionArgs, host, port, connectHost, - connectPort); - if (sessionArgs.IsFastPath && sessionArgs.ProxyEndPoint is TransparentBaseProxyEndPoint cacheEp) - { - cacheEp.CachedH2OriginAuthority = request.Authority; - cacheEp.CachedH2OriginHost = host; - cacheEp.CachedH2OriginPort = port; - cacheEp.CachedH2OriginPoolKey = poolKey; - } - } - - try - { - var on1xx = CreateInterimResponseAdapter(onInterimResponse); - var exchange = await SendHttp2OriginWithGoAwayRetryAsync( - server, logger, sessionArgs, - new Http2OriginTarget(host, port, connectHost, connectPort, poolKey), - on1xx, cancellationToken, copyRequestBody); - - var response = exchange.Response; - response.HttpVersion = HttpHeader.Version30; - response.RequestMethod = request.Method; - if (response.StreamBodyWriter == null) - { - response.IsBodyRead = true; - response.Body = exchange.Body; - // Http2OriginConnection materializes H2 DATA wire bytes. - response.BodyIsWireEncoded = true; - } - - if (exchange.TrailingHeaders != null && !response.HasTrailingHeaders) - { - foreach (var header in exchange.TrailingHeaders) - response.TrailingHeaders.AddHeader(header); - } - - sessionArgs.HttpClient.Response = response; - } - finally - { - request.HttpVersion = clientHttpVersion; - } - } - - private static async Task EnsureHttp3BufferedBodyAsync( - SessionEventArgs sessionArgs, CancellationToken cancellationToken) - { - var request = sessionArgs.HttpClient.Request; - if (request.IsBodyReceived || sessionArgs.Http3BufferedBodyReader == null) - return; - - if (request.HasBody) - { - await sessionArgs.GetRequestBody(cancellationToken); - return; - } - - _ = await sessionArgs.Http3BufferedBodyReader(cancellationToken); - sessionArgs.Http3BufferedBodyReader = null; - request.IsBodyReceived = true; - } - - private static (string Host, int Port) ResolveH2OriginAuthority(Request request) - => request.GetOriginHostPort(443); - - private static (string? ConnectHost, int? ConnectPort) ResolveTransparentForwardTarget( - SessionEventArgs sessionArgs) - { - if (sessionArgs.UpstreamConnectHost is { Length: > 0 } routedHost) - return (routedHost, sessionArgs.UpstreamConnectPort); - - if (sessionArgs.ProxyEndPoint is TransparentBaseProxyEndPoint transparent - && !string.IsNullOrEmpty(transparent.ForwardHost)) - return (transparent.ForwardHost, transparent.ForwardPort); - - return (null, null); - } - - private static Func? CreateInterimResponseAdapter( - Func? onInterimResponse) - { - if (onInterimResponse == null) - return null; - - return async (status, headers, ct) => - { - var interim = new Response - { - HttpVersion = HttpHeader.Version30, - StatusCode = status, - IsBodyRead = true, - Body = Array.Empty() - }; - foreach (var header in headers) - interim.Headers.AddHeader(header); - await onInterimResponse(interim, ct); - }; - } - - private static async Task SendHttp2OriginWithGoAwayRetryAsync( - ProxyServer server, ILogger logger, SessionEventArgs sessionArgs, - Http2OriginTarget target, - Func? on1xx, - CancellationToken cancellationToken, - Func, CancellationToken, ValueTask>, CancellationToken, Task>? copyRequestBody = - null) - { - Http2OriginConnection? h2 = null; - try - { - h2 = await LeaseHttp2OriginAsync(server, logger, sessionArgs, target, cancellationToken); - sessionArgs.HttpClient.BindUpstreamConnection(h2.ServerConnection); - return await h2.SendAsync(sessionArgs.HttpClient.Request, on1xx, cancellationToken, - copyRequestBody); - } - catch (Exception ex) when (ex is Http2OriginGoAwayException - || (ex is IOException && h2 is { IsUsable: false })) - { - // Stop new leases on this member; do not Dispose — siblings below last-stream-id - // must finish. Retry once on another pooled connection when the body is replayable. - // H3 GET still has a pump delegate even after a zero-DATA FIN, so do not treat - // "copyRequestBody != null" as "body was consumed and cannot be replayed". - if (h2 != null) - server.Http2OriginConnectionPool.Invalidate(target.PoolKey, h2); - - if (!CanReplayHttp2OriginRequest(sessionArgs.HttpClient.Request, copyRequestBody)) - throw; - - h2 = await LeaseHttp2OriginAsync(server, logger, sessionArgs, target, cancellationToken); - sessionArgs.HttpClient.BindUpstreamConnection(h2.ServerConnection); - return await h2.SendAsync(sessionArgs.HttpClient.Request, on1xx, cancellationToken); - } - } - - private static bool CanReplayHttp2OriginRequest(Request request, - Func, CancellationToken, ValueTask>, CancellationToken, Task>? copyRequestBody) => - copyRequestBody == null - || request.IsBodyRead - || request.IsBodyReceived - || !request.HasBody; - - private readonly record struct Http2OriginTarget( - string Host, int Port, string? ConnectHost, int? ConnectPort, string PoolKey); - - private static async Task LeaseHttp2OriginAsync( // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - ProxyServer server, ILogger logger, SessionEventArgs sessionArgs, - Http2OriginTarget target, CancellationToken cancellationToken) - { - return await server.Http2OriginConnectionPool.RentAsync(target.PoolKey, async ct => - { - var originIsHttps = sessionArgs.ProxyEndPoint is not TransparentBaseProxyEndPoint { ForwardCleartext: true }; - var upStreamProxy = sessionArgs.CustomUpStreamProxyUsed - ?? (originIsHttps ? server.UpStreamHttpsProxy : server.UpStreamHttpProxy); - - var tcp = await server.TcpConnectionFactory.GetServerConnection( - server, target.Host, target.Port, HttpHeader.Version20, originIsHttps, - originIsHttps ? SslExtensions.Http2ProtocolAsList : null, - false, sessionArgs, sessionArgs.HttpClient.UpStreamEndPoint ?? server.UpStreamEndPoint, - upStreamProxy, - true, false, ct, target.ConnectHost, target.ConnectPort); - - if (tcp != null && !originIsHttps) - tcp.Http2Cleartext = true; - - if (tcp == null || - (originIsHttps - ? tcp.NegotiatedApplicationProtocol != SslApplicationProtocol.Http2 - : !tcp.Http2Cleartext)) - { - if (tcp != null) - await server.TcpConnectionFactory.Release(tcp, true); - var how = originIsHttps ? "did not negotiate HTTP/2 via ALPN" : "did not accept cleartext HTTP/2 (h2c)"; - throw new ProxyHttpException( - $"The origin '{target.Host}:{target.Port}' {how} for the H3→H2 bridge.", - null, sessionArgs); - } - - return await Http2OriginConnection.CreateAsync(tcp, logger, - sessionArgs.MaxBufferedBodyBytes ?? server.MaxBufferedBodyBytes, ct, - server.ResourceLimits); - }, cancellationToken); - } - - private static void PrepareH2OriginRequestHeaders(Request request) - { - if (request.Authority.Length == 0) - { - var hostHeader = request.Host; - if (!string.IsNullOrEmpty(hostHeader)) - request.Authority = hostHeader.GetByteString(); - } - - request.Headers.RemoveHeader(KnownHeaders.Connection); - request.Headers.RemoveHeader("Keep-Alive"); - request.Headers.RemoveHeader(KnownHeaders.ProxyConnection); - request.Headers.RemoveHeader(KnownHeaders.TransferEncoding); - request.Headers.RemoveHeader(KnownHeaders.Upgrade); - request.Headers.RemoveHeader("TE"); - request.Headers.RemoveHeader(KnownHeaders.Host); - - // Fast path when names are already lowercase (QPACK); otherwise rename in place. - if (request.Headers.Any(h => - { - for (var i = 0; i < h.Name.Length; i++) - { - var c = h.Name[i]; - if (c is >= 'A' and <= 'Z') return true; - } - - return false; - })) - { - var renamed = request.Headers - .Select(h => (Name: h.Name.ToLowerInvariant(), h.Value)) - .ToList(); - request.Headers.Clear(); - foreach (var (name, value) in renamed) - request.Headers.AddHeader(name, value); - } - - request.HeaderNamesAreHttp2Normalized = true; - } - - // ──────────────────────────────────────────────────────────────────────────────────────── - // H3 → TCP (H1.1) - // ──────────────────────────────────────────────────────────────────────────────────────── - - /// - /// Forwards the session over a TCP (HTTP/1.1) connection to the origin server. - /// - /// H2/H3 client sessions arrive with HttpVersion 2/3, :authority instead of a - /// Host, and may still have unread request DATA. Request bodies stream live when - /// is set; response bodies stream via - /// unless a handler called GetResponseBody. - /// - /// - private static async Task ForwardOverTcpAsync( // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - SessionEventArgs sessionArgs, - ProxyServer server, - CancellationToken cancellationToken, - Func? onInterimResponse = null) - { - var request = sessionArgs.HttpClient.Request; - - // Prefer live pump (H3 client DATA → H1 body). Fall back to full buffer only when a - // BeforeRequest handler already called GetRequestBody, or no pump is available. - var streamRequestBody = !request.IsBodyRead && sessionArgs.Http3RequestBodyPump != null; - if (!streamRequestBody && !request.IsBodyReceived && sessionArgs.Http3BufferedBodyReader != null) - { - if (request.HasBody) - { - await sessionArgs.GetRequestBody(cancellationToken); - } - else - { - // Consume FIN for bodiless requests (GET) without exposing a body. - _ = await sessionArgs.Http3BufferedBodyReader(cancellationToken); - sessionArgs.Http3BufferedBodyReader = null; - sessionArgs.Http3RequestBodyPump = null; - request.IsBodyReceived = true; - } - } - else if (!request.HasBody && !request.IsBodyReceived && sessionArgs.Http3RequestBodyPump != null) - { - // Drain client FIN with no body octets (GET) so MsQuic is not left with unread DATA. - await sessionArgs.Http3RequestBodyPump(static (_, _) => default, cancellationToken); - } - - // SendRequest uses HTTP/1.x framing. Translate H2/H3-shaped requests the same way - // Http2ToHttp11BridgeHandler does before hitting the wire. - var needsHttp11Wire = request.HttpVersion.Major >= 2; - var clientHttpVersion = request.HttpVersion; - byte[]? body = null; - if (needsHttp11Wire) - { - request.HttpVersion = HttpHeader.Version11; - if (string.IsNullOrEmpty(request.Host) && request.Authority.Length > 0) - request.Host = request.Authority.GetString(); - - var cookieHeaders = request.Headers.GetHeaders("Cookie"); - if (cookieHeaders is { Count: > 1 }) - { - var combined = string.Join("; ", cookieHeaders.Select(h => h.Value)); - request.Headers.RemoveHeader("Cookie"); - request.Headers.AddHeader("Cookie", combined); - } - - if (!streamRequestBody) - { - // GetRequestBody() leaves plain bytes; CompressBody respects BodyIsWireEncoded so - // any remaining wire buffer is not double-compressed onto the H1 origin. - body = request.BodyAvailable || request.HasBody - ? request.CompressBodyAndUpdateContentLength() - : null; - } - else if (request.ContentLength < 0 && !request.IsChunked) - { - // Unknown length over H3 → chunked on the H1 wire. - request.Headers.AddHeader(KnownHeaders.TransferEncoding, KnownHeaders.TransferEncodingChunked); - } - // else: client-declared content-length is already correct for the streamed body. - // UpdateContentLength() must NOT run here — it stamps BodyInternal?.Length ?? 0 and - // would rewrite content-length to 0 (same bug H2→H1 already documents). - } - - TcpServerConnection? connection = null; - var closeConnection = true; - try - { - var isHttps = sessionArgs.IsHttps; - if (sessionArgs.ProxyEndPoint is TransparentBaseProxyEndPoint { ForwardCleartext: true }) - isHttps = false; - - var (host, port) = request.GetOriginHostPort(isHttps ? 443 : 80); - - var (connectHost, connectPort) = ResolveTransparentForwardTarget(sessionArgs); - - // Shared pool under multiplexed H3 fan-out — same as H2→H1 (noCache caused port storms). - string? poolKey = null; - if (sessionArgs.ProxyEndPoint is TransparentBaseProxyEndPoint poolEp - && poolEp.CachedHttp11PoolKey != null - && poolEp.CachedHttp11PoolIsHttps == isHttps) - { - poolKey = poolEp.CachedHttp11PoolKey; - } - - // Phase 3: when streaming an upload, start reading client DATA into a channel in - // parallel with the origin TCP/TLS connect so MsQuic is not stalled on a full window. - Channel>? earlyBodyChannel = null; - Task? earlyBodyPump = null; - if (streamRequestBody && request.HasBody && sessionArgs.Http3RequestBodyPump != null) - { - earlyBodyChannel = Channel.CreateBounded>( - new BoundedChannelOptions(256) - { - SingleReader = true, - SingleWriter = true, - FullMode = BoundedChannelFullMode.Wait - }); - var pump = sessionArgs.Http3RequestBodyPump; - var writer = earlyBodyChannel.Writer; - earlyBodyPump = pump( - async (data, ct) => - { - if (data.IsEmpty) - return; - // Copy before enqueue: StreamRequestBodyToWriteAsync returns the frame's - // ArrayPool buffer after writeData completes — Channel.WriteAsync only - // queues the Memory, so returning early would corrupt the upload. - var owned = data.ToArray(); - await writer.WriteAsync(owned, ct); - }, - cancellationToken).ContinueWith(t => - { - writer.TryComplete(t.Exception?.GetBaseException()); - }, TaskScheduler.Default); - } - - try - { - connection = await server.TcpConnectionFactory.GetServerConnection( - server, host, port, HttpHeader.Version11, isHttps, SslExtensions.Http11ProtocolAsList, - false, sessionArgs, sessionArgs.HttpClient.UpStreamEndPoint ?? server.UpStreamEndPoint, - sessionArgs.CustomUpStreamProxyUsed ?? (isHttps ? server.UpStreamHttpsProxy : server.UpStreamHttpProxy), - false, false, cancellationToken, connectHost, connectPort, - precomputedCacheKey: poolKey); - } - catch - { - // Connect failed: stop the early pump so MsQuic is not left with unread DATA. - earlyBodyChannel?.Writer.TryComplete(); - if (earlyBodyPump != null) - { - try { await earlyBodyPump; } - catch { /* best effort */ } - } - - throw; - } - - if (poolKey == null - && sessionArgs.ProxyEndPoint is TransparentBaseProxyEndPoint storePoolEp - && sessionArgs.CustomUpStreamProxyUsed == null - && (sessionArgs.HttpClient.UpStreamEndPoint ?? server.UpStreamEndPoint) == null) - { - storePoolEp.CachedHttp11PoolKey = connection!.CacheKey; - storePoolEp.CachedHttp11PoolIsHttps = isHttps; - } - - sessionArgs.HttpClient.SetConnection(connection - ?? throw new InvalidOperationException( - $"Failed to establish an HTTP/1.1 origin connection to '{host}:{port}'.")); - await sessionArgs.HttpClient.SendRequest( - server.Enable100ContinueBehaviour, sessionArgs.IsTransparent, - sessionArgs.OriginHttpVersionPolicy ?? server.OriginHttpVersionPolicy, cancellationToken); - - // Streamed uploads: start the origin body write in parallel with ReceiveResponse so an - // early-responding origin (compare-arch) can push response headers/body while the - // remaining request bytes are still in flight — same duplex shape as YARP StreamCopier. - // Buffered bodies stay half-duplex (write then read). - Task? uploadTask = null; - if (needsHttp11Wire && request.HasBody && !request.ExpectationFailed) - { - if (streamRequestBody) - { - var bodyWriter = new Helpers.BodyStreamWriter(connection.Stream, request.IsChunked); - var earlyChannel = earlyBodyChannel; - var earlyPump = earlyBodyPump; - var bodyPump = sessionArgs.Http3RequestBodyPump; - var trailing = request.HasTrailingHeaders ? request.TrailingHeaders : null; - uploadTask = PumpUploadAsync(); - - async Task PumpUploadAsync() - { - try - { - if (earlyChannel != null) - { - await foreach (var chunk in earlyChannel.Reader.ReadAllAsync(cancellationToken)) - { - if (!chunk.IsEmpty) - await bodyWriter.WriteAsync(chunk, cancellationToken); - } - - if (earlyPump != null) - await earlyPump; - } - else if (bodyPump != null) - { - await bodyPump( - async (data, ct) => - { - if (!data.IsEmpty) - await bodyWriter.WriteAsync(data, ct); - }, - cancellationToken); - } - - await bodyWriter.CompleteAsync(trailing, cancellationToken); - } - catch (Exception ex) - { - earlyChannel?.Writer.TryComplete(ex); - throw; - } - } - } - else - { - await connection.Stream.WriteBodyAsync(body ?? Array.Empty(), request.IsChunked, - request.HasTrailingHeaders ? request.TrailingHeaders : null, cancellationToken); - } - } - - try - { - await sessionArgs.HttpClient.ReceiveResponse(cancellationToken); - - while (sessionArgs.HttpClient.Response.StatusCode is >= 100 and < 200) - { - if (onInterimResponse != null) - await onInterimResponse(sessionArgs.HttpClient.Response, cancellationToken); - - await sessionArgs.ClearResponse(cancellationToken); - await sessionArgs.HttpClient.ReceiveResponse(cancellationToken); - } - } - catch - { - if (uploadTask != null) - { - try { await uploadTask; } - catch { /* surface ReceiveResponse failure */ } - } - - throw; - } - - var response = sessionArgs.HttpClient.Response; - // Stream the response unless a handler already buffered it. H3 client emit path - // (SendResponseAsync) honours StreamBodyWriter the same way H2 EmitSynthetic does. - // Eager-buffer known-CL bodies up to min(64 KiB, MaxBufferedBodyBytes); larger stream - // (matches H2→H1 / ForwardOverTcpFastAsync and compare-bodies GET size). - var eagerBodyThreshold = Math.Min(64 * 1024, - Math.Max(0, sessionArgs.MaxBufferedBodyBytes ?? server.MaxBufferedBodyBytes)); - if (response.HasBody && !response.IsBodyRead - && !response.IsChunked - && response.ContentLength >= 0 - && response.ContentLength <= eagerBodyThreshold) - { - // Finish upload before draining a buffered response body (same socket). - if (uploadTask != null) - await uploadTask; - - byte[] bodyBytes; - if (response.ContentLength == 0) - { - bodyBytes = Array.Empty(); - } - else - { - // Read CL bytes directly — avoids LimitedStream wrapper for small known-CL bodies. - bodyBytes = new byte[response.ContentLength]; - var offset = 0; - while (offset < bodyBytes.Length) - { - var read = await connection.Stream.ReadAsync( - bodyBytes.AsMemory(offset), cancellationToken); - if (read == 0) - break; - offset += read; - } - - if (offset != bodyBytes.Length) - { - closeConnection = true; - Array.Resize(ref bodyBytes, offset); - } - } - - response.Body = bodyBytes; - response.BodyIsWireEncoded = true; - response.IsBodyRead = true; - response.ContentLength = bodyBytes.Length; - response.Headers.RemoveHeader(KnownHeaders.TransferEncoding); - response.StreamBodyWriter = null; - } - else if (response.HasBody && !response.IsBodyRead) - { - var originConnection = connection; - var originIsChunked = response.IsChunked; - var originContentLength = response.ContentLength; - var pendingUpload = uploadTask; - if (response.ContentLength < 0 && !response.IsChunked) - response.Headers.AddHeader(KnownHeaders.TransferEncoding, KnownHeaders.TransferEncodingChunked); - - response.StreamBodyWriter = async (clientBodyStream, ct) => - { - async Task CopyResponseAsync() - { - IHttpStreamReader reader = originConnection.Stream; - using var limited = new LimitedStream(reader, server.BufferPool, originIsChunked, - originContentLength, response.TrailingHeaders); - var buffer = server.BufferPool.GetBuffer(); - try - { - int read; - while ((read = await limited.ReadAsync(buffer.AsMemory(), ct)) > 0) - await clientBodyStream.WriteAsync(buffer.AsMemory(0, read), ct); - await limited.Finish(); - } - finally - { - server.BufferPool.ReturnBuffer(buffer); - } - } - - // Keep request upload live while copying the response (true duplex). - var copyTask = CopyResponseAsync(); - if (pendingUpload != null) - await Task.WhenAll(pendingUpload, copyTask); - else - await copyTask; - }; - } - else if (uploadTask != null) - { - await uploadTask; - } - - closeConnection = !response.KeepAlive; - - // Do not probe residual bytes while a stream body writer still owns the origin socket — - // buffered DATA after headers would look like leftover framing and force-close keep-alive - // under multiplexed POST. Probe only after the body drain (eager path below, or the - // stream-body wrapper in finally). - if (sessionArgs.HttpClient.Response.StreamBodyWriter == null - && connection?.Stream is Helpers.HttpStream httpStream && httpStream.DataAvailable) - closeConnection = true; - } - finally - { - // FinishSession only nulls the HttpClient reference. Without Release, every H3→H1 - // GET paid a new origin TLS handshake (Windows ~300 ms / tens of RPS). - // When StreamBodyWriter owns the body copy, delay release until after the client emit - // path finishes — mark closeConnection so keep-alive is not reused with unread bytes. - if (connection != null) - { - if (sessionArgs.HttpClient.Response.StreamBodyWriter != null && - !sessionArgs.HttpClient.Response.IsBodyRead) - { - // Hand off: StreamBodyWriter will finish the socket read; release after copy - // by wrapping the writer. - var owned = connection; - var shouldClose = closeConnection; - var inner = sessionArgs.HttpClient.Response.StreamBodyWriter; - sessionArgs.HttpClient.Response.StreamBodyWriter = async (dest, ct) => - { - var copyCompleted = false; - try - { - await inner(dest, ct); - copyCompleted = true; - } - finally - { - // Incomplete copy may leave unread CL bytes on the socket while - // HttpStream.Available is 0 (bytes already in the pump buffer). Never pool. - if (!copyCompleted - || (owned.Stream is Helpers.HttpStream residual && residual.DataAvailable)) - shouldClose = true; - await server.TcpConnectionFactory.Release(owned, shouldClose); - } - }; - } - else - { - await server.TcpConnectionFactory.Release(connection, closeConnection); - } - } - - // Translation is wire-local. Preserve the protocol observed from the client for - // downstream response handling, callbacks, and the traffic tape. - request.HttpVersion = clientHttpVersion; - } - } - - // ──────────────────────────────────────────────────────────────────────────────────────── - // Helpers - // ──────────────────────────────────────────────────────────────────────────────────────── - - /// - /// Builds the QPACK name/value list for an origin request (test seam + EncodeRequest source of truth). - /// - private static List<(string, string)> BuildRequestHeaders(Request request, string authorityHost) // NOSONAR S1144 -- reflection test seam - { - string authority; - if (request.Authority.Length > 0) - authority = request.Authority.GetString(); - else if (!string.IsNullOrEmpty(request.Host)) - authority = request.Host; - else - authority = authorityHost; - var path = request.RequestUriString8.Length > 0 - ? request.RequestUriString8.GetString() - : "/"; - if (UriExtensions.GetScheme(request.RequestUriString8).Length > 0) - { - try - { - var uri = request.RequestUri; - authority = uri.Authority; - path = uri.PathAndQuery; - } - catch - { - // Keep ByteString-derived authority/path. - } - } - - var headers = new List<(string, string)> - { - (":method", request.Method), - (":scheme", request.IsHttps ? "https" : "http"), - (":authority", authority), - (":path", path.Length > 0 ? path : "/") - }; - - foreach (var header in request.Headers.GetAllHeaders()) - { - var name = header.Name.ToLowerInvariant(); - if (name is "connection" or "keep-alive" or "proxy-connection" - or "transfer-encoding" or "upgrade" or "te" or "host" - or "http2-settings" or "proxy-authorization" or "proxy-authenticate") - continue; - headers.Add((name, header.Value)); - } - - return headers; - } - - private static int ParseStatusCode(List<(string Name, string Value)> headers) - { - foreach (var (name, value) in headers) - if (name == ":status" && int.TryParse(value, out var code)) - return code; - return 0; - } - - private static Response BuildResponseFromHeaders( - List<(string Name, string Value)> headers, Version httpVersion) - { - var response = new Response { HttpVersion = httpVersion }; - foreach (var (name, value) in headers) - { - if (name == ":status" && int.TryParse(value, out var statusCode)) - response.StatusCode = statusCode; - else if (!name.StartsWith(':')) - response.Headers.AddHeader(new HttpHeader(name, value)); - } - return response; - } - - private static Response MakeBadGatewayResponse(string detail) => new() - { - HttpVersion = HttpHeader.Version30, - StatusCode = 502, - StatusDescription = "Bad Gateway", - IsBodyRead = true, - Body = System.Text.Encoding.UTF8.GetBytes($"HTTP/3 origin forwarding error: {detail}") - }; - - /// - /// Frame types that RFC 9114 forbids on request streams (must not be silently ignored). - /// - private static bool IsForbiddenOnRequestStream(ulong frameType) => - frameType is Http3FrameType.Settings or Http3FrameType.GoAway - or Http3FrameType.MaxPushId or Http3FrameType.CancelPush; } #pragma warning restore CA1416 diff --git a/src/Titanium.Web.Proxy/Http3/Http3OriginClientSession.cs b/src/Titanium.Web.Proxy/Http3/Http3OriginClientSession.cs index 407f3ed49..291ca5ee0 100644 --- a/src/Titanium.Web.Proxy/Http3/Http3OriginClientSession.cs +++ b/src/Titanium.Web.Proxy/Http3/Http3OriginClientSession.cs @@ -25,6 +25,10 @@ internal sealed class Http3OriginClientSession : IAsyncDisposable private Http3Settings? _peerSettings; private int _disposed; + // Connection-scoped reuse of identical bodiless reverse QPACK request blocks (probe tiny-GET). + // Published as one immutable slot so fingerprint and bytes cannot tear under c=64. + private CachedEncodedRequestHeaders? _cachedEncodedRequestHeaders; + internal Http3OriginClientSession(QuicConnection connection, ProxyServer proxyServer) { _connection = connection; @@ -179,6 +183,59 @@ private static async Task DrainBytesAsync(QuicStream stream, CancellationToken c return settings; } + + internal byte[]? TryGetCachedEncodedRequestHeaders( + int fingerprint, + string method, + ReadOnlySpan authority, + ReadOnlySpan path) + { + var cached = Volatile.Read(ref _cachedEncodedRequestHeaders); + if (cached is null || cached.Fingerprint != fingerprint) + return null; + if (!string.Equals(cached.Method, method, StringComparison.Ordinal)) + return null; + if (!cached.Authority.AsSpan().SequenceEqual(authority)) + return null; + if (!cached.Path.AsSpan().SequenceEqual(path)) + return null; + return cached.Encoded; + } + + internal void SetCachedEncodedRequestHeaders( + int fingerprint, + string method, + ReadOnlySpan authority, + ReadOnlySpan path, + byte[] encoded) + { + Volatile.Write(ref _cachedEncodedRequestHeaders, new CachedEncodedRequestHeaders( + fingerprint, + method, + authority.ToArray(), + path.ToArray(), + encoded)); + } + + private sealed class CachedEncodedRequestHeaders + { + internal CachedEncodedRequestHeaders( + int fingerprint, string method, byte[] authority, byte[] path, byte[] encoded) + { + Fingerprint = fingerprint; + Method = method; + Authority = authority; + Path = path; + Encoded = encoded; + } + + internal int Fingerprint { get; } + internal string Method { get; } + internal byte[] Authority { get; } + internal byte[] Path { get; } + internal byte[] Encoded { get; } + } + public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref _disposed, 1) != 0) return; diff --git a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs index ea02c2eb0..2558df1cf 100644 --- a/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs +++ b/src/Titanium.Web.Proxy/Http3/Http3RequestStream.cs @@ -247,8 +247,7 @@ await HandleH3OriginFastPathAsync( } // 6. Fire BeforeRequest (stamp timing milestone just before). - var requestHeaderRelayBaseline = - MitmCompressedRelayHelper.HeaderRelayBaseline.Capture(request.Headers); + request.Headers.ArmMitmRelayBaseline(); var capturedRequestMethod = request.Method; var capturedRequestPath = request.RequestUriString8; var capturedRequestAuthority = request.Authority; @@ -274,16 +273,20 @@ await HandleH3OriginFastPathAsync( // Inject Via only when we stay on the full session forward path (not MITM // unchanged-lite / IsFastPath). Adding Via before the unchanged check would // dirtied MutationCount and defeat the lite finish. + var requestHeaderRelayBaseline = request.Headers.TakeMitmRelayBaseline(); var mitmUnchangedH3H1 = TryMitmUnchangedH3ToH1Lite( sessionArgs, authArgs, request, requestHeaderRelayBaseline, capturedRequestMethod, capturedRequestPath, capturedRequestAuthority, method); + var mitmUnchangedH3H3 = !mitmUnchangedH3H1 && TryMitmUnchangedH3ToH3Lite( + sessionArgs, authArgs, request, requestHeaderRelayBaseline, + capturedRequestMethod, capturedRequestPath, capturedRequestAuthority, method); - if (!mitmUnchangedH3H1 && !sessionArgs.IsFastPath + if (!mitmUnchangedH3H1 && !mitmUnchangedH3H3 && !sessionArgs.IsFastPath && !string.IsNullOrEmpty(server.ViaHeaderPseudonym)) sessionArgs.HttpClient.Request.Headers.AddHeader( new HttpHeader("via", $"3.0 {server.ViaHeaderPseudonym}")); - if (sessionArgs.HttpClient.Response.Locked) + if (sessionArgs.HttpClient.HasResponse && sessionArgs.HttpClient.Response.Locked) { // Synthetic response: abort unread request DATA rather than draining an // endless upload (matches RespondStreaming closeServerConnection guidance). @@ -301,10 +304,11 @@ await HandleH3OriginFastPathAsync( new HttpHeader("via", $"3.0 {server.ViaHeaderPseudonym}")); await SendResponseAsync(stream, sessionArgs.HttpClient.Response, qpackContext, cancellationToken); } - else if (mitmUnchangedH3H1) + else if (mitmUnchangedH3H1 || mitmUnchangedH3H3) { - // True MITM noop-safe H3→H1: reuse ForwardOverTcpFastAsync after handlers left - // the request unchanged (same lite machinery as reverse). + // True MITM noop-safe: H3→H1 uses ForwardOverTcpFastAsync; H3→H3 captures QPACK + // via ForwardOverQuicFastAsync(clientStream: null) then SendPreencoded after + // BeforeResponse (same shape as H3→H1 lite — never emit before the handler). if (!streamState.RequestClosed) { sessionArgs.Http3BufferedBodyReader = null; @@ -347,89 +351,39 @@ SessionEventArgs ColdOpenSessionFactory() var stub = new SessionEventArgs(server, endPoint, nullStream, null, stubCts); stub.IsFastPath = true; stub.CustomUpStreamProxy = fwd.CustomUpStreamProxy; - stub.UpstreamHttpProtocol = UpstreamHttpProtocol.Http11; + stub.UpstreamHttpProtocol = mitmUnchangedH3H3 + ? UpstreamHttpProtocol.Http3 + : UpstreamHttpProtocol.Http11; return stub; } - await Http3OriginBridge.ForwardOverTcpFastAsync(fwd, server, logger, cancellationToken, - ColdOpenSessionFactory, qpackContext); - - var response = sessionArgs.HttpClient.Response; - var respHeaderRelayBaseline = - MitmCompressedRelayHelper.HeaderRelayBaseline.Capture(response.Headers); - var respStatusBaseline = response.StatusCode; - var respBodyRead = response.IsBodyRead; - var respBodyAvailable = response.BodyAvailable; - var respWriter = response.StreamBodyWriter; - - await onBeforeResponse(sessionArgs); - - var responseBodyUnchanged = response.StatusCode == respStatusBaseline - && response.IsBodyRead == respBodyRead - && response.BodyAvailable == respBodyAvailable - && response.StreamBodyWriter == respWriter; - - // Match H2 fast-path: skip Via on transparent/SOCKS; append handler/Via literals - // onto static QPACK instead of full re-encode when handlers left body unchanged. - var injectVia = !sessionArgs.IsFastPath - && !sessionArgs.IsTransparent - && !sessionArgs.IsSocks - && !string.IsNullOrEmpty(server.ViaHeaderPseudonym); - byte[] qpackHeaders = null!; - MitmCompressedRelayHelper.AddedHeaderBuffer addedRespHeaders = default; - var canRelayPreencoded = responseBodyUnchanged - && fwd.PreencodedQpackHeaders != null - && IsStaticOnlyQpackBlock(fwd.PreencodedQpackHeaders) - && MitmStaticRebuildHelper.TryPrepareStaticQpackRelay( - fwd.PreencodedQpackHeaders, respHeaderRelayBaseline, - response.Headers, out qpackHeaders, out addedRespHeaders); - - if (canRelayPreencoded) + if (mitmUnchangedH3H3) { - for (var i = 0; i < addedRespHeaders.Count; i++) - { - var h = addedRespHeaders[i]; - qpackHeaders = QpackEncoder.AppendLiteralHeader(qpackHeaders, h.Name, h.Value); - } - - if (injectVia && !addedRespHeaders.ContainsName("via") - && !response.Headers.HeaderExists("via")) - { - qpackHeaders = QpackEncoder.AppendLiteralHeader(qpackHeaders, "via", - $"3.0 {server.ViaHeaderPseudonym}"); - } - - var body = fwd.PreencodedBody; - var bodyLen = fwd.PreencodedBodyLength > 0 - ? fwd.PreencodedBodyLength - : body?.Length ?? 0; - ReadOnlyMemory bodyMem = body is { Length: > 0 } - ? body.AsMemory(0, Math.Min(bodyLen, body.Length)) - : ReadOnlyMemory.Empty; - try + var captured = await Http3OriginBridge.ForwardOverQuicFastAsync( + fwd, server, logger, cancellationToken, ColdOpenSessionFactory, + clientStream: null); + if (!captured) { - await SendPreencodedResponseAsync(stream, qpackHeaders, - bodyMem, fwd.PreencodedStreamBodyWriter, cancellationToken); + // Bad gateway was assigned onto fwd.Response — sync onto the session bag. + sessionArgs.HttpClient.Response = fwd.Response + ?? sessionArgs.HttpClient.Response; + await onBeforeResponse(sessionArgs); + await SendResponseAsync(stream, sessionArgs.HttpClient.Response, qpackContext, + cancellationToken); } - finally + else { - if (fwd.PreencodedBodyRented && body != null) - server.BufferPool.ReturnBuffer(body); + await FinishMitmPreencodedResponseAsync(sessionArgs, fwd, stream, server, + onBeforeResponse, qpackContext, cancellationToken); } } else { - if (injectVia) - response.Headers.AddHeader( - new HttpHeader("via", $"3.0 {server.ViaHeaderPseudonym}")); - if (fwd.PreencodedBodyRented && fwd.PreencodedBody != null) - { - // Response.Body holds an owned copy; return the rented Preencoded buffer. - server.BufferPool.ReturnBuffer(fwd.PreencodedBody); - fwd.PreencodedBodyRented = false; - } + await Http3OriginBridge.ForwardOverTcpFastAsync(fwd, server, logger, cancellationToken, + ColdOpenSessionFactory, qpackContext); - await SendResponseAsync(stream, response, qpackContext, cancellationToken); + await FinishMitmPreencodedResponseAsync(sessionArgs, fwd, stream, server, + onBeforeResponse, qpackContext, cancellationToken); } } else @@ -486,7 +440,9 @@ await Http3OriginBridge.ForwardAsync(sessionArgs, server, logger, cancellationTo qpackContext?.InFlightMinAbsoluteIndex.TryRemove(stream.Id, out _); streamState.ResponseClosed = true; - stream.CompleteWrites(); + // SendResponse often already FINed via completeWrites:true; only finish if still open. + if (stream.CanWrite) + stream.CompleteWrites(); } catch (Http3ConnectionException ex) { @@ -657,7 +613,12 @@ await Http3OriginBridge.ForwardOverHttp2FastAsync(fwd, server, logger, streamTok { qpackContext?.InFlightMinAbsoluteIndex.TryRemove(stream.Id, out _); streamState.ResponseClosed = true; - stream.CompleteWrites(); + // Match SendResponseAsync / origin bridge: Flush before FIN so Darwin MsQuic + // actually emits the verbatim HEADERS(+DATA) frames (skip-Flush was banned). + // Verbatim coalesce may already have FINed via completeWrites:true. + await stream.FlushAsync(streamToken); + if (stream.CanWrite) + stream.CompleteWrites(); return; } break; @@ -701,7 +662,10 @@ await SendPreencodedResponseAsync(stream, fwd.PreencodedQpackHeaders, qpackContext?.InFlightMinAbsoluteIndex.TryRemove(stream.Id, out _); streamState.ResponseClosed = true; - stream.CompleteWrites(); + // SendPreencoded / SendResponse often already FIN via completeWrites:true + Flush. + // Only CompleteWrites when the write side is still open (streamed body path). + if (stream.CanWrite) + stream.CompleteWrites(); } catch (Exception ex) when (ex is QuicException || ex.GetBaseException() is QuicException) { @@ -742,7 +706,49 @@ private static bool TryMitmUnchangedH3ToH1Lite( // NOSONAR S107 -- Baseline capt return false; if (authArgs.UpstreamHttpProtocol != UpstreamHttpProtocol.Http11) return false; - if (request.CancelRequest || sessionArgs.HttpClient.Response.Locked) + return MitmUnchangedLiteRequestMatches( + sessionArgs, request, requestHeaderRelayBaseline, + capturedRequestMethod, capturedRequestPath, capturedRequestAuthority, method); + } + + /// + /// True MITM H3→H3: same unchanged gate as H3→H1, but capture QPACK via + /// (clientStream: null) + /// then after BeforeResponse. + /// + private static bool TryMitmUnchangedH3ToH3Lite( // NOSONAR S107 -- Baseline capture args kept explicit to avoid allocating context structs on the H3 MITM hot path. + SessionEventArgs sessionArgs, + BeforeQuicAuthenticateEventArgs authArgs, + Request request, + MitmCompressedRelayHelper.HeaderRelayBaseline requestHeaderRelayBaseline, + string? capturedRequestMethod, + ByteString capturedRequestPath, + ByteString capturedRequestAuthority, + string method) + { + if (sessionArgs.IsFastPath) + return false; + if (authArgs.UpstreamHttpProtocol != UpstreamHttpProtocol.Http3) + return false; + return MitmUnchangedLiteRequestMatches( + sessionArgs, request, requestHeaderRelayBaseline, + capturedRequestMethod, capturedRequestPath, capturedRequestAuthority, method); + } + + private static bool MitmUnchangedLiteRequestMatches( // NOSONAR S107 + SessionEventArgs sessionArgs, + Request request, + MitmCompressedRelayHelper.HeaderRelayBaseline requestHeaderRelayBaseline, + string? capturedRequestMethod, + ByteString capturedRequestPath, + ByteString capturedRequestAuthority, + string method) + { + // Lite copies origin DATA without OnResponseBodyWrite; intercept body hooks need the full forward. + if (sessionArgs.Server.HasOnResponseBodyWriteSubscribers) + return false; + if (request.CancelRequest + || (sessionArgs.HttpClient.HasResponse && sessionArgs.HttpClient.Response.Locked)) return false; if (request.IsBodyRead || request.BodyAvailable) return false; @@ -759,6 +765,98 @@ private static bool TryMitmUnchangedH3ToH1Lite( // NOSONAR S107 -- Baseline capt return request.Authority.Equals(capturedRequestAuthority); } + /// + /// After H3→H1 / H3→H3 MITM lite origin fetch: BeforeResponse, then static QPACK relay or + /// full re-encode. Never emits before the response handler (noop-safe). + /// + private static async Task FinishMitmPreencodedResponseAsync( // NOSONAR S3776 -- MITM lite emit stays one method; splitting adds await/state-machine risk. + SessionEventArgs sessionArgs, + H3H2FastForward fwd, + QuicStream stream, + ProxyServer server, + Func onBeforeResponse, + QpackContext? qpackContext, + CancellationToken cancellationToken) + { + var response = sessionArgs.HttpClient.Response; + response.Headers.ArmMitmRelayBaseline(); + var respStatusBaseline = response.StatusCode; + var respBodyRead = response.IsBodyRead; + var respBodyAvailable = response.BodyAvailable; + var respWriter = response.StreamBodyWriter; + + await onBeforeResponse(sessionArgs); + + var respHeaderRelayBaseline = response.Headers.TakeMitmRelayBaseline(); + var responseBodyUnchanged = response.StatusCode == respStatusBaseline + && response.IsBodyRead == respBodyRead + && response.BodyAvailable == respBodyAvailable + && response.StreamBodyWriter == respWriter; + + // Match H2 fast-path: skip Via on transparent/SOCKS; append handler/Via literals + // onto static QPACK instead of full re-encode when handlers left body unchanged. + var injectVia = !sessionArgs.IsFastPath + && !sessionArgs.IsTransparent + && !sessionArgs.IsSocks + && !string.IsNullOrEmpty(server.ViaHeaderPseudonym); + byte[] qpackHeaders = null!; + MitmCompressedRelayHelper.AddedHeaderBuffer addedRespHeaders = default; + var canRelayPreencoded = responseBodyUnchanged + && fwd.PreencodedQpackHeaders != null + && IsStaticOnlyQpackBlock(fwd.PreencodedQpackHeaders) + && MitmStaticRebuildHelper.TryPrepareStaticQpackRelay( + fwd.PreencodedQpackHeaders, respHeaderRelayBaseline, + response.Headers, out qpackHeaders, out addedRespHeaders); + + if (canRelayPreencoded) + { + for (var i = 0; i < addedRespHeaders.Count; i++) + { + var h = addedRespHeaders[i]; + qpackHeaders = QpackEncoder.AppendLiteralHeader(qpackHeaders, h.Name, h.Value); + } + + if (injectVia && !addedRespHeaders.ContainsName("via") + && !response.Headers.HeaderExists("via")) + { + qpackHeaders = QpackEncoder.AppendLiteralHeader(qpackHeaders, "via", + $"3.0 {server.ViaHeaderPseudonym}"); + } + + var body = fwd.PreencodedBody; + var bodyLen = fwd.PreencodedBodyLength > 0 + ? fwd.PreencodedBodyLength + : body?.Length ?? 0; + ReadOnlyMemory bodyMem = body is { Length: > 0 } + ? body.AsMemory(0, Math.Min(bodyLen, body.Length)) + : ReadOnlyMemory.Empty; + try + { + await SendPreencodedResponseAsync(stream, qpackHeaders, + bodyMem, fwd.PreencodedStreamBodyWriter, cancellationToken); + } + finally + { + if (fwd.PreencodedBodyRented && body != null) + server.BufferPool.ReturnBuffer(body); + } + } + else + { + if (injectVia) + response.Headers.AddHeader( + new HttpHeader("via", $"3.0 {server.ViaHeaderPseudonym}")); + if (fwd.PreencodedBodyRented && fwd.PreencodedBody != null) + { + // Response.Body holds an owned copy; return the rented Preencoded buffer. + server.BufferPool.ReturnBuffer(fwd.PreencodedBody); + fwd.PreencodedBodyRented = false; + } + + await SendResponseAsync(stream, response, qpackContext, cancellationToken); + } + } + /// /// Consumes remaining client DATA until END_STREAM without exposing a body (bodiless GET/HEAD). /// Used on the interception-off fast path so we never allocate Http3RequestBodyPump closures. @@ -967,9 +1065,9 @@ private static async Task SendSimpleStatusResponseAsync( { var headers = new List<(string, string)> { (":status", statusCode.ToString()) }; var encoded = QpackEncoder.Encode(headers, qpackContext); - await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, encoded, ct); + await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, encoded, ct, completeWrites: true); await stream.FlushAsync(ct); - stream.CompleteWrites(); + // completeWrites:true already FINed the QuicStream write side. } /// @@ -1015,14 +1113,20 @@ private static async Task SendPreencodedResponseAsync( if (body.Length >= 16 * 1024) { - await Http3Frame.WriteHeadersAndDataAsync(stream, qpackHeaders, body, ct); + await Http3Frame.WriteHeadersAndDataAsync(stream, qpackHeaders, body, ct, completeWrites: true); await stream.FlushAsync(ct); return; } - await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, qpackHeaders, ct); if (body.Length > 0) - await Http3Frame.WriteAsync(stream, Http3FrameType.Data, body, ct); + { + await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, qpackHeaders, ct); + await Http3Frame.WriteAsync(stream, Http3FrameType.Data, body, ct, completeWrites: true); + } + else + { + await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, qpackHeaders, ct, completeWrites: true); + } await stream.FlushAsync(ct); } @@ -1072,14 +1176,20 @@ private static async Task SendResponseAsync(QuicStream stream, Response response // there raised cool absolutes and missed Windows CI (latency bundle revert). if (body is { Length: >= 16 * 1024 }) { - await Http3Frame.WriteHeadersAndDataAsync(stream, qpackHeaders, body, ct); + await Http3Frame.WriteHeadersAndDataAsync(stream, qpackHeaders, body, ct, completeWrites: true); await stream.FlushAsync(ct); return; } - await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, qpackHeaders, ct); if (body is { Length: > 0 }) - await Http3Frame.WriteAsync(stream, Http3FrameType.Data, body, ct); + { + await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, qpackHeaders, ct); + await Http3Frame.WriteAsync(stream, Http3FrameType.Data, body, ct, completeWrites: true); + } + else + { + await Http3Frame.WriteAsync(stream, Http3FrameType.Headers, qpackHeaders, ct, completeWrites: true); + } await stream.FlushAsync(ct); } diff --git a/src/Titanium.Web.Proxy/MitmExclusionDefaults.cs b/src/Titanium.Web.Proxy/MitmExclusionDefaults.cs new file mode 100644 index 000000000..80c4b919a --- /dev/null +++ b/src/Titanium.Web.Proxy/MitmExclusionDefaults.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Models; + +namespace Titanium.Web.Proxy; + +/// +/// Default hostname exclusions for MITM proxies (Microsoft identity / certificate pinning). +/// Use with and +/// . +/// +public static class MitmExclusionDefaults +{ + /// WinINET / system-proxy bypass patterns for Microsoft identity endpoints (Entra / WAM / RDP). + public static readonly string[] SystemProxyBypassRules = + [ + "*.microsoftonline.com", + "*.microsoftonline-p.com", + "login.windows.net", + "*.login.microsoft.com", + "login.live.com", + "account.live.com", + "*.msauth.net", + "*.msftauth.net", + "enterpriseregistration.windows.net", + ]; + + /// Pinning hosts that should tunnel (DecryptSsl=false) but stay visible. + public static readonly string[] TunnelOnlyPinningDomains = ["dropbox.com", "webex.com"]; + + /// + /// Builds with factory identity bypass rules and loopback + /// (). + /// + public static SystemProxySettings CreateSystemProxySettings() => + CreateSystemProxySettings(proxyLoopback: true, null, MitmExclusionMode.Merge); + + /// + /// Builds with factory identity bypass rules + /// (). + /// + public static SystemProxySettings CreateSystemProxySettings(bool proxyLoopback) => + CreateSystemProxySettings(proxyLoopback, null, MitmExclusionMode.Merge); + + /// + /// Builds with identity bypass rules and optional user additions + /// (). + /// + public static SystemProxySettings CreateSystemProxySettings( + bool proxyLoopback, + IEnumerable? additionalBypassRules) => + CreateSystemProxySettings(proxyLoopback, additionalBypassRules, MitmExclusionMode.Merge); + + /// + /// Builds from factory and/or caller bypass rules. + /// + /// When true, localhost uses the proxy (platform-specific loopback rule). + /// + /// Extra rules in , or the full authoritative list in + /// . + /// + /// Merge factory OS-bypass defaults, or replace them with . + public static SystemProxySettings CreateSystemProxySettings( + bool proxyLoopback, + IEnumerable? bypassRules, + MitmExclusionMode mode) + { + var settings = new SystemProxySettings(); + if (mode == MitmExclusionMode.Merge) + { + foreach (var rule in SystemProxyBypassRules) + { + settings.BypassRules.Add(rule); + } + } + + if (bypassRules is not null) + { + foreach (var rule in bypassRules.Where(r => !string.IsNullOrWhiteSpace(r))) + { + settings.BypassRules.Add(rule.Trim()); + } + } + + settings.ProxyLoopback = proxyLoopback; + return settings; + } + + /// Returns true when CONNECT should use SSL passthrough instead of MITM (Merge mode). + public static bool ShouldDisableSslDecrypt(string? hostname) => + ShouldDisableSslDecrypt(hostname, userSkipHosts: null, userOnlyHosts: null, MitmExclusionMode.Merge); + + /// + /// Returns true when TLS should stay opaque (no MITM decrypt) using . + /// + public static bool ShouldDisableSslDecrypt( + string? hostname, + IEnumerable? userSkipHosts, + IEnumerable? userOnlyHosts) => + ShouldDisableSslDecrypt(hostname, userSkipHosts, userOnlyHosts, MitmExclusionMode.Merge); + + /// + /// Returns true when TLS should stay opaque (no MITM decrypt). + /// : factory SSO/pinning hosts always skip, then user skip / + /// optional decrypt-only allowlist. + /// : only and optional + /// apply (factory hosts are not forced). + /// + public static bool ShouldDisableSslDecrypt( + string? hostname, + IEnumerable? userSkipHosts, + IEnumerable? userOnlyHosts, + MitmExclusionMode mode) + { + if (string.IsNullOrEmpty(hostname)) + { + return false; + } + + if (mode == MitmExclusionMode.Merge && IsBuiltInSslBypass(hostname)) + { + return true; + } + + if (MatchesAny(hostname, userSkipHosts)) + { + return true; + } + + var only = userOnlyHosts? + .Where(h => !string.IsNullOrWhiteSpace(h)) + .ToList(); + if (only is { Count: > 0 } && !MatchesAny(hostname, only)) + { + return true; + } + + return false; + } + + /// Wildcard-aware hostname match (*.example.com). + public static bool HostnameMatches(string hostname, string pattern) + { + if (string.IsNullOrWhiteSpace(pattern)) + { + return false; + } + + pattern = pattern.Trim(); + if (pattern.StartsWith("*.", StringComparison.Ordinal)) + { + var suffix = pattern[1..]; + return hostname.EndsWith(suffix, StringComparison.OrdinalIgnoreCase) + || hostname.Equals(pattern[2..], StringComparison.OrdinalIgnoreCase); + } + + return hostname.Equals(pattern, StringComparison.OrdinalIgnoreCase) + || hostname.EndsWith("." + pattern, StringComparison.OrdinalIgnoreCase); + } + + /// Registers per-CONNECT DecryptSsl gating on an explicit endpoint (Merge mode). + public static void ApplyDecryptExclusions( + ExplicitProxyEndPoint endPoint, + Func decryptHttpsEnabled) => + ApplyDecryptExclusions(endPoint, decryptHttpsEnabled, null, null, MitmExclusionMode.Merge); + + /// Registers per-CONNECT DecryptSsl gating on an explicit endpoint (Merge mode). + public static void ApplyDecryptExclusions( + ExplicitProxyEndPoint endPoint, + Func decryptHttpsEnabled, + IEnumerable? decryptSkipHosts, + IEnumerable? decryptOnlyHosts) => + ApplyDecryptExclusions( + endPoint, decryptHttpsEnabled, decryptSkipHosts, decryptOnlyHosts, MitmExclusionMode.Merge); + + /// Registers per-CONNECT DecryptSsl gating on an explicit endpoint. + public static void ApplyDecryptExclusions( + ExplicitProxyEndPoint endPoint, + Func decryptHttpsEnabled, + IEnumerable? decryptSkipHosts, + IEnumerable? decryptOnlyHosts, + MitmExclusionMode mode) + { + endPoint.BeforeTunnelConnectRequest += (_, e) => + { + var host = e.HttpClient.Request.RequestUri?.Host + ?? e.HttpClient.Request.Host; + e.DecryptSsl = decryptHttpsEnabled() + && !ShouldDisableSslDecrypt(host, decryptSkipHosts, decryptOnlyHosts, mode); + return Task.CompletedTask; + }; + } + + /// True when matches factory OS-bypass or tunnel-only defaults. + public static bool IsBuiltInSslBypass(string hostname) + { + if (SystemProxyBypassRules.Any(rule => HostnameMatches(hostname, rule))) + { + return true; + } + + return TunnelOnlyPinningDomains.Any(domain => + hostname.Equals(domain, StringComparison.OrdinalIgnoreCase) + || hostname.EndsWith("." + domain, StringComparison.OrdinalIgnoreCase)); + } + + private static bool MatchesAny(string hostname, IEnumerable? patterns) + { + if (patterns is null) + { + return false; + } + + return patterns.Any(pattern => HostnameMatches(hostname, pattern)); + } +} diff --git a/src/Titanium.Web.Proxy/MitmExclusionMode.cs b/src/Titanium.Web.Proxy/MitmExclusionMode.cs new file mode 100644 index 000000000..d7d1532f8 --- /dev/null +++ b/src/Titanium.Web.Proxy/MitmExclusionMode.cs @@ -0,0 +1,19 @@ +namespace Titanium.Web.Proxy; + +/// +/// How factory MITM exclusion defaults interact with caller-supplied host lists. +/// +public enum MitmExclusionMode +{ + /// + /// Factory OS-bypass and tunnel/SSO decrypt skips are always applied, then caller lists add more + /// (and optional decrypt-only allowlist). Default for back-compat. + /// + Merge = 0, + + /// + /// Caller lists are authoritative. Factory defaults are not re-injected — use them only as a + /// seed when building the lists you pass in. + /// + Replace = 1, +} diff --git a/src/Titanium.Web.Proxy/Models/HttpHeader.cs b/src/Titanium.Web.Proxy/Models/HttpHeader.cs index eea65fb93..afb2aa667 100644 --- a/src/Titanium.Web.Proxy/Models/HttpHeader.cs +++ b/src/Titanium.Web.Proxy/Models/HttpHeader.cs @@ -88,6 +88,16 @@ internal HttpHeader(ByteString name, ByteString value) ValueData = value; } + /// + /// True for HPACK static-table rows shared across streams. Must not be mutated in place — + /// replaces the entry on SetOrAdd. + /// + internal bool IsSharedStaticTableEntry { get; private init; } + + /// HPACK Appendix A entry — immutable shared instance. + internal static HttpHeader CreateSharedStaticTableEntry(ByteString name, ByteString value) => + new(name, value) { IsSharedStaticTableEntry = true }; + private protected HttpHeader(ByteString name, ByteString value, bool headerEntry) { // special header entry created in inherited class with empty name @@ -118,12 +128,16 @@ internal static int SizeOf(ByteString name, ByteString value) internal void SetValue(string value) { + if (IsSharedStaticTableEntry) + throw new InvalidOperationException("HPACK static-table headers are immutable; replace the HeaderCollection entry instead."); valueString = value; ValueData = value.GetByteString(); } internal void SetValue(KnownHeader value) { + if (IsSharedStaticTableEntry) + throw new InvalidOperationException("HPACK static-table headers are immutable; replace the HeaderCollection entry instead."); valueString = value.String; ValueData = value.String8; } diff --git a/src/Titanium.Web.Proxy/Models/HttpInterceptionContext.cs b/src/Titanium.Web.Proxy/Models/HttpInterceptionContext.cs index e59656f4e..43ab08503 100644 --- a/src/Titanium.Web.Proxy/Models/HttpInterceptionContext.cs +++ b/src/Titanium.Web.Proxy/Models/HttpInterceptionContext.cs @@ -34,6 +34,9 @@ public readonly struct HttpInterceptionContext /// Remote IP endpoint of the connected client (null when unavailable). public IPEndPoint? ClientRemoteEndPoint { get; init; } - /// Process ID of the local client (explicit proxy / Windows only; null otherwise). + /// + /// Process ID of the local client when available (Windows/Linux/macOS explicit-proxy paths). + /// Null when unset on the fast path or when the client is remote / unresolved. + /// public int? ClientProcessId { get; init; } } diff --git a/src/Titanium.Web.Proxy/Models/SystemProxySettings.cs b/src/Titanium.Web.Proxy/Models/SystemProxySettings.cs index 561a7e056..c0f9212ea 100644 --- a/src/Titanium.Web.Proxy/Models/SystemProxySettings.cs +++ b/src/Titanium.Web.Proxy/Models/SystemProxySettings.cs @@ -38,6 +38,12 @@ public class SystemProxySettings /// public SystemProxyBypassRuleMode BypassRuleMode { get; set; } = SystemProxyBypassRuleMode.Merge; + /// + /// Builds the WinINET-style semicolon-separated bypass list that would be applied to the OS. + /// + public string BuildProxyOverride(string? currentProxyOverride) => + BuildProxyOverrideInternal(currentProxyOverride); + /// /// Validates the configured bypass rules, throwing when any rule is malformed. /// @@ -54,7 +60,7 @@ internal void Validate() } } - internal string BuildProxyOverride(string? currentProxyOverride) + internal string BuildProxyOverrideInternal(string? currentProxyOverride) { var result = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/src/Titanium.Web.Proxy/Models/TransparentBaseProxyEndPoint.cs b/src/Titanium.Web.Proxy/Models/TransparentBaseProxyEndPoint.cs index 543a7d4fe..95a80662b 100644 --- a/src/Titanium.Web.Proxy/Models/TransparentBaseProxyEndPoint.cs +++ b/src/Titanium.Web.Proxy/Models/TransparentBaseProxyEndPoint.cs @@ -22,8 +22,10 @@ protected TransparentBaseProxyEndPoint(IPAddress ipAddress, int port, bool decry /// /// Optional fixed upstream server to forward all traffic on this endpoint to. - /// Only the TCP connection target is changed; the original host is still used - /// for TLS SNI/certificate validation and the HTTP Host header. + /// TCP connects to this host. For re-encrypt ( false), + /// TLS SNI and HTTP Host stay on the client authority. For TLS terminate + /// ( true), Host is rewritten to this host and + /// so HTTP origins see their own bind identity. /// public string? ForwardHost { get; set; } diff --git a/src/Titanium.Web.Proxy/Network/HttpWebClient.cs b/src/Titanium.Web.Proxy/Network/HttpWebClient.cs index a720f92d0..28af86612 100644 --- a/src/Titanium.Web.Proxy/Network/HttpWebClient.cs +++ b/src/Titanium.Web.Proxy/Network/HttpWebClient.cs @@ -43,7 +43,6 @@ internal HttpWebClient(ConnectRequest? connectRequest, Request request, Lazy - /// Stores internal data for the session. + /// Stores internal data for the session (lazy — most H2/H3 Lite streams never touch it). /// - internal InternalDataStore Data { get; } = new(); + internal InternalDataStore Data => data ??= new(); + + private InternalDataStore? data; /// /// Gets or sets the user data. @@ -127,13 +128,32 @@ internal TcpServerConnection Connection public Request Request { get; } /// - /// Web Response. + /// Web Response. Created on first access so H2/H3 MITM Lite request-only work + /// does not allocate a Response + HeaderCollection graph per stream up front. + /// CompareExchange: H2 request/response legs can race the first access. /// - public Response Response { get; internal set; } + public Response Response + { + get + { + var existing = response; + if (existing != null) + return existing; + var created = new Response(); + return Interlocked.CompareExchange(ref response, created, null) ?? created; + } + internal set => response = value; + } + + /// True when a response object has already been materialized for this exchange. + internal bool HasResponse => response != null; + + private Response? response; /// - /// PID of the process that is created the current session when client is running in this machine - /// If client is remote then this will return + /// PID of the local client process for this session (Windows, Linux, and macOS). + /// Remote clients, unsupported platforms, and unresolved sockets yield a non-positive value. + /// See . /// public Lazy ProcessId { get; internal set; } @@ -368,8 +388,8 @@ internal void ResetForKeepAlive() upstreamConnectionTiming = null; CloseServerConnection = false; Request.ResetForKeepAlive(); - Response.ResetForKeepAlive(); - Data.Clear(); + response?.ResetForKeepAlive(); + data?.Clear(); UserData = null; } diff --git a/src/Titanium.Web.Proxy/Network/Quic/IQuicConnectionFactory.cs b/src/Titanium.Web.Proxy/Network/Quic/IQuicConnectionFactory.cs index ffd38c727..31a282d05 100644 --- a/src/Titanium.Web.Proxy/Network/Quic/IQuicConnectionFactory.cs +++ b/src/Titanium.Web.Proxy/Network/Quic/IQuicConnectionFactory.cs @@ -21,5 +21,6 @@ Task CreateAsync( // NOSONAR S107 -- Factory contract keep IExternalProxy? upStreamProxy, string cacheKey, RemoteCertificateValidationCallback? remoteCertificateValidationCallback, - CancellationToken cancellationToken); + CancellationToken cancellationToken, + bool failFastHandshake = true); } diff --git a/src/Titanium.Web.Proxy/Network/Quic/QuicConnectionFactory.cs b/src/Titanium.Web.Proxy/Network/Quic/QuicConnectionFactory.cs index 74732abab..4916d7db7 100644 --- a/src/Titanium.Web.Proxy/Network/Quic/QuicConnectionFactory.cs +++ b/src/Titanium.Web.Proxy/Network/Quic/QuicConnectionFactory.cs @@ -51,16 +51,19 @@ public async Task CreateAsync( IExternalProxy? upStreamProxy, string cacheKey, RemoteCertificateValidationCallback? remoteCertificateValidationCallback, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool failFastHandshake = true) { if (upStreamProxy != null) throw new QuicProxyNotSupportedException(upStreamProxy.ToString() ?? "unknown"); - // Auto-policy H3 (SVCB/Alt-Svc) must fail fast when UDP/443 is filtered: the .NET default - // HandshakeTimeout is 10s, which makes every first request to an H3-advertising origin hang - // before TCP fallback. Cap at ConnectTimeOutSeconds but never above 3s so page loads stay - // responsive on networks that advertise H3 but cannot complete a QUIC handshake. - var handshakeSeconds = Math.Clamp(_proxyServer.ConnectTimeOutSeconds, 1, 3); + // Auto-policy H3 (SVCB/Alt-Svc) and pool warmups must fail fast when UDP is filtered: the + // .NET default HandshakeTimeout is 10s, which makes every first request to an H3-advertising + // origin hang before TCP fallback. Cap at 3s so page loads stay responsive. + // Forced H3 has no TCP fallback — use the full ConnectTimeOutSeconds budget (same as TCP). + var handshakeSeconds = failFastHandshake + ? Math.Clamp(_proxyServer.ConnectTimeOutSeconds, 1, 3) + : Math.Max(1, _proxyServer.ConnectTimeOutSeconds); var clientOptions = new QuicClientConnectionOptions { diff --git a/src/Titanium.Web.Proxy/Network/Quic/QuicConnectionPool.cs b/src/Titanium.Web.Proxy/Network/Quic/QuicConnectionPool.cs index 9f8314436..83095a7e6 100644 --- a/src/Titanium.Web.Proxy/Network/Quic/QuicConnectionPool.cs +++ b/src/Titanium.Web.Proxy/Network/Quic/QuicConnectionPool.cs @@ -93,14 +93,20 @@ internal QuicConnectionPool(ProxyServer proxyServer, IQuicConnectionFactory fact /// security identity so different origins sharing the same connect target are not coalesced. /// When , defaults to . /// - internal async ValueTask GetOrCreateAsync( + /// + /// When (default), cap QUIC handshake at 3s for Alt-Svc/SVCB and + /// warmup fail-fast before TCP fallback. Pass for Forced H3 so the + /// dial uses the full budget. + /// + internal async ValueTask GetOrCreateAsync( // NOSONAR S107 -- Pool acquire keeps origin identity, proxy, and handshake flags explicit. string connectHost, int port, IPEndPoint? upStreamEndPoint, IExternalProxy? upStreamProxy, RemoteCertificateValidationCallback? remoteCertificateValidationCallback, CancellationToken cancellationToken, - string? sniHost = null) + string? sniHost = null, + bool failFastHandshake = true) { if (_draining) throw new InvalidOperationException("QuicConnectionPool is draining."); @@ -129,7 +135,8 @@ internal async ValueTask GetOrCreateAsync( var created = await _factory.CreateAsync( connectHost, effectiveSniHost, port, upStreamEndPoint, upStreamProxy, - cacheKey, remoteCertificateValidationCallback, cancellationToken); + cacheKey, remoteCertificateValidationCallback, cancellationToken, + failFastHandshake); // Nothing else can see `created` yet, so this cannot fail. created.TryAcquireStream(); diff --git a/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Body.cs b/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Body.cs new file mode 100644 index 000000000..2781a12b4 --- /dev/null +++ b/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Body.cs @@ -0,0 +1,532 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net.Security; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Titanium.Web.Proxy.Compression; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Exceptions; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Logging; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Options; +using Titanium.Web.Proxy.Shared; +using Titanium.Web.Proxy.StreamExtended.BufferPool; +using Titanium.Web.Proxy.StreamExtended.Network; + +namespace Titanium.Web.Proxy.Helpers; + +internal partial class HttpStream : Stream, IHttpStreamWriter, IHttpStreamReader, IPeekStream, ITransportCapableStream +{ + /// + /// Writes the byte array body to the stream; optionally chunked + /// + /// + /// + /// + /// Optional trailer headers to emit after the terminating zero-length chunk (ignored when + /// is false - trailers are not defined for fixed-length bodies). + /// + /// + /// + internal ValueTask WriteBodyAsync(byte[] data, bool isChunked, HeaderCollection? trailingHeaders, + CancellationToken cancellationToken) + { + if (isChunked) return WriteBodyChunkedAsync(data, trailingHeaders, cancellationToken); + + return WriteAsync(data, cancellationToken: cancellationToken); + } + + public async Task CopyBodyAsync(RequestResponseBase requestResponse, bool useOriginalHeaderValues, + IHttpStreamWriter writer, TransformationMode transformation, bool isRequest, SessionEventArgs args, + CancellationToken cancellationToken) + { + var isChunked = useOriginalHeaderValues ? requestResponse.OriginalIsChunked : requestResponse.IsChunked; + var contentLength = useOriginalHeaderValues + ? requestResponse.OriginalContentLength + : requestResponse.ContentLength; + + if (transformation == TransformationMode.None) + { + await CopyBodyAsync(writer, isChunked, contentLength, isRequest, args, cancellationToken); + return; + } + + LimitedStream limitedStream; + List? decompressLayers = null; + + var contentEncoding = useOriginalHeaderValues + ? requestResponse.OriginalContentEncoding + : requestResponse.ContentEncoding; + + Stream s = limitedStream = new LimitedStream(this, bufferPool, isChunked, contentLength, + requestResponse.TrailingHeaders); + + if (transformation == TransformationMode.Uncompress && contentEncoding != null) + { + // Content-Encoding may list multiple stacked encodings (e.g. "gzip, br"); each layer + // becomes its own chained decompression stream, applied in reverse order. + (s, decompressLayers) = CompressionUtil.CreateDecompressionChain(s, contentEncoding); + } + + // leaveOpen: true so disposing the wrapper returns its pooled buffer without + // disposing the underlying limited/decompression stream (handled in finally). + var http = new HttpStream(server, s, bufferPool, cancellationToken, true); + try + { + await http.CopyBodyAsync(writer, false, -1, isRequest, args, cancellationToken); + } + finally + { + await http.DisposeAsync(); + + if (decompressLayers != null) + for (var i = decompressLayers.Count - 1; i >= 0; i--) + await decompressLayers[i].DisposeAsync(); + + await limitedStream.Finish(); + await limitedStream.DisposeAsync(); + } + } + + /// + /// Copies the specified content length number of bytes to the output stream from the given inputs stream + /// optionally chunked + /// + /// + /// + /// + /// + /// + /// + public Task CopyBodyAsync(IHttpStreamWriter writer, bool isChunked, long contentLength, + bool isRequest, + SessionEventArgs args, CancellationToken cancellationToken) + { + var isResponse = !isRequest; + + // The per-chunk body-write hook needs a real duplex network transport on both ends (plain socket or + // TLS-decrypted) - it is not meaningful for in-memory/decompression streams. Checked via the internal + // ITransportCapableStream marker rather than the public IHttpStreamWriter/IHttpStreamReader interfaces, + // so external implementers of those public interfaces are not source-broken; one that doesn't also + // implement the marker is simply treated as not supporting the hook (today's behavior, preserved). + var readerSupportsHook = SupportsBodyWriteHook; + var writerSupportsHook = writer is ITransportCapableStream { SupportsBodyWriteHook: true }; // NOSONAR S3060 -- preserves external interface compatibility. + + if (readerSupportsHook && writerSupportsHook && !args.IsFastPath && + ((isRequest && args.HttpClient.Request.OriginalHasBody && !args.HttpClient.Request.IsBodyRead && server.ShouldCallBeforeRequestBodyWrite()) || + (isResponse && args.HttpClient.Response.OriginalHasBody && !args.HttpClient.Response.IsBodyRead && server.ShouldCallBeforeResponseBodyWrite()))) + { + return HandleBodyWrite(writer, isChunked, isRequest, args, cancellationToken); + } + + // For chunked request we need to read data as they arrive, until we reach a chunk end symbol + if (isChunked) return CopyBodyChunkedAsync(writer, isRequest, args, cancellationToken); + + // http 1.0 or the stream reader limits the stream + if (contentLength == -1) contentLength = long.MaxValue; + + // If not chunked then its easy just read the amount of bytes mentioned in content length header + return CopyBytesToStream(writer, contentLength, isRequest, args, cancellationToken); + } + + /// + /// Streams the body from this source stream to the target writer, invoking the + /// OnRequestBodyWrite / OnResponseBodyWrite handler for each buffer-sized piece so consumers + /// can inspect or modify the body chunk-by-chunk without buffering the whole body. + /// The bytes are exposed exactly as they arrive on the wire (still content-encoded if the message + /// uses Content-Encoding); on-the-fly decompression/recompression is not performed here in order to + /// preserve exact framing and length. Reads are bounded by bufferPool.BufferSize to keep memory flat. + /// + private async Task HandleBodyWrite(IHttpStreamWriter writer, bool isChunked, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + bool isRequest, SessionEventArgs args, CancellationToken cancellationToken) + { + var requestResponse = isRequest ? (RequestResponseBase)args.HttpClient.Request : args.HttpClient.Response; + + var originalContentLength = requestResponse.OriginalContentLength; + var originalIsChunked = requestResponse.OriginalIsChunked; + + async ValueTask writeFramed(byte[] data) + { + if (data.Length == 0) return; + + if (isChunked) + { + await writer.WriteLineAsync(data.Length.ToString("x"), cancellationToken); + await writer.WriteAsync(data, 0, data.Length, cancellationToken); + await writer.WriteLineAsync(cancellationToken); + } + else + { + await writer.WriteAsync(data, 0, data.Length, cancellationToken); + } + } + + async ValueTask writeTerminator() + { + if (isChunked) + { + await writer.WriteLineAsync("0", cancellationToken); + await ChunkedTrailerHelper.WriteTrailingHeadersAsync(writer, + requestResponse.HasTrailingHeaders ? requestResponse.TrailingHeaders : null, + cancellationToken); + } + } + + // returns true when writing should stop (either source end reached or handler requested it) + async Task emit(byte[] piece, bool isLastPiece) + { + var eventArgs = new BeforeBodyWriteEventArgs(args, piece, isChunked, isLastPiece); + + if (isRequest) + await server.OnBeforeRequestBodyWrite(eventArgs); + else + await server.OnBeforeResponseBodyWrite(eventArgs); + + if (eventArgs.BodyBytes is { Length: > 0 }) await writeFramed(eventArgs.BodyBytes); + + return isLastPiece || eventArgs.IsLastChunk; + } + + var buffer = bufferPool.GetBuffer(); + + // The handler ended the message before the source's real end (isLastChunk / handler-driven stop). + // Drain (read and discard) everything still remaining on the source - the rest of the chunk in + // progress, any further chunks, and the trailer block - so the underlying connection is left at a + // clean message boundary and can still be safely reused/pooled, even though none of this is + // relayed to `writer` (the consumer already decided to stop emitting). + async Task drainRemainingChunkedBody(long remainingInCurrentChunk) + { + while (remainingInCurrentChunk > 0) + { + var toRead = (int)Math.Min(buffer.Length, remainingInCurrentChunk); + var bytesRead = await ReadAsync(buffer.AsMemory(0, toRead), cancellationToken); + if (bytesRead == 0) return; + remainingInCurrentChunk -= bytesRead; + } + + // trailing CRLF of the chunk that was in progress + await ReadLineAsync(cancellationToken); + + while (true) + { + var chunkHead = await ReadLineAsync(cancellationToken); + if (chunkHead == null) return; + + if (!ChunkSizeParser.TryParse(chunkHead, ProxyLimits.DefaultMaxChunkSizeBytes, out var chunkSize)) + throw new ProxyHttpException($"Invalid chunk length: '{chunkHead}'", null, null); + + if (chunkSize == 0) + { + // discard the trailer block too - it belongs to a message we chose not to forward in full + await ChunkedTrailerHelper.ReadTrailingHeaders(this, new HeaderCollection(), null, + cancellationToken); + return; + } + + var toDiscard = chunkSize; + while (toDiscard > 0) + { + var toRead = (int)Math.Min(buffer.Length, toDiscard); + var bytesRead = await ReadAsync(buffer.AsMemory(0, toRead), cancellationToken); + if (bytesRead == 0) return; + toDiscard -= bytesRead; + } + + // trailing CRLF after chunk data + await ReadLineAsync(cancellationToken); + } + } + + try + { + if (originalIsChunked) + { + while (true) + { + var chunkHead = await ReadLineAsync(cancellationToken); + if (chunkHead == null) break; + + if (!ChunkSizeParser.TryParse(chunkHead, ProxyLimits.DefaultMaxChunkSizeBytes, out var chunkSize)) + throw new ProxyHttpException($"Invalid chunk length: '{chunkHead}'", null, null); + + if (chunkSize == 0) + { + // Read the optional trailer header block, strictly through the terminating blank + // line, populating requestResponse.TrailingHeaders (writeTerminator() below + // re-emits them for `writer`). See ChunkedTrailerHelper for why this is bounded. + await ChunkedTrailerHelper.ReadTrailingHeaders(this, requestResponse.TrailingHeaders, + null, cancellationToken); + await emit(Array.Empty(), true); + break; + } + + var remaining = chunkSize; + var stop = false; + while (remaining > 0) + { + var toRead = (int)Math.Min(buffer.Length, remaining); + var bytesRead = await ReadAsync(buffer.AsMemory(0, toRead), cancellationToken); + if (bytesRead == 0) + throw new ProxyHttpException("Unexpected end of stream while reading chunk body.", null, args); + + remaining -= bytesRead; + + if (isRequest) args.OnDataSent(buffer, 0, bytesRead); + else args.OnDataReceived(buffer, 0, bytesRead); + + // Fresh array per chunk so BeforeBodyWrite handlers may retain BodyBytes + // across callbacks without seeing later overwrites (matches H2 body-write). + var piece = new byte[bytesRead]; + Buffer.BlockCopy(buffer, 0, piece, 0, bytesRead); + + if (await emit(piece, false)) + { + stop = true; + break; + } + } + + if (stop) + { + await drainRemainingChunkedBody(remaining); + break; + } + + // trailing CRLF after chunk data + await ReadLineAsync(cancellationToken); + } + + await writeTerminator(); + } + else + { + var remaining = originalContentLength == -1 ? long.MaxValue : originalContentLength; + + while (remaining > 0) + { + var toRead = (int)Math.Min(buffer.Length, remaining); + var bytesRead = await ReadAsync(buffer.AsMemory(0, toRead), cancellationToken); + if (bytesRead == 0) break; + + remaining -= bytesRead; + + if (isRequest) args.OnDataSent(buffer, 0, bytesRead); + else args.OnDataReceived(buffer, 0, bytesRead); + + var piece = new byte[bytesRead]; + Buffer.BlockCopy(buffer, 0, piece, 0, bytesRead); + + if (await emit(piece, remaining == 0)) break; + } + + await writeTerminator(); + } + } + finally + { + bufferPool.ReturnBuffer(buffer); + } + } + + /// + /// Copies the given input bytes to output stream chunked + /// + /// + /// Optional trailer headers to emit after the terminating zero-length chunk. + /// + /// + private async ValueTask WriteBodyChunkedAsync(byte[] data, HeaderCollection? trailingHeaders, + CancellationToken cancellationToken) + { + var chunkHead = Encoding.ASCII.GetBytes(data.Length.ToString("x2")); + + await WriteAsync(chunkHead, cancellationToken: cancellationToken); + await WriteLineAsync(cancellationToken); + await WriteAsync(data, cancellationToken: cancellationToken); + await WriteLineAsync(cancellationToken); + + await WriteLineAsync("0", cancellationToken); + await ChunkedTrailerHelper.WriteTrailingHeadersAsync(this, trailingHeaders, cancellationToken); + } + + /// + /// Copies the streams chunked + /// + /// + /// + /// + /// + private async Task CopyBodyChunkedAsync(IHttpStreamWriter writer, bool isRequest, SessionEventArgs args, + CancellationToken cancellationToken) + { + var requestResponse = isRequest ? (RequestResponseBase)args.HttpClient.Request : args.HttpClient.Response; + + while (true) + { + var chunkHead = await ReadLineAsync(cancellationToken); + if (chunkHead == null) return; + + if (!ChunkSizeParser.TryParse(chunkHead, ProxyLimits.DefaultMaxChunkSizeBytes, out var chunkSize)) + throw new ProxyHttpException($"Invalid chunk length: '{chunkHead}'", null, null); + + await writer.WriteLineAsync(chunkHead, cancellationToken); + + if (chunkSize == 0) + { + // Read the optional trailer header block, strictly through the terminating blank line - + // even when there turn out to be no trailers - so a pooled keep-alive connection never + // retains stray trailer bytes that would corrupt the next message (see ChunkedTrailerHelper). + // This is a pure pass-through relay, so the exact raw lines are also captured and forwarded + // to `writer` byte-for-byte below, rather than re-serializing the parsed HeaderCollection. + var rawTrailerLines = new List(); + await ChunkedTrailerHelper.ReadTrailingHeaders(this, requestResponse.TrailingHeaders, + rawTrailerLines, cancellationToken); + + await ChunkedTrailerHelper.WriteRawTrailingLinesAsync(writer, rawTrailerLines, cancellationToken); + + break; + } + + await CopyBytesToStream(writer, chunkSize, isRequest, args, cancellationToken); + + await writer.WriteLineAsync(cancellationToken); + + // chunk trail + await ReadLineAsync(cancellationToken); + } + } + + /// + /// Copies the specified bytes to the stream from the input stream + /// + /// + /// + /// + /// + /// + private async Task CopyBytesToStream(IHttpStreamWriter writer, long count, bool isRequest, SessionEventArgs args, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + CancellationToken cancellationToken) + { + var remainingBytes = count; + var httpWriter = writer as HttpStream; + // YARP StreamCopier uses 64 KiB; H2→H1 large-read bypass never ran on this FillBuffer loop. + // Empty parser window + remaining ≥ streamBuffer → rent a large window and ReadAsync so + // BaseStream fills directly (classic BufferedStream rule). Cuts origin read / SslStream + // WriteAsync count on known-CL reverse bodies (e.g. 256 KiB: ~32×8 KiB → ~4×64 KiB). + const int largeCopyGrain = 64 * 1024; + byte[]? largeBuf = null; + + try + { + while (remainingBytes > 0) + { + if (Available == 0 && remainingBytes >= streamBuffer.Length && streamBuffer.Length > 0) + { + var grain = (int)Math.Min(remainingBytes, largeCopyGrain); + if (largeBuf == null || largeBuf.Length < grain) + { + if (largeBuf != null) + bufferPool.ReturnBuffer(largeBuf); + + largeBuf = bufferPool.GetBuffer(grain); + } + + var read = await ReadAsync(largeBuf.AsMemory(0, grain), cancellationToken); + if (read == 0) + break; + + if (httpWriter != null) + await httpWriter.WriteAsync(largeBuf.AsMemory(0, read), cancellationToken); + else + await writer.WriteAsync(largeBuf, 0, read, cancellationToken); + + if (isRequest) + args.OnDataSent(largeBuf, 0, read); + else + args.OnDataReceived(largeBuf, 0, read); + + remainingBytes -= read; + continue; + } + + if (Available == 0) + { + var fill = await FillBufferWithResultAsync(cancellationToken); + if (fill == BufferFillResult.Cancelled) + cancellationToken.ThrowIfCancellationRequested(); + if (fill != BufferFillResult.GotData) + break; + } + + var n = (int)Math.Min(Available, remainingBytes); + var offset = bufferPos; + + // Write the unread window in place — no second pooled rent/copy. Await before the next + // fill: FillBuffer compact-moves streamBuffer and would invalidate this window. + if (httpWriter != null) + await httpWriter.WriteAsync(streamBuffer.AsMemory(offset, n), cancellationToken); + else + await writer.WriteAsync(streamBuffer, offset, n, cancellationToken); + + if (isRequest) + args.OnDataSent(streamBuffer, offset, n); + else + args.OnDataReceived(streamBuffer, offset, n); + + bufferPos += n; + Available -= n; + remainingBytes -= n; + } + } + finally + { + if (largeBuf != null) + bufferPool.ReturnBuffer(largeBuf); + } + } + + /// + /// Writes the request/response headers and body. + /// + /// + /// + /// + /// + protected async ValueTask WriteAsync(RequestResponseBase requestResponse, HeaderBuilder headerBuilder, + CancellationToken cancellationToken = default) + { + var body = requestResponse.CompressBodyAndUpdateContentLength(); + headerBuilder.WriteHeaders(requestResponse.Headers); + + // Fixed-length body up to one large-copy grain: one SslStream/NetworkStream write + // (headers+body) instead of a header-only TLS record + body records. Matches YARP's + // larger first forward write under delay-sensitive workloads (compare-lossy). + if (body != null + && body.Length <= 64 * 1024 + && !requestResponse.IsChunked + && !requestResponse.HasTrailingHeaders) + { + headerBuilder.WriteRaw(body); + await WriteHeadersAsync(headerBuilder, cancellationToken); + requestResponse.IsBodySent = true; + return; + } + + await WriteHeadersAsync(headerBuilder, cancellationToken); + + if (body != null) + { + await WriteBodyAsync(body, requestResponse.IsChunked, + requestResponse.HasTrailingHeaders ? requestResponse.TrailingHeaders : null, cancellationToken); + requestResponse.IsBodySent = true; + } + } +} diff --git a/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Fill.cs b/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Fill.cs new file mode 100644 index 000000000..52169769b --- /dev/null +++ b/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Fill.cs @@ -0,0 +1,199 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net.Security; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Titanium.Web.Proxy.Compression; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Exceptions; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Logging; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Options; +using Titanium.Web.Proxy.Shared; +using Titanium.Web.Proxy.StreamExtended.BufferPool; +using Titanium.Web.Proxy.StreamExtended.Network; + +namespace Titanium.Web.Proxy.Helpers; + +internal partial class HttpStream : Stream, IHttpStreamWriter, IHttpStreamReader, IPeekStream, ITransportCapableStream +{ + public bool FillBuffer() + { + // Once EOF has already been observed, keep reporting it idempotently (like a normal Stream would + // on a repeat Read after EOF) instead of throwing. A caller composed underneath another stream - + // notably SslStream, which may issue more than one inner read while assembling a single TLS + // record (see SslStream.EnsureFullTlsFrameAsync) - can legitimately call this again after this + // stream already reported end-of-stream once; throwing here turned that benign, expected + // "still nothing more to read" case into an unhandled exception that bypassed the IsNetworkStream + // swallow-and-report-EOF handling below entirely. + if (IsClosed) return false; + + if (Available > 0) + // normally we fill the buffer only when it is empty, but sometimes we need more data + // move the remaining data to the beginning of the buffer + Buffer.BlockCopy(streamBuffer, bufferPos, streamBuffer, 0, Available); + + bufferPos = 0; + + var result = false; + try + { + var readBytes = BaseStream.Read(streamBuffer, Available, streamBuffer.Length - Available); + result = readBytes > 0; + if (result) + { + OnDataRead(streamBuffer, Available, readBytes); + Available += readBytes; + } + } + catch (Exception ex) + { + if (!IsNetworkStream) + { + throw ReportRethrownFailure(ex); + } + else + { + ReportSuppressedFailure(ex); + } + } + finally + { + if (!result) + { + IsClosed = true; + closedWrite = true; + } + } + + return result; + } + + /// + /// Fills the buffer asynchronous. + /// + /// The cancellation token. + /// when data was read; on EOF. + /// + /// Cancellation still throws to preserve the public + /// contract. Prefer + /// on HTTP/1 session paths that must avoid cancel unwind. + /// + public ValueTask FillBufferAsync(CancellationToken cancellationToken = default) + { + var fill = FillBufferWithResultAsync(cancellationToken); + if (fill.IsCompletedSuccessfully) + { + var result = fill.Result; + if (result == BufferFillResult.Cancelled) + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(result == BufferFillResult.GotData); + } + + return FillBufferAsyncSlow(fill, cancellationToken); + } + + private static async ValueTask FillBufferAsyncSlow(ValueTask fill, + CancellationToken cancellationToken) + { + var result = await fill; + if (result == BufferFillResult.Cancelled) + cancellationToken.ThrowIfCancellationRequested(); + return result == BufferFillResult.GotData; + } + + /// + /// Fills the buffer without throwing on cancellation. Used by HTTP/1 session paths that treat + /// cancel as a value (timeout discrimination happens at the deadline catch site). + /// + internal ValueTask FillBufferWithResultAsync( + CancellationToken cancellationToken = default) + { + // See the remarks on the synchronous FillBuffer() above for why this is a graceful no-op rather + // than a thrown exception once EOF has already been observed. + if (IsClosed) return new ValueTask(BufferFillResult.EndOfStream); + + var bytesToRead = streamBuffer.Length - Available; + if (bytesToRead == 0) return new ValueTask(BufferFillResult.EndOfStream); + + return FillBufferWithResultCoreAsync(bytesToRead, cancellationToken); + } + + private async ValueTask FillBufferWithResultCoreAsync(int bytesToRead, + CancellationToken cancellationToken) + { + if (Available > 0) + // normally we fill the buffer only when it is empty, but sometimes we need more data + // move the remaining data to the beginning of the buffer + Buffer.BlockCopy(streamBuffer, bufferPos, streamBuffer, 0, Available); + + bufferPos = 0; + + var result = BufferFillResult.EndOfStream; + // A cancelled/timed-out wait is not evidence the connection is dead - the read simply never + // got the chance to observe EOF or a transport error. Unlike a genuine EOF or I/O failure + // (which correctly poison the stream below via IsClosed/closedWrite), an operation-cancelled + // read must leave the stream's write side usable: callers (e.g. WebSocketInterceptRelay + // cancelling the "losing" direction's pending read after the other leg finds a protocol + // violation) still need to write a conformant close frame on this same stream afterwards. + // Cancel sets result to Cancelled (not EndOfStream), so the finally poison check is enough. + try + { + // Await ReadAsync with the real cancellation token directly. Do not wrap with + // WithCancellation: that races the socket read against a cancel-triggered TCS and, + // on cancel, returns 0 without awaiting the real read — abandoning it mid-flight while + // it still writes into streamBuffer. A later FillBufferAsync/Dispose could then reuse + // or return that buffer while the abandoned read is still writing (same class of bug + // StreamExtensions.CopyToAsync already fixed). Modern NetworkStream/SslStream observe + // cancellation themselves; OperationCanceledException is handled below so cancel does + // not poison IsClosed/closedWrite. + var readBytes = await BaseStream.ReadAsync( + streamBuffer.AsMemory(Available, bytesToRead), cancellationToken); + + if (readBytes > 0) + { + OnDataRead(streamBuffer, Available, readBytes); + Available += readBytes; + result = BufferFillResult.GotData; + } + } + catch (OperationCanceledException) + { + result = BufferFillResult.Cancelled; + } + catch (Exception ex) + { + if (!IsNetworkStream) + { + throw ReportRethrownFailure(ex); + } + else + { + ReportSuppressedFailure(ex); + } + result = BufferFillResult.EndOfStream; + } + finally + { + if (result == BufferFillResult.EndOfStream) + { + IsClosed = true; + closedWrite = true; + } + } + + return result; + } + + /// + /// Read a line from the byte stream + /// +} diff --git a/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Lines.cs b/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Lines.cs new file mode 100644 index 000000000..76692e93f --- /dev/null +++ b/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Lines.cs @@ -0,0 +1,385 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net.Security; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Titanium.Web.Proxy.Compression; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Exceptions; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Logging; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Options; +using Titanium.Web.Proxy.Shared; +using Titanium.Web.Proxy.StreamExtended.BufferPool; +using Titanium.Web.Proxy.StreamExtended.Network; + +namespace Titanium.Web.Proxy.Helpers; + +internal partial class HttpStream : Stream, IHttpStreamWriter, IHttpStreamReader, IPeekStream, ITransportCapableStream +{ + public ValueTask ReadLineAsync(CancellationToken cancellationToken = default) + { + var lineVt = ReadLineWithResultAsync(cancellationToken); + if (lineVt.IsCompletedSuccessfully) + { + var (line, cancelled) = lineVt.Result; + if (cancelled) + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(line); + } + + return ReadLineAsyncSlow(lineVt, cancellationToken); + } + + private static async ValueTask ReadLineAsyncSlow( + ValueTask<(string? Line, bool Cancelled)> lineVt, CancellationToken cancellationToken) + { + var (line, cancelled) = await lineVt; + if (cancelled) + cancellationToken.ThrowIfCancellationRequested(); + return line; + } + + /// + /// Reads a line without throwing on cancellation. Used by HTTP/1 session loops that treat + /// cancel as a value and discriminate timeout at the deadline site. + /// + internal ValueTask<(string? Line, bool Cancelled)> ReadLineWithResultAsync( + CancellationToken cancellationToken = default) + { + // Keep-alive leftover: a complete line is already in streamBuffer — return without a + // state machine. Incomplete lines (no LF yet) fall through to the async fill loop. + if (Available > 0 && TryReadLineFromBuffer(out var line)) + return new ValueTask<(string? Line, bool Cancelled)>((line, false)); + + return ReadLineFromStreamBufferAsync(cancellationToken); + } + + /// + /// When a complete request line is already buffered, parse it from bytes (no line string). + /// Returns when more socket data is needed. + /// + protected bool TryParseRequestLineFromBuffer(out string method, out ByteString requestUri, out Version version, + out bool emptyLine) + { + method = null!; + requestUri = default; + version = HttpHeader.VersionUnknown; + emptyLine = false; + + var maxLineBytes = server.ResourceLimits.MaxHeaderLineBytes; + var window = streamBuffer.AsSpan(bufferPos, Available); + var lfIndex = window.IndexOf((byte)'\n'); + if (lfIndex < 0) + return false; + + if (lfIndex > maxLineBytes) + throw new ProxyHttpException( + $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", + null, null); + + var line = window.Slice(0, lfIndex); + if (line.Length > 0 && line[^1] == (byte)'\r') + line = line[..^1]; + + var consumed = lfIndex + 1; + bufferPos += consumed; + Available -= consumed; + + if (line.Length == 0) + { + emptyLine = true; + return true; + } + + Request.ParseRequestLine(line, out method, out requestUri, out version); + return true; + } + + /// + /// When a complete header line is already buffered, consume it without Encoding.GetString. + /// Returns when more socket data is needed. On success, + /// is true for the blank line that ends the header block; otherwise is the + /// line without CR/LF (still pointing into the stream buffer — copy before the next consume). + /// + internal bool TryConsumeHeaderLineFromBuffer(out bool emptyLine, out ReadOnlySpan lineBytes) + { + emptyLine = false; + lineBytes = default; + + var maxLineBytes = server.ResourceLimits.MaxHeaderLineBytes; + var window = streamBuffer.AsSpan(bufferPos, Available); + var lfIndex = window.IndexOf((byte)'\n'); + if (lfIndex < 0) + return false; + + if (lfIndex > maxLineBytes) + throw new ProxyHttpException( + $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", + null, null); + + var line = window.Slice(0, lfIndex); + if (line.Length > 0 && line[^1] == (byte)'\r') + line = line[..^1]; + + var consumed = lfIndex + 1; + bufferPos += consumed; + Available -= consumed; + + if (line.Length == 0) + { + emptyLine = true; + return true; + } + + lineBytes = line; + return true; + } + + /// + /// Tries to decode one complete line from the unread window when an LF is already buffered. + /// Returns when more socket data is needed (no LF yet). + /// + private bool TryReadLineFromBuffer(out string? line) + { + var maxLineBytes = server.ResourceLimits.MaxHeaderLineBytes; + var window = streamBuffer.AsSpan(bufferPos, Available); + var lfIndex = window.IndexOf((byte)'\n'); + if (lfIndex < 0) + { + line = null; + return false; + } + + if (lfIndex > maxLineBytes) + throw new ProxyHttpException( + $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", + null, null); + + line = DecodeCompletedLine(window.Slice(0, lfIndex)); + var consumed = lfIndex + 1; + bufferPos += consumed; + Available -= consumed; + return true; + } + + /// + /// Scans with IndexOf('\n') instead of copying one byte at a + /// time into a scratch array. A scratch buffer is only rented when a line spans multiple fills. + /// + private async ValueTask<(string? Line, bool Cancelled)> ReadLineFromStreamBufferAsync( // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + CancellationToken cancellationToken) + { + var maxLineBytes = server.ResourceLimits.MaxHeaderLineBytes; + var accumulatedLength = 0; + byte[]? scratchPoolBuffer = null; + byte[]? scratch = null; + + try + { + while (true) + { + if (Available == 0) + { + var fill = await FillBufferWithResultAsync(cancellationToken); + if (fill == BufferFillResult.Cancelled) return (null, true); + if (fill != BufferFillResult.GotData) break; + } + + var window = streamBuffer.AsSpan(bufferPos, Available); + var lfIndex = window.IndexOf((byte)'\n'); + + if (lfIndex >= 0) + { + var lineByteCount = accumulatedLength + lfIndex; + if (lineByteCount > maxLineBytes) + throw new ProxyHttpException( + $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", + null, null); + + string line; + if (accumulatedLength == 0) + { + line = DecodeCompletedLine(window.Slice(0, lfIndex)); + } + else + { + EnsureLineBufferMinLength(ref scratch!, lineByteCount, maxLineBytes); + window.Slice(0, lfIndex).CopyTo(scratch.AsSpan(accumulatedLength)); + line = DecodeCompletedLine(scratch.AsSpan(0, lineByteCount)); + } + + var consumed = lfIndex + 1; + bufferPos += consumed; + Available -= consumed; + return (line, false); + } + + // No LF in this window — carry bytes across the next fill. + var append = Available; + var nextLength = accumulatedLength + append; + if (nextLength > maxLineBytes) + throw new ProxyHttpException( + $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", + null, null); + + if (scratch == null) + { + scratchPoolBuffer = bufferPool.GetBuffer(); + scratch = scratchPoolBuffer; + } + + EnsureLineBufferMinLength(ref scratch, nextLength, maxLineBytes); + window.CopyTo(scratch.AsSpan(accumulatedLength)); + accumulatedLength = nextLength; + bufferPos += append; + Available = 0; + } + + if (accumulatedLength == 0) return (null, false); + return (Encoding.GetString(scratch!, 0, accumulatedLength), false); + } + finally + { + if (scratchPoolBuffer != null) + bufferPool.ReturnBuffer(scratchPoolBuffer); + } + } + + /// + /// Read a line from the byte stream + /// + /// Line source. + /// Buffer pool for the scratch line buffer. + /// Cancellation token. + /// + /// Maximum accepted line length in bytes (excluding the terminating LF). Defaults to + /// .MaxHeaderLineBytes when omitted. + /// Exceeding the cap throws rather than growing without bound. + /// + /// + internal static async ValueTask ReadLineInternalAsync(ILineStream reader, IBufferPool bufferPool, + CancellationToken cancellationToken = default, long maxLineBytes = -1) + { + if (maxLineBytes < 0) + maxLineBytes = ProxyResourceLimits.Default.MaxHeaderLineBytes; + + byte lastChar = default; + + var bufferDataLength = 0; + + // try to use buffer from the buffer pool, usually it is enough + var bufferPoolBuffer = bufferPool.GetBuffer(); + var buffer = bufferPoolBuffer; + + try + { + while (reader.DataAvailable || await reader.FillBufferAsync(cancellationToken)) + { + var newChar = reader.ReadByteFromBuffer(); + buffer[bufferDataLength] = newChar; + + if (newChar == '\n') + return DecodeCompletedLine(buffer, bufferDataLength, lastChar); + + bufferDataLength++; + lastChar = newChar; + EnsureLineBufferCapacity(ref buffer, bufferDataLength, maxLineBytes); + } + + // reached end of stream without a trailing '\n'. + // build the result string here, while the pooled buffer is still valid, + // before it is returned in the finally block below. + if (bufferDataLength == 0) return null; + + return Encoding.GetString(buffer, 0, bufferDataLength); + } + finally + { + bufferPool.ReturnBuffer(bufferPoolBuffer); + } + } + + /// + /// Decodes bytes accumulated up to (but not including) a terminating LF. + /// When the previous byte was CR, both CR and LF are excluded (CRLF line ending). + /// + private static string DecodeCompletedLine(byte[] buffer, int lfIndex, byte charBeforeLf) + { + var length = charBeforeLf == '\r' ? lfIndex - 1 : lfIndex; + return Encoding.GetString(buffer, 0, length); + } + + /// + /// Decodes bytes that precede a terminating LF. Strips a trailing CR when present (CRLF). + /// + private static string DecodeCompletedLine(ReadOnlySpan lineBytesBeforeLf) + { + if (lineBytesBeforeLf.Length > 0 && lineBytesBeforeLf[^1] == (byte)'\r') + lineBytesBeforeLf = lineBytesBeforeLf[..^1]; + return Encoding.GetString(lineBytesBeforeLf); + } + + /// + /// Enforces and grows the scratch buffer when full. + /// + private static void EnsureLineBufferCapacity(ref byte[] buffer, int bufferDataLength, long maxLineBytes) + { + if (bufferDataLength > maxLineBytes) + throw new ProxyHttpException( + $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", + null, null); + + if (bufferDataLength != buffer.Length) + return; + + if (bufferDataLength >= maxLineBytes) + throw new ProxyHttpException( + $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", + null, null); + + var newSize = (int)Math.Min(bufferDataLength * 2L, maxLineBytes); + if (newSize <= bufferDataLength) + newSize = bufferDataLength + 1; + Array.Resize(ref buffer, newSize); + } + + /// + /// Grows so it can hold at least + /// bytes (used by the IndexOf line scanner when appending a whole unread window). + /// + private static void EnsureLineBufferMinLength(ref byte[] buffer, int requiredLength, long maxLineBytes) + { + if (requiredLength > maxLineBytes) + throw new ProxyHttpException( + $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", + null, null); + + if (requiredLength <= buffer.Length) + return; + + var newSize = (int)Math.Min(Math.Max(buffer.Length * 2L, requiredLength), maxLineBytes); + if (newSize < requiredLength) + throw new ProxyHttpException( + $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", + null, null); + + Array.Resize(ref buffer, newSize); + } + + /// + /// Base Stream.BeginRead will call this.Read and block thread (we don't want this, Network stream handles async) + /// In order to really async Reading Launch this.ReadAsync as Task will fire NetworkStream.ReadAsync + /// See Threads here : + /// https://github.com/justcoding121/Stream-Extended/pull/43 + /// https://github.com/justcoding121/Titanium-Web-Proxy/issues/575 + /// +} diff --git a/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Write.cs b/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Write.cs new file mode 100644 index 000000000..59afe3a32 --- /dev/null +++ b/src/Titanium.Web.Proxy/Network/Streams/HttpStream.Write.cs @@ -0,0 +1,284 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Net.Security; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Titanium.Web.Proxy.Compression; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Exceptions; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Logging; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Options; +using Titanium.Web.Proxy.Shared; +using Titanium.Web.Proxy.StreamExtended.BufferPool; +using Titanium.Web.Proxy.StreamExtended.Network; + +namespace Titanium.Web.Proxy.Helpers; + +internal partial class HttpStream : Stream, IHttpStreamWriter, IHttpStreamReader, IPeekStream, ITransportCapableStream +{ + /// Writes a line async + /// + /// Optional cancellation token for this async task. + /// + public ValueTask WriteLineAsync(CancellationToken cancellationToken = default) + { + return WriteAsync(newLine, cancellationToken: cancellationToken); + } + + public ValueTask WriteLineAsync(string value, CancellationToken cancellationToken = default) + { + return WriteAsyncInternal(value, true, cancellationToken); + } + + private ValueTask WriteAsyncInternal(string value, bool addNewLine, CancellationToken cancellationToken) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + { + if (closedWrite) return default; + + var newLineChars = addNewLine ? newLine.Length : 0; + var charCount = value.Length; + if (charCount < bufferPool.BufferSize - newLineChars) + { + var buffer = bufferPool.GetBuffer(); + try + { + var idx = Encoding.GetBytes(value, 0, charCount, buffer, 0); + if (newLineChars > 0) + { + Buffer.BlockCopy(newLine, 0, buffer, idx, newLineChars); + idx += newLineChars; + } + + var writeVt = WriteToBaseStreamAsync(buffer.AsMemory(0, idx), cancellationToken); + if (writeVt.IsCompletedSuccessfully) + return default; + + // Transfer buffer ownership to the await helper. + var pending = WriteAsyncInternalAwaitPoolBuffer(writeVt, buffer); + buffer = null!; + return pending; + } + finally + { + if (buffer != null) + bufferPool.ReturnBuffer(buffer); + } + } + + var rentSize = charCount + newLineChars; + var rented = ArrayPool.Shared.Rent(rentSize); + try + { + var idx = Encoding.GetBytes(value, 0, charCount, rented, 0); + if (newLineChars > 0) + { + Buffer.BlockCopy(newLine, 0, rented, idx, newLineChars); + idx += newLineChars; + } + + var writeVt = WriteToBaseStreamAsync(rented.AsMemory(0, idx), cancellationToken); + if (writeVt.IsCompletedSuccessfully) + return default; + + var pending = WriteAsyncInternalAwaitArrayPool(writeVt, rented); + rented = null!; + return pending; + } + finally + { + if (rented != null) + ArrayPool.Shared.Return(rented); + } + } + + private async ValueTask WriteAsyncInternalAwaitPoolBuffer(ValueTask writeVt, byte[] buffer) + { + try + { + await writeVt; + } + finally + { + bufferPool.ReturnBuffer(buffer); + } + } + + private static async ValueTask WriteAsyncInternalAwaitArrayPool(ValueTask writeVt, byte[] rented) + { + try + { + await writeVt; + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + /// + /// Write the headers to client + /// + /// + /// + /// + internal ValueTask WriteHeadersAsync(HeaderBuilder headerBuilder, CancellationToken cancellationToken = default) + { + var buffer = headerBuilder.GetBuffer(); + var array = buffer.Array ?? + throw new InvalidOperationException("The header buffer has no backing array."); + + try + { + // NetworkStream.FlushAsync is a no-op but still pays async machinery. SslStream.Write + // already emits the TLS record for typical small writes — flushing after every origin + // header block was a per-request MITM tax vs cleartext (same H2→H1 bridge). Flush only + // for custom/buffered base streams that are neither NetworkStream nor SslStream. + return WriteAsync(array, buffer.Offset, buffer.Count, flush: !IsNetworkStream, cancellationToken); + } + catch (IOException e) + { + //throw this as ServerConnectionException so that RetryPolicy can retry with a new server connection. + if (IsRetryableHeaderWriteFailure) + { + ProxyDiagnostics.ReportCaught(ProxyDiagnostics.Logger, + "HttpStream header write failed; wrapping as RetryableServerConnectionException", e); + throw new RetryableServerConnectionException( + "Server connection was closed. Exception while sending request line and headers.", e); + } + + ProxyDiagnostics.ReportCaught(ProxyDiagnostics.Logger, + "HttpStream header write failed; rethrowing", e); + throw; + } + } + + /// + /// Writes the data to the stream. + /// + /// The data. + /// Should we flush after write? + /// The cancellation token. + internal ValueTask WriteAsync(byte[] data, bool flush = false, CancellationToken cancellationToken = default) + { + return WriteAsync(data, 0, data.Length, flush, cancellationToken); + } + + internal ValueTask WriteAsync(byte[] data, int offset, int count, bool flush, + CancellationToken cancellationToken = default) + { + var writeVt = WriteToBaseStreamAsync(data.AsMemory(offset, count), cancellationToken); + if (!flush) + return writeVt; + + if (writeVt.IsCompletedSuccessfully) + return FlushBaseStreamAsync(cancellationToken); + + return WriteThenFlushAsync(writeVt, cancellationToken); + } + + /// + /// Writes to without an async state machine when the write completes + /// synchronously (typical for with room in the send buffer). + /// + private ValueTask WriteToBaseStreamAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken) + { + if (closedWrite) return default; + + ValueTask writeVt; + try + { + writeVt = BaseStream.WriteAsync(buffer, cancellationToken); + } + catch (Exception ex) + { + return HandleWriteFailureAsValueTask(ex); + } + + if (writeVt.IsCompletedSuccessfully) + return default; + + return AwaitWriteAndHandleFailure(writeVt); + } + + private ValueTask FlushBaseStreamAsync(CancellationToken cancellationToken) + { + if (closedWrite) return default; + + Task flushTask; + try + { + flushTask = BaseStream.FlushAsync(cancellationToken); + } + catch (Exception ex) + { + return HandleWriteFailureAsValueTask(ex); + } + + if (flushTask.IsCompletedSuccessfully) + return default; + + return AwaitWriteAndHandleFailure(new ValueTask(flushTask)); + } + + private async ValueTask WriteThenFlushAsync(ValueTask writeVt, CancellationToken cancellationToken) + { + await AwaitWriteAndHandleFailure(writeVt); + await FlushBaseStreamAsync(cancellationToken); + } + + private async ValueTask AwaitWriteAndHandleFailure(ValueTask writeVt) + { + try + { + await writeVt; + } + catch (Exception ex) + { + closedWrite = true; + if (!IsNetworkStream) + throw ReportRethrownFailure(ex); + + ReportSuppressedFailure(ex); + } + } + + private ValueTask HandleWriteFailureAsValueTask(Exception ex) + { + closedWrite = true; + if (!IsNetworkStream) + throw ReportRethrownFailure(ex); + + ReportSuppressedFailure(ex); + return default; + } + + /// + /// Asynchronously writes a sequence of bytes to the current stream, advances the current position within this stream by the number of bytes written, and monitors cancellation requests. + /// + /// The buffer to write data from. + /// The token to monitor for cancellation requests. The default value is . + /// A task that represents the asynchronous write operation. + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = + default) + { + // Only materialize a heap copy when a DataWrite subscriber needs a byte[] and the + // memory is not already array-backed. + if (DataWrite != null) + { + if (MemoryMarshal.TryGetArray(buffer, out var segment)) + OnDataWrite(segment.Array!, segment.Offset, segment.Count); + else + OnDataWrite(buffer.ToArray(), 0, buffer.Length); + } + + return WriteToBaseStreamAsync(buffer, cancellationToken); + } +} diff --git a/src/Titanium.Web.Proxy/Network/Streams/HttpStream.cs b/src/Titanium.Web.Proxy/Network/Streams/HttpStream.cs index d6eb0cc04..fa4dae810 100644 --- a/src/Titanium.Web.Proxy/Network/Streams/HttpStream.cs +++ b/src/Titanium.Web.Proxy/Network/Streams/HttpStream.cs @@ -23,7 +23,7 @@ namespace Titanium.Web.Proxy.Helpers; -internal class HttpStream : Stream, IHttpStreamWriter, IHttpStreamReader, IPeekStream, ITransportCapableStream +internal partial class HttpStream : Stream, IHttpStreamWriter, IHttpStreamReader, IPeekStream, ITransportCapableStream { private readonly bool leaveOpen; private readonly byte[] streamBuffer; @@ -741,535 +741,7 @@ public override int WriteTimeout /// /// Fills the buffer. /// - public bool FillBuffer() - { - // Once EOF has already been observed, keep reporting it idempotently (like a normal Stream would - // on a repeat Read after EOF) instead of throwing. A caller composed underneath another stream - - // notably SslStream, which may issue more than one inner read while assembling a single TLS - // record (see SslStream.EnsureFullTlsFrameAsync) - can legitimately call this again after this - // stream already reported end-of-stream once; throwing here turned that benign, expected - // "still nothing more to read" case into an unhandled exception that bypassed the IsNetworkStream - // swallow-and-report-EOF handling below entirely. - if (IsClosed) return false; - - if (Available > 0) - // normally we fill the buffer only when it is empty, but sometimes we need more data - // move the remaining data to the beginning of the buffer - Buffer.BlockCopy(streamBuffer, bufferPos, streamBuffer, 0, Available); - - bufferPos = 0; - - var result = false; - try - { - var readBytes = BaseStream.Read(streamBuffer, Available, streamBuffer.Length - Available); - result = readBytes > 0; - if (result) - { - OnDataRead(streamBuffer, Available, readBytes); - Available += readBytes; - } - } - catch (Exception ex) - { - if (!IsNetworkStream) - { - throw ReportRethrownFailure(ex); - } - else - { - ReportSuppressedFailure(ex); - } - } - finally - { - if (!result) - { - IsClosed = true; - closedWrite = true; - } - } - - return result; - } - - /// - /// Fills the buffer asynchronous. - /// - /// The cancellation token. - /// when data was read; on EOF. - /// - /// Cancellation still throws to preserve the public - /// contract. Prefer - /// on HTTP/1 session paths that must avoid cancel unwind. - /// - public ValueTask FillBufferAsync(CancellationToken cancellationToken = default) - { - var fill = FillBufferWithResultAsync(cancellationToken); - if (fill.IsCompletedSuccessfully) - { - var result = fill.Result; - if (result == BufferFillResult.Cancelled) - cancellationToken.ThrowIfCancellationRequested(); - return new ValueTask(result == BufferFillResult.GotData); - } - - return FillBufferAsyncSlow(fill, cancellationToken); - } - - private static async ValueTask FillBufferAsyncSlow(ValueTask fill, - CancellationToken cancellationToken) - { - var result = await fill; - if (result == BufferFillResult.Cancelled) - cancellationToken.ThrowIfCancellationRequested(); - return result == BufferFillResult.GotData; - } - - /// - /// Fills the buffer without throwing on cancellation. Used by HTTP/1 session paths that treat - /// cancel as a value (timeout discrimination happens at the deadline catch site). - /// - internal ValueTask FillBufferWithResultAsync( - CancellationToken cancellationToken = default) - { - // See the remarks on the synchronous FillBuffer() above for why this is a graceful no-op rather - // than a thrown exception once EOF has already been observed. - if (IsClosed) return new ValueTask(BufferFillResult.EndOfStream); - - var bytesToRead = streamBuffer.Length - Available; - if (bytesToRead == 0) return new ValueTask(BufferFillResult.EndOfStream); - - return FillBufferWithResultCoreAsync(bytesToRead, cancellationToken); - } - - private async ValueTask FillBufferWithResultCoreAsync(int bytesToRead, - CancellationToken cancellationToken) - { - if (Available > 0) - // normally we fill the buffer only when it is empty, but sometimes we need more data - // move the remaining data to the beginning of the buffer - Buffer.BlockCopy(streamBuffer, bufferPos, streamBuffer, 0, Available); - - bufferPos = 0; - - var result = BufferFillResult.EndOfStream; - // A cancelled/timed-out wait is not evidence the connection is dead - the read simply never - // got the chance to observe EOF or a transport error. Unlike a genuine EOF or I/O failure - // (which correctly poison the stream below via IsClosed/closedWrite), an operation-cancelled - // read must leave the stream's write side usable: callers (e.g. WebSocketInterceptRelay - // cancelling the "losing" direction's pending read after the other leg finds a protocol - // violation) still need to write a conformant close frame on this same stream afterwards. - // Cancel sets result to Cancelled (not EndOfStream), so the finally poison check is enough. - try - { - // Await ReadAsync with the real cancellation token directly. Do not wrap with - // WithCancellation: that races the socket read against a cancel-triggered TCS and, - // on cancel, returns 0 without awaiting the real read — abandoning it mid-flight while - // it still writes into streamBuffer. A later FillBufferAsync/Dispose could then reuse - // or return that buffer while the abandoned read is still writing (same class of bug - // StreamExtensions.CopyToAsync already fixed). Modern NetworkStream/SslStream observe - // cancellation themselves; OperationCanceledException is handled below so cancel does - // not poison IsClosed/closedWrite. - var readBytes = await BaseStream.ReadAsync( - streamBuffer.AsMemory(Available, bytesToRead), cancellationToken); - - if (readBytes > 0) - { - OnDataRead(streamBuffer, Available, readBytes); - Available += readBytes; - result = BufferFillResult.GotData; - } - } - catch (OperationCanceledException) - { - result = BufferFillResult.Cancelled; - } - catch (Exception ex) - { - if (!IsNetworkStream) - { - throw ReportRethrownFailure(ex); - } - else - { - ReportSuppressedFailure(ex); - } - result = BufferFillResult.EndOfStream; - } - finally - { - if (result == BufferFillResult.EndOfStream) - { - IsClosed = true; - closedWrite = true; - } - } - - return result; - } - - /// - /// Read a line from the byte stream - /// - /// - public ValueTask ReadLineAsync(CancellationToken cancellationToken = default) - { - var lineVt = ReadLineWithResultAsync(cancellationToken); - if (lineVt.IsCompletedSuccessfully) - { - var (line, cancelled) = lineVt.Result; - if (cancelled) - cancellationToken.ThrowIfCancellationRequested(); - return new ValueTask(line); - } - - return ReadLineAsyncSlow(lineVt, cancellationToken); - } - - private static async ValueTask ReadLineAsyncSlow( - ValueTask<(string? Line, bool Cancelled)> lineVt, CancellationToken cancellationToken) - { - var (line, cancelled) = await lineVt; - if (cancelled) - cancellationToken.ThrowIfCancellationRequested(); - return line; - } - - /// - /// Reads a line without throwing on cancellation. Used by HTTP/1 session loops that treat - /// cancel as a value and discriminate timeout at the deadline site. - /// - internal ValueTask<(string? Line, bool Cancelled)> ReadLineWithResultAsync( - CancellationToken cancellationToken = default) - { - // Keep-alive leftover: a complete line is already in streamBuffer — return without a - // state machine. Incomplete lines (no LF yet) fall through to the async fill loop. - if (Available > 0 && TryReadLineFromBuffer(out var line)) - return new ValueTask<(string? Line, bool Cancelled)>((line, false)); - - return ReadLineFromStreamBufferAsync(cancellationToken); - } - - /// - /// When a complete request line is already buffered, parse it from bytes (no line string). - /// Returns when more socket data is needed. - /// - protected bool TryParseRequestLineFromBuffer(out string method, out ByteString requestUri, out Version version, - out bool emptyLine) - { - method = null!; - requestUri = default; - version = HttpHeader.VersionUnknown; - emptyLine = false; - - var maxLineBytes = server.ResourceLimits.MaxHeaderLineBytes; - var window = streamBuffer.AsSpan(bufferPos, Available); - var lfIndex = window.IndexOf((byte)'\n'); - if (lfIndex < 0) - return false; - - if (lfIndex > maxLineBytes) - throw new ProxyHttpException( - $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", - null, null); - - var line = window.Slice(0, lfIndex); - if (line.Length > 0 && line[^1] == (byte)'\r') - line = line[..^1]; - - var consumed = lfIndex + 1; - bufferPos += consumed; - Available -= consumed; - - if (line.Length == 0) - { - emptyLine = true; - return true; - } - - Request.ParseRequestLine(line, out method, out requestUri, out version); - return true; - } - - /// - /// When a complete header line is already buffered, consume it without Encoding.GetString. - /// Returns when more socket data is needed. On success, - /// is true for the blank line that ends the header block; otherwise is the - /// line without CR/LF (still pointing into the stream buffer — copy before the next consume). - /// - internal bool TryConsumeHeaderLineFromBuffer(out bool emptyLine, out ReadOnlySpan lineBytes) - { - emptyLine = false; - lineBytes = default; - - var maxLineBytes = server.ResourceLimits.MaxHeaderLineBytes; - var window = streamBuffer.AsSpan(bufferPos, Available); - var lfIndex = window.IndexOf((byte)'\n'); - if (lfIndex < 0) - return false; - - if (lfIndex > maxLineBytes) - throw new ProxyHttpException( - $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", - null, null); - - var line = window.Slice(0, lfIndex); - if (line.Length > 0 && line[^1] == (byte)'\r') - line = line[..^1]; - - var consumed = lfIndex + 1; - bufferPos += consumed; - Available -= consumed; - - if (line.Length == 0) - { - emptyLine = true; - return true; - } - - lineBytes = line; - return true; - } - - /// - /// Tries to decode one complete line from the unread window when an LF is already buffered. - /// Returns when more socket data is needed (no LF yet). - /// - private bool TryReadLineFromBuffer(out string? line) - { - var maxLineBytes = server.ResourceLimits.MaxHeaderLineBytes; - var window = streamBuffer.AsSpan(bufferPos, Available); - var lfIndex = window.IndexOf((byte)'\n'); - if (lfIndex < 0) - { - line = null; - return false; - } - - if (lfIndex > maxLineBytes) - throw new ProxyHttpException( - $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", - null, null); - - line = DecodeCompletedLine(window.Slice(0, lfIndex)); - var consumed = lfIndex + 1; - bufferPos += consumed; - Available -= consumed; - return true; - } - - /// - /// Scans with IndexOf('\n') instead of copying one byte at a - /// time into a scratch array. A scratch buffer is only rented when a line spans multiple fills. - /// - private async ValueTask<(string? Line, bool Cancelled)> ReadLineFromStreamBufferAsync( // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - CancellationToken cancellationToken) - { - var maxLineBytes = server.ResourceLimits.MaxHeaderLineBytes; - var accumulatedLength = 0; - byte[]? scratchPoolBuffer = null; - byte[]? scratch = null; - - try - { - while (true) - { - if (Available == 0) - { - var fill = await FillBufferWithResultAsync(cancellationToken); - if (fill == BufferFillResult.Cancelled) return (null, true); - if (fill != BufferFillResult.GotData) break; - } - - var window = streamBuffer.AsSpan(bufferPos, Available); - var lfIndex = window.IndexOf((byte)'\n'); - - if (lfIndex >= 0) - { - var lineByteCount = accumulatedLength + lfIndex; - if (lineByteCount > maxLineBytes) - throw new ProxyHttpException( - $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", - null, null); - - string line; - if (accumulatedLength == 0) - { - line = DecodeCompletedLine(window.Slice(0, lfIndex)); - } - else - { - EnsureLineBufferMinLength(ref scratch!, lineByteCount, maxLineBytes); - window.Slice(0, lfIndex).CopyTo(scratch.AsSpan(accumulatedLength)); - line = DecodeCompletedLine(scratch.AsSpan(0, lineByteCount)); - } - - var consumed = lfIndex + 1; - bufferPos += consumed; - Available -= consumed; - return (line, false); - } - - // No LF in this window — carry bytes across the next fill. - var append = Available; - var nextLength = accumulatedLength + append; - if (nextLength > maxLineBytes) - throw new ProxyHttpException( - $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", - null, null); - - if (scratch == null) - { - scratchPoolBuffer = bufferPool.GetBuffer(); - scratch = scratchPoolBuffer; - } - - EnsureLineBufferMinLength(ref scratch, nextLength, maxLineBytes); - window.CopyTo(scratch.AsSpan(accumulatedLength)); - accumulatedLength = nextLength; - bufferPos += append; - Available = 0; - } - - if (accumulatedLength == 0) return (null, false); - return (Encoding.GetString(scratch!, 0, accumulatedLength), false); - } - finally - { - if (scratchPoolBuffer != null) - bufferPool.ReturnBuffer(scratchPoolBuffer); - } - } - - /// - /// Read a line from the byte stream - /// - /// Line source. - /// Buffer pool for the scratch line buffer. - /// Cancellation token. - /// - /// Maximum accepted line length in bytes (excluding the terminating LF). Defaults to - /// .MaxHeaderLineBytes when omitted. - /// Exceeding the cap throws rather than growing without bound. - /// /// - internal static async ValueTask ReadLineInternalAsync(ILineStream reader, IBufferPool bufferPool, - CancellationToken cancellationToken = default, long maxLineBytes = -1) - { - if (maxLineBytes < 0) - maxLineBytes = ProxyResourceLimits.Default.MaxHeaderLineBytes; - - byte lastChar = default; - - var bufferDataLength = 0; - - // try to use buffer from the buffer pool, usually it is enough - var bufferPoolBuffer = bufferPool.GetBuffer(); - var buffer = bufferPoolBuffer; - - try - { - while (reader.DataAvailable || await reader.FillBufferAsync(cancellationToken)) - { - var newChar = reader.ReadByteFromBuffer(); - buffer[bufferDataLength] = newChar; - - if (newChar == '\n') - return DecodeCompletedLine(buffer, bufferDataLength, lastChar); - - bufferDataLength++; - lastChar = newChar; - EnsureLineBufferCapacity(ref buffer, bufferDataLength, maxLineBytes); - } - - // reached end of stream without a trailing '\n'. - // build the result string here, while the pooled buffer is still valid, - // before it is returned in the finally block below. - if (bufferDataLength == 0) return null; - - return Encoding.GetString(buffer, 0, bufferDataLength); - } - finally - { - bufferPool.ReturnBuffer(bufferPoolBuffer); - } - } - - /// - /// Decodes bytes accumulated up to (but not including) a terminating LF. - /// When the previous byte was CR, both CR and LF are excluded (CRLF line ending). - /// - private static string DecodeCompletedLine(byte[] buffer, int lfIndex, byte charBeforeLf) - { - var length = charBeforeLf == '\r' ? lfIndex - 1 : lfIndex; - return Encoding.GetString(buffer, 0, length); - } - - /// - /// Decodes bytes that precede a terminating LF. Strips a trailing CR when present (CRLF). - /// - private static string DecodeCompletedLine(ReadOnlySpan lineBytesBeforeLf) - { - if (lineBytesBeforeLf.Length > 0 && lineBytesBeforeLf[^1] == (byte)'\r') - lineBytesBeforeLf = lineBytesBeforeLf[..^1]; - return Encoding.GetString(lineBytesBeforeLf); - } - - /// - /// Enforces and grows the scratch buffer when full. - /// - private static void EnsureLineBufferCapacity(ref byte[] buffer, int bufferDataLength, long maxLineBytes) - { - if (bufferDataLength > maxLineBytes) - throw new ProxyHttpException( - $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", - null, null); - - if (bufferDataLength != buffer.Length) - return; - - if (bufferDataLength >= maxLineBytes) - throw new ProxyHttpException( - $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", - null, null); - - var newSize = (int)Math.Min(bufferDataLength * 2L, maxLineBytes); - if (newSize <= bufferDataLength) - newSize = bufferDataLength + 1; - Array.Resize(ref buffer, newSize); - } - - /// - /// Grows so it can hold at least - /// bytes (used by the IndexOf line scanner when appending a whole unread window). - /// - private static void EnsureLineBufferMinLength(ref byte[] buffer, int requiredLength, long maxLineBytes) - { - if (requiredLength > maxLineBytes) - throw new ProxyHttpException( - $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", - null, null); - - if (requiredLength <= buffer.Length) - return; - - var newSize = (int)Math.Min(Math.Max(buffer.Length * 2L, requiredLength), maxLineBytes); - if (newSize < requiredLength) - throw new ProxyHttpException( - $"HTTP header/request line exceeded the configured maximum of {maxLineBytes:N0} bytes.", - null, null); - - Array.Resize(ref buffer, newSize); - } - - /// - /// Base Stream.BeginRead will call this.Read and block thread (we don't want this, Network stream handles async) - /// In order to really async Reading Launch this.ReadAsync as Task will fire NetworkStream.ReadAsync - /// See Threads here : - /// https://github.com/justcoding121/Stream-Extended/pull/43 - /// https://github.com/justcoding121/Titanium-Web-Proxy/issues/575 - /// /// public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) { @@ -1328,767 +800,4 @@ public override void EndWrite(IAsyncResult asyncResult) ((TaskResult)asyncResult).GetResult(); } - - /// - /// Writes a line async - /// - /// Optional cancellation token for this async task. - /// - public ValueTask WriteLineAsync(CancellationToken cancellationToken = default) - { - return WriteAsync(newLine, cancellationToken: cancellationToken); - } - - public ValueTask WriteLineAsync(string value, CancellationToken cancellationToken = default) - { - return WriteAsyncInternal(value, true, cancellationToken); - } - - private ValueTask WriteAsyncInternal(string value, bool addNewLine, CancellationToken cancellationToken) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - { - if (closedWrite) return default; - - var newLineChars = addNewLine ? newLine.Length : 0; - var charCount = value.Length; - if (charCount < bufferPool.BufferSize - newLineChars) - { - var buffer = bufferPool.GetBuffer(); - try - { - var idx = Encoding.GetBytes(value, 0, charCount, buffer, 0); - if (newLineChars > 0) - { - Buffer.BlockCopy(newLine, 0, buffer, idx, newLineChars); - idx += newLineChars; - } - - var writeVt = WriteToBaseStreamAsync(buffer.AsMemory(0, idx), cancellationToken); - if (writeVt.IsCompletedSuccessfully) - return default; - - // Transfer buffer ownership to the await helper. - var pending = WriteAsyncInternalAwaitPoolBuffer(writeVt, buffer); - buffer = null!; - return pending; - } - finally - { - if (buffer != null) - bufferPool.ReturnBuffer(buffer); - } - } - - var rentSize = charCount + newLineChars; - var rented = ArrayPool.Shared.Rent(rentSize); - try - { - var idx = Encoding.GetBytes(value, 0, charCount, rented, 0); - if (newLineChars > 0) - { - Buffer.BlockCopy(newLine, 0, rented, idx, newLineChars); - idx += newLineChars; - } - - var writeVt = WriteToBaseStreamAsync(rented.AsMemory(0, idx), cancellationToken); - if (writeVt.IsCompletedSuccessfully) - return default; - - var pending = WriteAsyncInternalAwaitArrayPool(writeVt, rented); - rented = null!; - return pending; - } - finally - { - if (rented != null) - ArrayPool.Shared.Return(rented); - } - } - - private async ValueTask WriteAsyncInternalAwaitPoolBuffer(ValueTask writeVt, byte[] buffer) - { - try - { - await writeVt; - } - finally - { - bufferPool.ReturnBuffer(buffer); - } - } - - private static async ValueTask WriteAsyncInternalAwaitArrayPool(ValueTask writeVt, byte[] rented) - { - try - { - await writeVt; - } - finally - { - ArrayPool.Shared.Return(rented); - } - } - - /// - /// Write the headers to client - /// - /// - /// - /// - internal ValueTask WriteHeadersAsync(HeaderBuilder headerBuilder, CancellationToken cancellationToken = default) - { - var buffer = headerBuilder.GetBuffer(); - var array = buffer.Array ?? - throw new InvalidOperationException("The header buffer has no backing array."); - - try - { - // NetworkStream.FlushAsync is a no-op but still pays async machinery. SslStream.Write - // already emits the TLS record for typical small writes — flushing after every origin - // header block was a per-request MITM tax vs cleartext (same H2→H1 bridge). Flush only - // for custom/buffered base streams that are neither NetworkStream nor SslStream. - return WriteAsync(array, buffer.Offset, buffer.Count, flush: !IsNetworkStream, cancellationToken); - } - catch (IOException e) - { - //throw this as ServerConnectionException so that RetryPolicy can retry with a new server connection. - if (IsRetryableHeaderWriteFailure) - { - ProxyDiagnostics.ReportCaught(ProxyDiagnostics.Logger, - "HttpStream header write failed; wrapping as RetryableServerConnectionException", e); - throw new RetryableServerConnectionException( - "Server connection was closed. Exception while sending request line and headers.", e); - } - - ProxyDiagnostics.ReportCaught(ProxyDiagnostics.Logger, - "HttpStream header write failed; rethrowing", e); - throw; - } - } - - /// - /// Writes the data to the stream. - /// - /// The data. - /// Should we flush after write? - /// The cancellation token. - internal ValueTask WriteAsync(byte[] data, bool flush = false, CancellationToken cancellationToken = default) - { - return WriteAsync(data, 0, data.Length, flush, cancellationToken); - } - - internal ValueTask WriteAsync(byte[] data, int offset, int count, bool flush, - CancellationToken cancellationToken = default) - { - var writeVt = WriteToBaseStreamAsync(data.AsMemory(offset, count), cancellationToken); - if (!flush) - return writeVt; - - if (writeVt.IsCompletedSuccessfully) - return FlushBaseStreamAsync(cancellationToken); - - return WriteThenFlushAsync(writeVt, cancellationToken); - } - - /// - /// Writes to without an async state machine when the write completes - /// synchronously (typical for with room in the send buffer). - /// - private ValueTask WriteToBaseStreamAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken) - { - if (closedWrite) return default; - - ValueTask writeVt; - try - { - writeVt = BaseStream.WriteAsync(buffer, cancellationToken); - } - catch (Exception ex) - { - return HandleWriteFailureAsValueTask(ex); - } - - if (writeVt.IsCompletedSuccessfully) - return default; - - return AwaitWriteAndHandleFailure(writeVt); - } - - private ValueTask FlushBaseStreamAsync(CancellationToken cancellationToken) - { - if (closedWrite) return default; - - Task flushTask; - try - { - flushTask = BaseStream.FlushAsync(cancellationToken); - } - catch (Exception ex) - { - return HandleWriteFailureAsValueTask(ex); - } - - if (flushTask.IsCompletedSuccessfully) - return default; - - return AwaitWriteAndHandleFailure(new ValueTask(flushTask)); - } - - private async ValueTask WriteThenFlushAsync(ValueTask writeVt, CancellationToken cancellationToken) - { - await AwaitWriteAndHandleFailure(writeVt); - await FlushBaseStreamAsync(cancellationToken); - } - - private async ValueTask AwaitWriteAndHandleFailure(ValueTask writeVt) - { - try - { - await writeVt; - } - catch (Exception ex) - { - closedWrite = true; - if (!IsNetworkStream) - throw ReportRethrownFailure(ex); - - ReportSuppressedFailure(ex); - } - } - - private ValueTask HandleWriteFailureAsValueTask(Exception ex) - { - closedWrite = true; - if (!IsNetworkStream) - throw ReportRethrownFailure(ex); - - ReportSuppressedFailure(ex); - return default; - } - - /// - /// Writes the byte array body to the stream; optionally chunked - /// - /// - /// - /// - /// Optional trailer headers to emit after the terminating zero-length chunk (ignored when - /// is false - trailers are not defined for fixed-length bodies). - /// - /// - /// - internal ValueTask WriteBodyAsync(byte[] data, bool isChunked, HeaderCollection? trailingHeaders, - CancellationToken cancellationToken) - { - if (isChunked) return WriteBodyChunkedAsync(data, trailingHeaders, cancellationToken); - - return WriteAsync(data, cancellationToken: cancellationToken); - } - - public async Task CopyBodyAsync(RequestResponseBase requestResponse, bool useOriginalHeaderValues, - IHttpStreamWriter writer, TransformationMode transformation, bool isRequest, SessionEventArgs args, - CancellationToken cancellationToken) - { - var isChunked = useOriginalHeaderValues ? requestResponse.OriginalIsChunked : requestResponse.IsChunked; - var contentLength = useOriginalHeaderValues - ? requestResponse.OriginalContentLength - : requestResponse.ContentLength; - - if (transformation == TransformationMode.None) - { - await CopyBodyAsync(writer, isChunked, contentLength, isRequest, args, cancellationToken); - return; - } - - LimitedStream limitedStream; - List? decompressLayers = null; - - var contentEncoding = useOriginalHeaderValues - ? requestResponse.OriginalContentEncoding - : requestResponse.ContentEncoding; - - Stream s = limitedStream = new LimitedStream(this, bufferPool, isChunked, contentLength, - requestResponse.TrailingHeaders); - - if (transformation == TransformationMode.Uncompress && contentEncoding != null) - { - // Content-Encoding may list multiple stacked encodings (e.g. "gzip, br"); each layer - // becomes its own chained decompression stream, applied in reverse order. - (s, decompressLayers) = CompressionUtil.CreateDecompressionChain(s, contentEncoding); - } - - // leaveOpen: true so disposing the wrapper returns its pooled buffer without - // disposing the underlying limited/decompression stream (handled in finally). - var http = new HttpStream(server, s, bufferPool, cancellationToken, true); - try - { - await http.CopyBodyAsync(writer, false, -1, isRequest, args, cancellationToken); - } - finally - { - await http.DisposeAsync(); - - if (decompressLayers != null) - for (var i = decompressLayers.Count - 1; i >= 0; i--) - await decompressLayers[i].DisposeAsync(); - - await limitedStream.Finish(); - await limitedStream.DisposeAsync(); - } - } - - /// - /// Copies the specified content length number of bytes to the output stream from the given inputs stream - /// optionally chunked - /// - /// - /// - /// - /// - /// - /// - public Task CopyBodyAsync(IHttpStreamWriter writer, bool isChunked, long contentLength, - bool isRequest, - SessionEventArgs args, CancellationToken cancellationToken) - { - var isResponse = !isRequest; - - // The per-chunk body-write hook needs a real duplex network transport on both ends (plain socket or - // TLS-decrypted) - it is not meaningful for in-memory/decompression streams. Checked via the internal - // ITransportCapableStream marker rather than the public IHttpStreamWriter/IHttpStreamReader interfaces, - // so external implementers of those public interfaces are not source-broken; one that doesn't also - // implement the marker is simply treated as not supporting the hook (today's behavior, preserved). - var readerSupportsHook = SupportsBodyWriteHook; - var writerSupportsHook = writer is ITransportCapableStream { SupportsBodyWriteHook: true }; // NOSONAR S3060 -- preserves external interface compatibility. - - if (readerSupportsHook && writerSupportsHook && !args.IsFastPath && - ((isRequest && args.HttpClient.Request.OriginalHasBody && !args.HttpClient.Request.IsBodyRead && server.ShouldCallBeforeRequestBodyWrite()) || - (isResponse && args.HttpClient.Response.OriginalHasBody && !args.HttpClient.Response.IsBodyRead && server.ShouldCallBeforeResponseBodyWrite()))) - { - return HandleBodyWrite(writer, isChunked, isRequest, args, cancellationToken); - } - - // For chunked request we need to read data as they arrive, until we reach a chunk end symbol - if (isChunked) return CopyBodyChunkedAsync(writer, isRequest, args, cancellationToken); - - // http 1.0 or the stream reader limits the stream - if (contentLength == -1) contentLength = long.MaxValue; - - // If not chunked then its easy just read the amount of bytes mentioned in content length header - return CopyBytesToStream(writer, contentLength, isRequest, args, cancellationToken); - } - - /// - /// Streams the body from this source stream to the target writer, invoking the - /// OnRequestBodyWrite / OnResponseBodyWrite handler for each buffer-sized piece so consumers - /// can inspect or modify the body chunk-by-chunk without buffering the whole body. - /// The bytes are exposed exactly as they arrive on the wire (still content-encoded if the message - /// uses Content-Encoding); on-the-fly decompression/recompression is not performed here in order to - /// preserve exact framing and length. Reads are bounded by bufferPool.BufferSize to keep memory flat. - /// - private async Task HandleBodyWrite(IHttpStreamWriter writer, bool isChunked, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - bool isRequest, SessionEventArgs args, CancellationToken cancellationToken) - { - var requestResponse = isRequest ? (RequestResponseBase)args.HttpClient.Request : args.HttpClient.Response; - - var originalContentLength = requestResponse.OriginalContentLength; - var originalIsChunked = requestResponse.OriginalIsChunked; - - async ValueTask writeFramed(byte[] data) - { - if (data.Length == 0) return; - - if (isChunked) - { - await writer.WriteLineAsync(data.Length.ToString("x"), cancellationToken); - await writer.WriteAsync(data, 0, data.Length, cancellationToken); - await writer.WriteLineAsync(cancellationToken); - } - else - { - await writer.WriteAsync(data, 0, data.Length, cancellationToken); - } - } - - async ValueTask writeTerminator() - { - if (isChunked) - { - await writer.WriteLineAsync("0", cancellationToken); - await ChunkedTrailerHelper.WriteTrailingHeadersAsync(writer, - requestResponse.HasTrailingHeaders ? requestResponse.TrailingHeaders : null, - cancellationToken); - } - } - - // returns true when writing should stop (either source end reached or handler requested it) - async Task emit(byte[] piece, bool isLastPiece) - { - var eventArgs = new BeforeBodyWriteEventArgs(args, piece, isChunked, isLastPiece); - - if (isRequest) - await server.OnBeforeRequestBodyWrite(eventArgs); - else - await server.OnBeforeResponseBodyWrite(eventArgs); - - if (eventArgs.BodyBytes is { Length: > 0 }) await writeFramed(eventArgs.BodyBytes); - - return isLastPiece || eventArgs.IsLastChunk; - } - - var buffer = bufferPool.GetBuffer(); - - // The handler ended the message before the source's real end (isLastChunk / handler-driven stop). - // Drain (read and discard) everything still remaining on the source - the rest of the chunk in - // progress, any further chunks, and the trailer block - so the underlying connection is left at a - // clean message boundary and can still be safely reused/pooled, even though none of this is - // relayed to `writer` (the consumer already decided to stop emitting). - async Task drainRemainingChunkedBody(long remainingInCurrentChunk) - { - while (remainingInCurrentChunk > 0) - { - var toRead = (int)Math.Min(buffer.Length, remainingInCurrentChunk); - var bytesRead = await ReadAsync(buffer.AsMemory(0, toRead), cancellationToken); - if (bytesRead == 0) return; - remainingInCurrentChunk -= bytesRead; - } - - // trailing CRLF of the chunk that was in progress - await ReadLineAsync(cancellationToken); - - while (true) - { - var chunkHead = await ReadLineAsync(cancellationToken); - if (chunkHead == null) return; - - if (!ChunkSizeParser.TryParse(chunkHead, ProxyLimits.DefaultMaxChunkSizeBytes, out var chunkSize)) - throw new ProxyHttpException($"Invalid chunk length: '{chunkHead}'", null, null); - - if (chunkSize == 0) - { - // discard the trailer block too - it belongs to a message we chose not to forward in full - await ChunkedTrailerHelper.ReadTrailingHeaders(this, new HeaderCollection(), null, - cancellationToken); - return; - } - - var toDiscard = chunkSize; - while (toDiscard > 0) - { - var toRead = (int)Math.Min(buffer.Length, toDiscard); - var bytesRead = await ReadAsync(buffer.AsMemory(0, toRead), cancellationToken); - if (bytesRead == 0) return; - toDiscard -= bytesRead; - } - - // trailing CRLF after chunk data - await ReadLineAsync(cancellationToken); - } - } - - try - { - if (originalIsChunked) - { - while (true) - { - var chunkHead = await ReadLineAsync(cancellationToken); - if (chunkHead == null) break; - - if (!ChunkSizeParser.TryParse(chunkHead, ProxyLimits.DefaultMaxChunkSizeBytes, out var chunkSize)) - throw new ProxyHttpException($"Invalid chunk length: '{chunkHead}'", null, null); - - if (chunkSize == 0) - { - // Read the optional trailer header block, strictly through the terminating blank - // line, populating requestResponse.TrailingHeaders (writeTerminator() below - // re-emits them for `writer`). See ChunkedTrailerHelper for why this is bounded. - await ChunkedTrailerHelper.ReadTrailingHeaders(this, requestResponse.TrailingHeaders, - null, cancellationToken); - await emit(Array.Empty(), true); - break; - } - - var remaining = chunkSize; - var stop = false; - while (remaining > 0) - { - var toRead = (int)Math.Min(buffer.Length, remaining); - var bytesRead = await ReadAsync(buffer.AsMemory(0, toRead), cancellationToken); - if (bytesRead == 0) - throw new ProxyHttpException("Unexpected end of stream while reading chunk body.", null, args); - - remaining -= bytesRead; - - if (isRequest) args.OnDataSent(buffer, 0, bytesRead); - else args.OnDataReceived(buffer, 0, bytesRead); - - // Fresh array per chunk so BeforeBodyWrite handlers may retain BodyBytes - // across callbacks without seeing later overwrites (matches H2 body-write). - var piece = new byte[bytesRead]; - Buffer.BlockCopy(buffer, 0, piece, 0, bytesRead); - - if (await emit(piece, false)) - { - stop = true; - break; - } - } - - if (stop) - { - await drainRemainingChunkedBody(remaining); - break; - } - - // trailing CRLF after chunk data - await ReadLineAsync(cancellationToken); - } - - await writeTerminator(); - } - else - { - var remaining = originalContentLength == -1 ? long.MaxValue : originalContentLength; - - while (remaining > 0) - { - var toRead = (int)Math.Min(buffer.Length, remaining); - var bytesRead = await ReadAsync(buffer.AsMemory(0, toRead), cancellationToken); - if (bytesRead == 0) break; - - remaining -= bytesRead; - - if (isRequest) args.OnDataSent(buffer, 0, bytesRead); - else args.OnDataReceived(buffer, 0, bytesRead); - - var piece = new byte[bytesRead]; - Buffer.BlockCopy(buffer, 0, piece, 0, bytesRead); - - if (await emit(piece, remaining == 0)) break; - } - - await writeTerminator(); - } - } - finally - { - bufferPool.ReturnBuffer(buffer); - } - } - - /// - /// Copies the given input bytes to output stream chunked - /// - /// - /// Optional trailer headers to emit after the terminating zero-length chunk. - /// - /// - private async ValueTask WriteBodyChunkedAsync(byte[] data, HeaderCollection? trailingHeaders, - CancellationToken cancellationToken) - { - var chunkHead = Encoding.ASCII.GetBytes(data.Length.ToString("x2")); - - await WriteAsync(chunkHead, cancellationToken: cancellationToken); - await WriteLineAsync(cancellationToken); - await WriteAsync(data, cancellationToken: cancellationToken); - await WriteLineAsync(cancellationToken); - - await WriteLineAsync("0", cancellationToken); - await ChunkedTrailerHelper.WriteTrailingHeadersAsync(this, trailingHeaders, cancellationToken); - } - - /// - /// Copies the streams chunked - /// - /// - /// - /// - /// - private async Task CopyBodyChunkedAsync(IHttpStreamWriter writer, bool isRequest, SessionEventArgs args, - CancellationToken cancellationToken) - { - var requestResponse = isRequest ? (RequestResponseBase)args.HttpClient.Request : args.HttpClient.Response; - - while (true) - { - var chunkHead = await ReadLineAsync(cancellationToken); - if (chunkHead == null) return; - - if (!ChunkSizeParser.TryParse(chunkHead, ProxyLimits.DefaultMaxChunkSizeBytes, out var chunkSize)) - throw new ProxyHttpException($"Invalid chunk length: '{chunkHead}'", null, null); - - await writer.WriteLineAsync(chunkHead, cancellationToken); - - if (chunkSize == 0) - { - // Read the optional trailer header block, strictly through the terminating blank line - - // even when there turn out to be no trailers - so a pooled keep-alive connection never - // retains stray trailer bytes that would corrupt the next message (see ChunkedTrailerHelper). - // This is a pure pass-through relay, so the exact raw lines are also captured and forwarded - // to `writer` byte-for-byte below, rather than re-serializing the parsed HeaderCollection. - var rawTrailerLines = new List(); - await ChunkedTrailerHelper.ReadTrailingHeaders(this, requestResponse.TrailingHeaders, - rawTrailerLines, cancellationToken); - - await ChunkedTrailerHelper.WriteRawTrailingLinesAsync(writer, rawTrailerLines, cancellationToken); - - break; - } - - await CopyBytesToStream(writer, chunkSize, isRequest, args, cancellationToken); - - await writer.WriteLineAsync(cancellationToken); - - // chunk trail - await ReadLineAsync(cancellationToken); - } - } - - /// - /// Copies the specified bytes to the stream from the input stream - /// - /// - /// - /// - /// - /// - private async Task CopyBytesToStream(IHttpStreamWriter writer, long count, bool isRequest, SessionEventArgs args, // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - CancellationToken cancellationToken) - { - var remainingBytes = count; - var httpWriter = writer as HttpStream; - // YARP StreamCopier uses 64 KiB; H2→H1 large-read bypass never ran on this FillBuffer loop. - // Empty parser window + remaining ≥ streamBuffer → rent a large window and ReadAsync so - // BaseStream fills directly (classic BufferedStream rule). Cuts origin read / SslStream - // WriteAsync count on known-CL reverse bodies (e.g. 256 KiB: ~32×8 KiB → ~4×64 KiB). - const int largeCopyGrain = 64 * 1024; - byte[]? largeBuf = null; - - try - { - while (remainingBytes > 0) - { - if (Available == 0 && remainingBytes >= streamBuffer.Length && streamBuffer.Length > 0) - { - var grain = (int)Math.Min(remainingBytes, largeCopyGrain); - if (largeBuf == null || largeBuf.Length < grain) - { - if (largeBuf != null) - bufferPool.ReturnBuffer(largeBuf); - - largeBuf = bufferPool.GetBuffer(grain); - } - - var read = await ReadAsync(largeBuf.AsMemory(0, grain), cancellationToken); - if (read == 0) - break; - - if (httpWriter != null) - await httpWriter.WriteAsync(largeBuf.AsMemory(0, read), cancellationToken); - else - await writer.WriteAsync(largeBuf, 0, read, cancellationToken); - - if (isRequest) - args.OnDataSent(largeBuf, 0, read); - else - args.OnDataReceived(largeBuf, 0, read); - - remainingBytes -= read; - continue; - } - - if (Available == 0) - { - var fill = await FillBufferWithResultAsync(cancellationToken); - if (fill == BufferFillResult.Cancelled) - cancellationToken.ThrowIfCancellationRequested(); - if (fill != BufferFillResult.GotData) - break; - } - - var n = (int)Math.Min(Available, remainingBytes); - var offset = bufferPos; - - // Write the unread window in place — no second pooled rent/copy. Await before the next - // fill: FillBuffer compact-moves streamBuffer and would invalidate this window. - if (httpWriter != null) - await httpWriter.WriteAsync(streamBuffer.AsMemory(offset, n), cancellationToken); - else - await writer.WriteAsync(streamBuffer, offset, n, cancellationToken); - - if (isRequest) - args.OnDataSent(streamBuffer, offset, n); - else - args.OnDataReceived(streamBuffer, offset, n); - - bufferPos += n; - Available -= n; - remainingBytes -= n; - } - } - finally - { - if (largeBuf != null) - bufferPool.ReturnBuffer(largeBuf); - } - } - - /// - /// Writes the request/response headers and body. - /// - /// - /// - /// - /// - protected async ValueTask WriteAsync(RequestResponseBase requestResponse, HeaderBuilder headerBuilder, - CancellationToken cancellationToken = default) - { - var body = requestResponse.CompressBodyAndUpdateContentLength(); - headerBuilder.WriteHeaders(requestResponse.Headers); - - // Fixed-length body up to one large-copy grain: one SslStream/NetworkStream write - // (headers+body) instead of a header-only TLS record + body records. Matches YARP's - // larger first forward write under delay-sensitive workloads (compare-lossy). - if (body != null - && body.Length <= 64 * 1024 - && !requestResponse.IsChunked - && !requestResponse.HasTrailingHeaders) - { - headerBuilder.WriteRaw(body); - await WriteHeadersAsync(headerBuilder, cancellationToken); - requestResponse.IsBodySent = true; - return; - } - - await WriteHeadersAsync(headerBuilder, cancellationToken); - - if (body != null) - { - await WriteBodyAsync(body, requestResponse.IsChunked, - requestResponse.HasTrailingHeaders ? requestResponse.TrailingHeaders : null, cancellationToken); - requestResponse.IsBodySent = true; - } - } - - /// - /// Asynchronously writes a sequence of bytes to the current stream, advances the current position within this stream by the number of bytes written, and monitors cancellation requests. - /// - /// The buffer to write data from. - /// The token to monitor for cancellation requests. The default value is . - /// A task that represents the asynchronous write operation. - public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = - default) - { - // Only materialize a heap copy when a DataWrite subscriber needs a byte[] and the - // memory is not already array-backed. - if (DataWrite != null) - { - if (MemoryMarshal.TryGetArray(buffer, out var segment)) - OnDataWrite(segment.Array!, segment.Offset, segment.Count); - else - OnDataWrite(buffer.ToArray(), 0, buffer.Length); - } - - return WriteToBaseStreamAsync(buffer, cancellationToken); - } -} \ No newline at end of file +} diff --git a/src/Titanium.Web.Proxy/Network/TcpConnection/TcpClientConnection.cs b/src/Titanium.Web.Proxy/Network/TcpConnection/TcpClientConnection.cs index c9961b42a..9dc27a1cb 100644 --- a/src/Titanium.Web.Proxy/Network/TcpConnection/TcpClientConnection.cs +++ b/src/Titanium.Web.Proxy/Network/TcpConnection/TcpClientConnection.cs @@ -173,20 +173,21 @@ public int GetProcessId(ProxyEndPoint endPoint) if (processId.HasValue) return processId.Value; - if (RunTime.IsWindows) + if (!Titanium.Web.Proxy.ClientProcessId.IsSupported) { - var remoteEndPoint = (IPEndPoint)RemoteEndPoint; + processId = -1; + return -1; + } - // If client is localhost get the process id - if (NetworkHelper.IsLocalIpAddress(remoteEndPoint.Address)) - processId = TcpHelper.GetProcessIdByLocalPort(endPoint.IpAddress.AddressFamily, remoteEndPoint.Port); - else - // can't access process Id of remote request from remote machine - processId = -1; + var remoteEndPoint = (IPEndPoint)RemoteEndPoint; - return processId.Value; - } + // If client is localhost get the process id + if (NetworkHelper.IsLocalIpAddress(remoteEndPoint.Address)) + processId = TcpHelper.GetProcessIdByLocalPort(endPoint.IpAddress.AddressFamily, remoteEndPoint.Port); + else + // can't access process Id of remote request from remote machine + processId = -1; - throw new PlatformNotSupportedException(); + return processId.Value; } } \ No newline at end of file diff --git a/src/Titanium.Web.Proxy/Network/TcpConnection/TcpConnectionFactory.cs b/src/Titanium.Web.Proxy/Network/TcpConnection/TcpConnectionFactory.cs index bd082ca34..3d30503f2 100644 --- a/src/Titanium.Web.Proxy/Network/TcpConnection/TcpConnectionFactory.cs +++ b/src/Titanium.Web.Proxy/Network/TcpConnection/TcpConnectionFactory.cs @@ -608,14 +608,16 @@ internal bool TryRentPooled(ProxyServer proxyServer, string cacheKey, var connectHostName = string.IsNullOrEmpty(connectHost) ? remoteHostName : connectHost; var connectPortNumber = connectPort ?? remotePort; - // deny connection to proxy end points to avoid infinite connection loop. - if (Server.ProxyEndPoints.Any(x => x.Port == connectPortNumber) + // Deny TCP origin connects that would loop into one of our TCP listeners. + // UDP-only QUIC endpoints (Listener == null) share a port number with a distinct transport — + // matching them here falsely blocks H3→TCP origin when Kestrel reuses that port. + if (Server.ProxyEndPoints.Any(x => x.Port == connectPortNumber && x.Listener != null) && NetworkHelper.IsLocalIpAddress(connectHostName)) throw new InvalidOperationException( $"A client is making HTTP request to one of the listening ports of this proxy {connectHostName}:{connectPortNumber}"); if (externalProxy != null && - Server.ProxyEndPoints.Any(x => x.Port == externalProxy.Port) && + Server.ProxyEndPoints.Any(x => x.Port == externalProxy.Port && x.Listener != null) && NetworkHelper.IsLocalIpAddress(externalProxy.HostName)) throw new InvalidOperationException( $"A client is making HTTP request via external proxy to one of the listening ports of this proxy {remoteHostName}:{remotePort}"); diff --git a/src/Titanium.Web.Proxy/Options/ProxyResourceLimits.cs b/src/Titanium.Web.Proxy/Options/ProxyResourceLimits.cs index 8ed9a8f5b..27125b237 100644 --- a/src/Titanium.Web.Proxy/Options/ProxyResourceLimits.cs +++ b/src/Titanium.Web.Proxy/Options/ProxyResourceLimits.cs @@ -226,7 +226,10 @@ private ProxyResourceLimits() MaxOpenHeaderBlockDuration = maxOpenHeaderBlockDuration, ConnectionPoolingEnabled = connectionPoolingEnabled, MaxCachedConnectionsPerHost = maxCachedConnectionsPerHost, - MaxOriginHttp2ConnectionsPerAuthority = 8, + // SoftPick SoftGrow=SoftCap. Offer-once (seed only when pool empty) + MaxOrigin=1: + // H1 ALPN Offer flood ~0.89×; MaxOrigin=1 SoftPick long ~0.95× H1 TLS. SoftGrow=8 + // cleartext MaxOrigin=8 Offer-once regresses local H3→h2c. SoftGrow=16 Offer-once rejected. + MaxOriginHttp2ConnectionsPerAuthority = 1, MaxCertificateCacheEntries = maxCertificateCacheEntries }; } diff --git a/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs b/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs index cc095045f..3fea37c4b 100644 --- a/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs +++ b/src/Titanium.Web.Proxy/Properties/AssemblyInfo.cs @@ -40,6 +40,18 @@ "de7f3ba0bdad35ec2d6057ee1846091b34be2abc3f97dc7e72c16fd4958c15126b12923df76964" + "7d84922c3f4f3b80ee0ae8e4cb40bc1973b782afb90bb00519fd16adf960f217e23696e7c31654" + "01d0acd6")] +[assembly: InternalsVisibleTo("Titanium.Plus.Tests, PublicKey=" + + "0024000004800000940000000602000000240000525341310004000001000100e7368e0ccc717e" + + "eb4d57d35ad6a8305cbbed14faa222e13869405e92c83856266d400887d857005f1393ffca2b92" + + "de7f3ba0bdad35ec2d6057ee1846091b34be2abc3f97dc7e72c16fd4958c15126b12923df76964" + + "7d84922c3f4f3b80ee0ae8e4cb40bc1973b782afb90bb00519fd16adf960f217e23696e7c31654" + + "01d0acd6")] +[assembly: InternalsVisibleTo("Titanium.Inspector.Tests, PublicKey=" + + "0024000004800000940000000602000000240000525341310004000001000100e7368e0ccc717e" + + "eb4d57d35ad6a8305cbbed14faa222e13869405e92c83856266d400887d857005f1393ffca2b92" + + "de7f3ba0bdad35ec2d6057ee1846091b34be2abc3f97dc7e72c16fd4958c15126b12923df76964" + + "7d84922c3f4f3b80ee0ae8e4cb40bc1973b782afb90bb00519fd16adf960f217e23696e7c31654" + + "01d0acd6")] // Setting ComVisible to false makes the types in this assembly not visible // to COM components. If you need to access a type in this assembly from @@ -65,5 +77,5 @@ // file-properties version disagreed with the package it was published in. Keep both of the values // below equal to (as Major.Minor.Build.0) whenever that property changes. -[assembly: AssemblyVersion("7.0.4.0")] -[assembly: AssemblyFileVersion("7.0.4.0")] +[assembly: AssemblyVersion("7.0.5.0")] +[assembly: AssemblyFileVersion("7.0.5.0")] diff --git a/src/Titanium.Web.Proxy/ProxyServer.Endpoints.cs b/src/Titanium.Web.Proxy/ProxyServer.Endpoints.cs new file mode 100644 index 000000000..87fa169a4 --- /dev/null +++ b/src/Titanium.Web.Proxy/ProxyServer.Endpoints.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Titanium.Web.Proxy.Diagnostics; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Helpers.WinHttp; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Http2; +using Titanium.Web.Proxy.Logging; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network; +using Titanium.Web.Proxy.Network.Quic; +using Titanium.Web.Proxy.Network.Tcp; +using Titanium.Web.Proxy.Network.WinAuth; +using Titanium.Web.Proxy.Abstractions; +using Titanium.Web.Proxy.Options; +using Titanium.Web.Proxy.StreamExtended.BufferPool; + +namespace Titanium.Web.Proxy; + +/// +/// +/// This class is the backbone of proxy. One can create as many instances as needed. +/// However care should be taken to avoid using the same listening ports across multiple instances. +/// +public partial class ProxyServer : IDisposable +{ + /// + /// Add a proxy end point. + /// + /// The proxy endpoint. + public void AddEndPoint(ProxyEndPoint endPoint) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + { + if (ProxyEndPoints.Any(x => + x.IpAddress.Equals(endPoint.IpAddress) && endPoint.Port != 0 && x.Port == endPoint.Port)) + throw new InvalidOperationException("Cannot add another endpoint to same port & ip address"); + + ProxyEndPoints.Add(endPoint); + + if (ProxyRunning && endPoint is TransparentQuicProxyEndPoint quicEndPoint) + { + quicListenerCts ??= new CancellationTokenSource(); + ListenQuic(quicEndPoint); + } + else if (ProxyRunning) + { + var wantDualQuic = EnableHttp3 && endPoint is TransparentProxyEndPoint { EnableHttp3: true }; + var ephemeralDual = wantDualQuic && endPoint.Port == 0; + const int maxDualListenAttempts = 20; + var dualAttempts = 0; + while (true) + { + dualAttempts++; + Listen(endPoint); + + if (!wantDualQuic) + break; + + var dualListen = (TransparentProxyEndPoint)endPoint; + if (!dualListen.DecryptSsl) + throw new InvalidOperationException( + "TransparentProxyEndPoint.EnableHttp3 requires DecryptSsl = true."); + + try + { + quicListenerCts ??= new CancellationTokenSource(); + ListenQuic(dualListen); + break; + } + catch (Exception ex) when (ephemeralDual && dualAttempts < maxDualListenAttempts + && IsAddressAlreadyInUse(ex)) + { + QuitListenQuic(dualListen); + QuitListen(endPoint); + endPoint.Port = 0; + } + } + } + } + + /// + /// Remove a proxy end point. + /// Will throw error if the end point doesn't exist. + /// + /// The existing endpoint to remove. + public void RemoveEndPoint(ProxyEndPoint endPoint) + { + if (!ProxyEndPoints.Contains(endPoint)) + throw new InvalidOperationException("Cannot remove endPoints not added to proxy"); + + ProxyEndPoints.Remove(endPoint); + + if (ProxyRunning && endPoint is TransparentQuicProxyEndPoint quicEndPoint) + QuitListenQuic(quicEndPoint); + else if (ProxyRunning) + { + if (endPoint is TransparentProxyEndPoint { EnableHttp3: true } dualListen) + QuitListenQuic(dualListen); + QuitListen(endPoint); + } + } + /// + /// Update client connection count. + /// + /// Should we increment/decrement? + internal void UpdateClientConnectionCount(bool increment) + { + if (increment) + Interlocked.Increment(ref clientConnectionCount); + else + Interlocked.Decrement(ref clientConnectionCount); + + try + { + ClientConnectionCountChanged?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + OnException(null, ex); + } + } + + /// + /// Update server connection count. + /// + /// Should we increment/decrement? + internal void UpdateServerConnectionCount(bool increment) + { + if (increment) + Interlocked.Increment(ref serverConnectionCount); + else + Interlocked.Decrement(ref serverConnectionCount); + + try + { + ServerConnectionCountChanged?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + OnException(null, ex); + } + } + + /// + /// Update inbound HTTP/3 client connection count. + /// + /// Should we increment/decrement? + internal void UpdateHttp3ClientConnectionCount(bool increment) + { + if (increment) + Interlocked.Increment(ref http3ClientConnectionCount); + else + Interlocked.Decrement(ref http3ClientConnectionCount); + + try + { + Http3ClientConnectionCountChanged?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + OnException(null, ex); + } + } + + /// + /// Update upstream HTTP/3 server connection count. + /// + /// Should we increment/decrement? + internal void UpdateHttp3ServerConnectionCount(bool increment) + { + if (increment) + Interlocked.Increment(ref http3ServerConnectionCount); + else + Interlocked.Decrement(ref http3ServerConnectionCount); + + try + { + Http3ServerConnectionCountChanged?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + OnException(null, ex); + } + } + + /// + /// Invoke client tcp connection events if subscribed by API user. + /// + /// The TcpClient object. + /// + internal Task InvokeClientConnectionCreateEvent(Socket clientSocket) + { + return OnClientConnectionCreate != null + ? OnClientConnectionCreate.InvokeAsync(this, clientSocket, logger) + : Task.CompletedTask; + } + + /// + /// Invoke server tcp connection events if subscribed by API user. + /// + /// The Socket object. + /// + internal Task InvokeServerConnectionCreateEvent(Socket serverSocket) + { + return OnServerConnectionCreate != null + ? OnServerConnectionCreate.InvokeAsync(this, serverSocket, logger) + : Task.CompletedTask; + } +} diff --git a/src/Titanium.Web.Proxy/ProxyServer.Lifecycle.cs b/src/Titanium.Web.Proxy/ProxyServer.Lifecycle.cs new file mode 100644 index 000000000..ccefc8ab3 --- /dev/null +++ b/src/Titanium.Web.Proxy/ProxyServer.Lifecycle.cs @@ -0,0 +1,455 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Titanium.Web.Proxy.Diagnostics; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Helpers.WinHttp; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Http2; +using Titanium.Web.Proxy.Logging; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network; +using Titanium.Web.Proxy.Network.Quic; +using Titanium.Web.Proxy.Network.Tcp; +using Titanium.Web.Proxy.Network.WinAuth; +using Titanium.Web.Proxy.Abstractions; +using Titanium.Web.Proxy.Options; +using Titanium.Web.Proxy.StreamExtended.BufferPool; + +namespace Titanium.Web.Proxy; + +/// +/// +/// This class is the backbone of proxy. One can create as many instances as needed. +/// However care should be taken to avoid using the same listening ports across multiple instances. +/// +public partial class ProxyServer : IDisposable +{ + /// + /// Start this proxy server instance. + /// + /// Transactional: if any endpoint fails to start, every listener this call already + /// started is stopped, the system-upstream-proxy resolver (if this call created one) is + /// disposed, and is left before the + /// exception propagates. A caller that catches the exception is left with an instance in + /// exactly the same state as before calling , not a partially-bound + /// proxy with some endpoints silently listening. + /// + /// + /// + /// Whether or not clear any system proxy settings which is pointing to our own endpoint (causing a cycle). + /// E.g due to ungracious proxy shutdown before. + /// + public void Start(bool changeSystemProxySettings = true) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + { + if (ProxyRunning) throw new InvalidOperationException("Proxy is already running."); + + // Freeze the active logging configuration for the duration of this run. + ApplyLoggingConfiguration(); + + SetThreadPoolMinThread(ThreadPoolWorkerThread); + + // Only create the root certificate when at least one endpoint will actually perform + // TLS decryption and does not already have a custom GenericCertificate. Endpoints + // whose DecryptSsl is false never need to generate leaf certificates, so creating a + // root PFX for them is unnecessary I/O and key-generation work. + if (ProxyEndPoints.Any(x => x.DecryptSsl && x.GenericCertificate == null)) + CertificateManager.EnsureRootCertificate(); + + if (changeSystemProxySettings && SystemProxySettingsManager != null) + { + try + { + var ownedPorts = ProxyEndPoints.Select(x => x.Port).ToHashSet(); + var protocolToRemove = SystemProxySettingsManager.GetStaleLocalProxyProtocols(ownedPorts); + if (protocolToRemove != ProxyProtocolType.None) + SystemProxySettingsManager.RemoveProxy(protocolToRemove, false); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Clearing stale system proxy on Start failed (continuing)"); + } + } + + var assignedSystemUpStreamResolver = false; + if (RunTime.IsWindows && ForwardToUpstreamGateway && GetCustomUpStreamProxyFunc == null && + SystemProxySettingsManager != null) + { + systemProxyResolver = new WinHttpWebProxyFinder(); + if (UpstreamProxyConfigurationScript != null) + //Use the provided proxy configuration script + systemProxyResolver.UsePacFile(UpstreamProxyConfigurationScript); + else + // Use WinHttp to handle PAC/WAPD scripts. + systemProxyResolver.LoadFromIe(); + + GetCustomUpStreamProxyFunc = GetSystemUpStreamProxy; + assignedSystemUpStreamResolver = true; + } + + ProxyRunning = true; + + // Name only, per the plan's rollout section - never hosts, URLs or secrets. + ProxyLog.EffectiveProfileAtStartup(logger, profile, policyModes); + + _ = CertificateManager.ClearIdleCertificates(); + + var startedTcpEndPoints = new List(); + var startedQuicEndPoints = new List(); + var createdQuicListenerCts = false; + + try + { + var hasUdpOnlyQuic = ProxyEndPoints.OfType().Any(); + var hasDualListenHttp3 = ProxyEndPoints.OfType() + .Any(e => e.EnableHttp3); + var needsInboundHttp3 = EnableHttp3 && (hasUdpOnlyQuic || hasDualListenHttp3); + + if (needsInboundHttp3) + quicListenerCts = new CancellationTokenSource(); + else if (EnableHttp3) + { + // Explicit/SOCKS endpoints speak TCP to the client; EnableHttp3 still correctly + // arms origin-side QUIC (Alt-Svc / H2↔H3 bridge). That is the Inspector/CLI happy + // path — do not warn. Warn only when nothing can use either inbound or origin H3 + // (no client-facing TCP endpoints), which usually means a misconfigured Start(). + var hasTcpClientFacingEndpoint = ProxyEndPoints.Any(e => + e is ExplicitProxyEndPoint or SocksProxyEndPoint or TransparentProxyEndPoint); + if (!hasTcpClientFacingEndpoint) + { + Logger.LogWarning( + "EnableHttp3 is true but no inbound HTTP/3 endpoint is registered. " + + "Add a TransparentQuicProxyEndPoint, or a TransparentProxyEndPoint with EnableHttp3, " + + "before calling Start()."); + } + } + + // UDP-only transparent QUIC first (no TCP on that port). + if (needsInboundHttp3 && hasUdpOnlyQuic) + { + createdQuicListenerCts = true; + foreach (var quicEndPoint in ProxyEndPoints.OfType()) + { + ListenQuic(quicEndPoint); + startedQuicEndPoints.Add(quicEndPoint); + } + } + + // TCP endpoints. Dual-listen reverse H3: bind TCP first (assign ephemeral port), then UDP + // on the same IP:port so HttpClient can discover H3 via Alt-Svc or RequestVersionExact. + // Windows TCP and UDP port spaces are independent — ephemeral TCP can land on a UDP port + // that is already taken / excluded (WSAEADDRINUSE). Retry ephemeral dual-listen binds. + foreach (var endPoint in ProxyEndPoints) + { + if (endPoint is TransparentQuicProxyEndPoint) + continue; + + if (endPoint is TransparentProxyEndPoint { EnableHttp3: true } dualListenGate) + { + if (!EnableHttp3) + throw new InvalidOperationException( + "TransparentProxyEndPoint.EnableHttp3 requires ProxyServer.EnableHttp3 = true."); + if (!dualListenGate.DecryptSsl) + throw new InvalidOperationException( + "TransparentProxyEndPoint.EnableHttp3 requires DecryptSsl = true."); + } + + var wantDualQuic = EnableHttp3 && endPoint is TransparentProxyEndPoint { EnableHttp3: true }; + var ephemeralDual = wantDualQuic && endPoint.Port == 0; + const int maxDualListenAttempts = 20; + var dualAttempts = 0; + while (true) + { + dualAttempts++; + Listen(endPoint); + + if (!wantDualQuic) + { + startedTcpEndPoints.Add(endPoint); + break; + } + + var dual = (TransparentProxyEndPoint)endPoint; + try + { + createdQuicListenerCts = true; + quicListenerCts ??= new CancellationTokenSource(); + ListenQuic(dual); + startedTcpEndPoints.Add(endPoint); + startedQuicEndPoints.Add(dual); + break; + } + catch (Exception ex) when (ephemeralDual && dualAttempts < maxDualListenAttempts + && IsAddressAlreadyInUse(ex)) + { + SafeRollback(() => QuitListenQuic(dual)); + SafeRollback(() => QuitListen(endPoint)); + endPoint.Port = 0; + } + } + } + } + catch (Exception startEx) + { + // Roll back, in reverse dependency order, everything this call already started. + // QuitListen/QuitListenQuic tolerate a listener that never started (no-op), so it is + // safe to call them uniformly rather than re-deriving exactly how far each one got. + ProxyDiagnostics.ReportCaught(logger, + "ProxyServer.Start failed; rolling back and rethrowing", startEx); + foreach (var quicEndPoint in startedQuicEndPoints) SafeRollback(() => QuitListenQuic(quicEndPoint)); + foreach (var endPoint in startedTcpEndPoints) SafeRollback(() => QuitListen(endPoint)); + + if (createdQuicListenerCts) + { + SafeRollback(() => quicListenerCts?.Cancel()); + SafeRollback(() => quicListenerCts?.Dispose()); + quicListenerCts = null; + } + + if (assignedSystemUpStreamResolver) + { + if (OperatingSystem.IsWindows()) + try + { + systemProxyResolver?.Dispose(); + } + catch (Exception ex) + { + OnException(null, ex); + } + + systemProxyResolver = null; + GetCustomUpStreamProxyFunc = null; + } + + ProxyRunning = false; + + throw; + } + } + + /// + /// Runs a single rollback step, reporting rather than propagating a + /// failure so one misbehaving teardown step cannot mask the original failure or abandon the + /// rest of the rollback. + /// + private void SafeRollback(Action rollbackStep) + { + try + { + rollbackStep(); + } + catch (Exception ex) + { + OnException(null, ex); + } + } + + /// + /// Stop this proxy server instance. + /// Endpoints remain registered so can re-listen on the same ports. + /// In-flight sessions are cancelled; pooled upstream connections are cleared. The connection + /// factory itself stays usable for a subsequent Start (it is only disposed with the proxy). + /// + public void Stop() + { + StopCore(cancelSessions: true, clearPools: true); + } + + /// + /// Asynchronously stop this proxy server, cancel in-flight sessions, and wait briefly for + /// client connection count to drain before clearing the upstream pool. + /// + /// + /// Maximum time to wait for active client handlers to exit after cancellation. + /// Defaults to 5 seconds. + /// + public async Task StopAsync(TimeSpan? drainTimeout = null) + { + if (!ProxyRunning) throw new InvalidOperationException("Proxy is not running."); + + StopCore(cancelSessions: true, clearPools: false); + + var timeout = drainTimeout ?? TimeSpan.FromSeconds(5); + var deadline = DateTime.UtcNow + timeout; + // Http3ClientConnectionCount tracks inbound QUIC clients separately from + // ClientConnectionCount (TCP-based H1/H2); draining only the former would let this + // return, and the pools below get cleared, while HTTP/3 streams are still in flight. + while ((ClientConnectionCount > 0 || Http3ClientConnectionCount > 0) && DateTime.UtcNow < deadline) + // StopCore already cancelled quicListenerCts; drain polling must not share that token. + await Task.Delay(50, CancellationToken.None).ConfigureAwait(false); + + TcpConnectionFactory.ClearPools(); + await QuicConnectionPool.DrainAsync(); + await Http2OriginConnectionPool.DrainAsync(); + } + + private void StopCore(bool cancelSessions, bool clearPools) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. + { + if (!ProxyRunning) throw new InvalidOperationException("Proxy is not running."); + + if (SystemProxySettingsManager != null) + { + var systemProxyEndPoints = ProxyEndPoints.OfType() + .Where(x => x.IsSystemHttpProxy || x.IsSystemHttpsProxy) + .ToList(); + + if (systemProxyEndPoints.Count > 0) + { + SystemProxySettingsManager.RestoreOriginalSettings(); + foreach (var endPoint in systemProxyEndPoints) + { + endPoint.IsSystemHttpProxy = false; + endPoint.IsSystemHttpsProxy = false; + } + } + } + + // Prevent accept callbacks from scheduling another accept while listeners are stopping. + ProxyRunning = false; + + if (cancelSessions) CancelActiveSessions(); + + foreach (var endPoint in ProxyEndPoints) + { + if (endPoint is TransparentQuicProxyEndPoint) + continue; + QuitListen(endPoint); + } + + // Cancel and wait for QUIC accept loops to exit (UDP-only + dual-listen reverse). + quicListenerCts?.Cancel(); + foreach (var quicEndPoint in ProxyEndPoints.OfType()) + QuitListenQuic(quicEndPoint); + foreach (var dual in ProxyEndPoints.OfType().Where(e => e.EnableHttp3)) + QuitListenQuic(dual); + quicListenerCts?.Dispose(); + quicListenerCts = null; + + // Keep ProxyEndPoints so Start() can re-bind the same listeners (issue #799). + + CertificateManager?.StopClearIdleCertificates(); + + if (clearPools) TcpConnectionFactory.ClearPools(); + if (clearPools) DrainPoolBlocking(QuicConnectionPool.DrainAsync()); + if (clearPools) DrainPoolBlocking(Http2OriginConnectionPool.DrainAsync()); + + // Start() may have wired GetCustomUpStreamProxyFunc to GetSystemUpStreamProxy and created + // systemProxyResolver to back it. Undo both together: leaving the callback in place while + // disposing its resolver below would make a subsequent Start() see GetCustomUpStreamProxyFunc + // != null and skip creating a fresh resolver, so the callback would call into a disposed + // WinHttpWebProxyFinder on the first request after restart. Only clear the callback if it is + // still the delegate we assigned - a caller who has since replaced it with their own must not + // have that overwritten here. + if (Equals(GetCustomUpStreamProxyFunc, (Func>)GetSystemUpStreamProxy)) + GetCustomUpStreamProxyFunc = null; + + // Release the WinHTTP session handle acquired during Start() (Windows-only type). + if (OperatingSystem.IsWindows()) + systemProxyResolver?.Dispose(); + systemProxyResolver = null; + } + private bool disposed; + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + [SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", + Justification = "Protected Dispose(bool) is required by the standard IDisposable pattern but is not public API.")] + protected virtual void Dispose(bool disposing) + { + if (disposed) return; + + if (disposing) + { + // No finalizer: Stop()/certificate/buffer disposal must only run on the explicit + // Dispose path. Callers that omit Dispose leave OS sockets to safe-handle cleanup. + StopIfRunning(); + DisposeConnectionResources(); + DisposeOwnedLoggerFactory(); + } + + disposed = true; + } + + private void StopIfRunning() + { + if (!ProxyRunning) return; + + try + { + Stop(); + } + catch + { + // ignore + } + } + + private void DisposeConnectionResources() + { + try + { + TcpConnectionFactory.Dispose(); + } + catch + { + // ignore + } + + try + { + DrainPoolBlocking(QuicConnectionPool.DrainAsync()); + } + catch + { + // ignore + } + + try + { + DrainPoolBlocking(Http2OriginConnectionPool.DrainAsync()); + } + catch + { + // ignore + } + + CertificateManager?.Dispose(); + BufferPool?.Dispose(); + _svcbDiscoveryCoordinator?.Dispose(); + + // SystemProxyManager is [SupportedOSPlatform("windows")]; the platform analyzer cannot + // prove that from a null-conditional access alone, so guard explicitly. + SystemProxySettingsManager?.Dispose(); + } + + private void DisposeOwnedLoggerFactory() + { + if (!ownsActiveLoggerFactory) return; + + try + { + activeLoggerFactory.Dispose(); + } + catch + { + // A misbehaving sink must never prevent proxy disposal from completing. + } + } +} diff --git a/src/Titanium.Web.Proxy/ProxyServer.SystemProxy.cs b/src/Titanium.Web.Proxy/ProxyServer.SystemProxy.cs new file mode 100644 index 000000000..25c39a21f --- /dev/null +++ b/src/Titanium.Web.Proxy/ProxyServer.SystemProxy.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Titanium.Web.Proxy.Diagnostics; +using Titanium.Web.Proxy.EventArguments; +using Titanium.Web.Proxy.Extensions; +using Titanium.Web.Proxy.Helpers; +using Titanium.Web.Proxy.Helpers.WinHttp; +using Titanium.Web.Proxy.Http; +using Titanium.Web.Proxy.Http2; +using Titanium.Web.Proxy.Logging; +using Titanium.Web.Proxy.Models; +using Titanium.Web.Proxy.Network; +using Titanium.Web.Proxy.Network.Quic; +using Titanium.Web.Proxy.Network.Tcp; +using Titanium.Web.Proxy.Network.WinAuth; +using Titanium.Web.Proxy.Abstractions; +using Titanium.Web.Proxy.Options; +using Titanium.Web.Proxy.StreamExtended.BufferPool; + +namespace Titanium.Web.Proxy; + +/// +/// +/// This class is the backbone of proxy. One can create as many instances as needed. +/// However care should be taken to avoid using the same listening ports across multiple instances. +/// +public partial class ProxyServer : IDisposable +{ + /// + /// Set the given explicit end point as the default proxy server for current machine. + /// + /// The explicit endpoint. + public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint) + { + SetAsSystemProxy(endPoint, ProxyProtocolType.Http); + } + + /// + /// Set the given explicit end point as the default HTTP proxy server for current machine. + /// + /// The explicit endpoint. + /// The Windows system proxy settings. + public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) + { + SetAsSystemProxy(endPoint, ProxyProtocolType.Http, settings); + } + + /// + /// Set the given explicit end point as the default proxy server for current machine. + /// + /// The explicit endpoint. + public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint) + { + SetAsSystemProxy(endPoint, ProxyProtocolType.Https); + } + + /// + /// Set the given explicit end point as the default HTTPS proxy server for current machine. + /// + /// The explicit endpoint. + /// The Windows system proxy settings. + public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) + { + SetAsSystemProxy(endPoint, ProxyProtocolType.Https, settings); + } + + /// + /// Set the given explicit end point as the default proxy server for current machine. + /// + /// The explicit endpoint. + /// The proxy protocol type. + public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType) + { + SetAsSystemProxy(endPoint, protocolType, null); + } + + /// + /// Set the given explicit end point as the default proxy server for current machine. + /// + /// The explicit endpoint. + /// The proxy protocol type. + /// + /// The Windows system proxy settings, or to preserve the current bypass list. + /// + public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, + SystemProxySettings? settings) + { + var result = TrySetAsSystemProxy(endPoint, protocolType, settings); + if (!result.Succeeded) + logger.LogWarning("SetAsSystemProxy failed: {Message}", result.Message); + } + + /// + /// Enable OS system proxy without throwing. Failures are logged and returned so Inspector/CLI + /// can show a status message instead of crashing. + /// + public SystemProxyChangeResult TrySetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, + SystemProxySettings? settings = null) + { + ArgumentNullException.ThrowIfNull(endPoint); + + if (SystemProxySettingsManager == null) + return SystemProxyChangeResult.Fail(SystemProxyNotSupportedMessage); + + try + { + ValidateEndPointAsSystemProxy(endPoint); + settings?.Validate(); + + var isHttp = (protocolType & ProxyProtocolType.Http) > 0; + var isHttps = (protocolType & ProxyProtocolType.Https) > 0; + + if (isHttps) + { + CertificateManager.EnsureRootCertificate(); + + if (!CertificateManager.CertValidated) + { + protocolType &= ~ProxyProtocolType.Https; + isHttps = false; + } + } + + if (isHttp) + ProxyEndPoints.OfType().ToList().ForEach(x => x.IsSystemHttpProxy = false); + + if (isHttps) + ProxyEndPoints.OfType().ToList().ForEach(x => x.IsSystemHttpsProxy = false); + + string? proxyOverride = null; + if (settings != null) + { + var currentProxyOverride = SystemProxySettingsManager.GetCurrentProxyOverride(); + proxyOverride = settings.BuildProxyOverride(currentProxyOverride); + } + + SystemProxySettingsManager.SetProxy( + FormatSystemProxyHostname(endPoint.IpAddress), + endPoint.Port, + protocolType, + proxyOverride); + + if (isHttp) endPoint.IsSystemHttpProxy = true; + if (isHttps) endPoint.IsSystemHttpsProxy = true; + + string? proxyType = null; + switch (protocolType) + { + case ProxyProtocolType.Http: + proxyType = "HTTP"; + break; + case ProxyProtocolType.Https: + proxyType = "HTTPS"; + break; + case ProxyProtocolType.AllHttp: + proxyType = "HTTP and HTTPS"; + break; + } + + var message = protocolType == ProxyProtocolType.None + ? "System proxy request completed with no HTTP(S) protocols enabled" + : $"Set endpoint at Ip {endPoint.IpAddress} and port: {endPoint.Port} as System {proxyType} Proxy"; + if (protocolType != ProxyProtocolType.None) + ProxyDiagnostics.ReportInformation(logger, message); + + return SystemProxyChangeResult.Ok(message); + } + catch (Exception ex) + { + logger.LogWarning(ex, "System proxy enable failed"); + return SystemProxyChangeResult.Fail(ex.Message); + } + } + + /// + /// Clear HTTP proxy settings of current machine. + /// + public void DisableSystemHttpProxy() + { + DisableSystemProxy(ProxyProtocolType.Http); + } + + /// + /// Clear HTTPS proxy settings of current machine. + /// + public void DisableSystemHttpsProxy() + { + DisableSystemProxy(ProxyProtocolType.Https); + } + + /// + /// Restores the original proxy settings. + /// + public void RestoreOriginalProxySettings() + { + var result = TryRestoreOriginalProxySettings(); + if (!result.Succeeded) + logger.LogWarning("RestoreOriginalProxySettings failed: {Message}", result.Message); + } + + /// Restore OS proxy without throwing. + public SystemProxyChangeResult TryRestoreOriginalProxySettings() + { + if (SystemProxySettingsManager == null) + return SystemProxyChangeResult.Fail(SystemProxyNotSupportedMessage); + + try + { + SystemProxySettingsManager.RestoreOriginalSettings(); + ClearEndpointSystemProxyFlags(ProxyProtocolType.AllHttp); + return SystemProxyChangeResult.Ok("System proxy restored"); + } + catch (Exception ex) + { + logger.LogWarning(ex, "System proxy restore failed"); + return SystemProxyChangeResult.Fail(ex.Message); + } + } + + /// + /// Clear the specified proxy setting for current machine. + /// + public void DisableSystemProxy(ProxyProtocolType protocolType) + { + var result = TryDisableSystemProxy(protocolType); + if (!result.Succeeded) + logger.LogWarning("DisableSystemProxy failed: {Message}", result.Message); + } + + /// Clear OS proxy for the given protocols without throwing. + public SystemProxyChangeResult TryDisableSystemProxy(ProxyProtocolType protocolType) + { + if (SystemProxySettingsManager == null) + return SystemProxyChangeResult.Fail(SystemProxyNotSupportedMessage); + + try + { + SystemProxySettingsManager.RemoveProxy(protocolType); + ClearEndpointSystemProxyFlags(protocolType); + return SystemProxyChangeResult.Ok("System proxy disabled"); + } + catch (Exception ex) + { + logger.LogWarning(ex, "System proxy disable failed"); + return SystemProxyChangeResult.Fail(ex.Message); + } + } + + /// + /// Clear all proxy settings for current machine. + /// + public void DisableAllSystemProxies() + { + var result = TryDisableAllSystemProxies(); + if (!result.Succeeded) + logger.LogWarning("DisableAllSystemProxies failed: {Message}", result.Message); + } + + /// Clear all OS proxy settings without throwing. + public SystemProxyChangeResult TryDisableAllSystemProxies() + { + if (SystemProxySettingsManager == null) + return SystemProxyChangeResult.Fail(SystemProxyNotSupportedMessage); + + try + { + SystemProxySettingsManager.DisableAllProxy(); + ClearEndpointSystemProxyFlags(ProxyProtocolType.AllHttp); + return SystemProxyChangeResult.Ok("All system proxies disabled"); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Disable all system proxies failed"); + return SystemProxyChangeResult.Fail(ex.Message); + } + } + + /// + /// Clears / + /// on every endpoint for the protocol(s) + /// named in , so those flags never outlive the registry setting + /// they were tracking. + /// + private void ClearEndpointSystemProxyFlags(ProxyProtocolType protocolType) + { + var clearHttp = protocolType.HasFlag(ProxyProtocolType.Http); + var clearHttps = protocolType.HasFlag(ProxyProtocolType.Https); + if (!clearHttp && !clearHttps) return; + + foreach (var endPoint in ProxyEndPoints.OfType()) + { + if (clearHttp) endPoint.IsSystemHttpProxy = false; + if (clearHttps) endPoint.IsSystemHttpsProxy = false; + } + } +} diff --git a/src/Titanium.Web.Proxy/ProxyServer.cs b/src/Titanium.Web.Proxy/ProxyServer.cs index a41545b95..d2b918401 100644 --- a/src/Titanium.Web.Proxy/ProxyServer.cs +++ b/src/Titanium.Web.Proxy/ProxyServer.cs @@ -546,6 +546,14 @@ internal Http3.Dns.IHttpsSvcbResolver HttpsSvcbResolver /// public X509RevocationMode CheckCertificateRevocation { get; set; } + /// + /// When , origin TLS certificates that fail OS chain validation are + /// still accepted (MITM of loopback/self-signed/private CAs). Inspector's + /// "Ignore server certificate errors" maps here. Default . + /// A subscribed still wins. + /// + public bool IgnoreServerCertificateErrors { get; set; } + /// /// Does this proxy uses the HTTP protocol 100 continue behaviour strictly? /// Broken 100 continue implementations on server/client may cause problems if enabled. @@ -1336,602 +1344,8 @@ internal bool ShouldIntercept(HttpInterceptionContext ctx, ProxyEndPoint? endPoi /// public int ThreadPoolWorkerThread { get; set; } = Math.Max(Environment.ProcessorCount * 8, 64); - /// - /// Add a proxy end point. - /// - /// The proxy endpoint. - public void AddEndPoint(ProxyEndPoint endPoint) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - { - if (ProxyEndPoints.Any(x => - x.IpAddress.Equals(endPoint.IpAddress) && endPoint.Port != 0 && x.Port == endPoint.Port)) - throw new InvalidOperationException("Cannot add another endpoint to same port & ip address"); - - ProxyEndPoints.Add(endPoint); - - if (ProxyRunning && endPoint is TransparentQuicProxyEndPoint quicEndPoint) - { - quicListenerCts ??= new CancellationTokenSource(); - ListenQuic(quicEndPoint); - } - else if (ProxyRunning) - { - var wantDualQuic = EnableHttp3 && endPoint is TransparentProxyEndPoint { EnableHttp3: true }; - var ephemeralDual = wantDualQuic && endPoint.Port == 0; - const int maxDualListenAttempts = 20; - var dualAttempts = 0; - while (true) - { - dualAttempts++; - Listen(endPoint); - - if (!wantDualQuic) - break; - - var dualListen = (TransparentProxyEndPoint)endPoint; - if (!dualListen.DecryptSsl) - throw new InvalidOperationException( - "TransparentProxyEndPoint.EnableHttp3 requires DecryptSsl = true."); - - try - { - quicListenerCts ??= new CancellationTokenSource(); - ListenQuic(dualListen); - break; - } - catch (Exception ex) when (ephemeralDual && dualAttempts < maxDualListenAttempts - && IsAddressAlreadyInUse(ex)) - { - QuitListenQuic(dualListen); - QuitListen(endPoint); - endPoint.Port = 0; - } - } - } - } - - /// - /// Remove a proxy end point. - /// Will throw error if the end point doesn't exist. - /// - /// The existing endpoint to remove. - public void RemoveEndPoint(ProxyEndPoint endPoint) - { - if (!ProxyEndPoints.Contains(endPoint)) - throw new InvalidOperationException("Cannot remove endPoints not added to proxy"); - - ProxyEndPoints.Remove(endPoint); - - if (ProxyRunning && endPoint is TransparentQuicProxyEndPoint quicEndPoint) - QuitListenQuic(quicEndPoint); - else if (ProxyRunning) - { - if (endPoint is TransparentProxyEndPoint { EnableHttp3: true } dualListen) - QuitListenQuic(dualListen); - QuitListen(endPoint); - } - } - - /// - /// Set the given explicit end point as the default proxy server for current machine. - /// - /// The explicit endpoint. - public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint) - { - SetAsSystemProxy(endPoint, ProxyProtocolType.Http); - } - - /// - /// Set the given explicit end point as the default HTTP proxy server for current machine. - /// - /// The explicit endpoint. - /// The Windows system proxy settings. - public void SetAsSystemHttpProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) - { - SetAsSystemProxy(endPoint, ProxyProtocolType.Http, settings); - } - - /// - /// Set the given explicit end point as the default proxy server for current machine. - /// - /// The explicit endpoint. - public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint) - { - SetAsSystemProxy(endPoint, ProxyProtocolType.Https); - } - - /// - /// Set the given explicit end point as the default HTTPS proxy server for current machine. - /// - /// The explicit endpoint. - /// The Windows system proxy settings. - public void SetAsSystemHttpsProxy(ExplicitProxyEndPoint endPoint, SystemProxySettings settings) - { - SetAsSystemProxy(endPoint, ProxyProtocolType.Https, settings); - } - - /// - /// Set the given explicit end point as the default proxy server for current machine. - /// - /// The explicit endpoint. - /// The proxy protocol type. - public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType) - { - SetAsSystemProxy(endPoint, protocolType, null); - } - - /// - /// Set the given explicit end point as the default proxy server for current machine. - /// - /// The explicit endpoint. - /// The proxy protocol type. - /// - /// The Windows system proxy settings, or to preserve the current bypass list. - /// - public void SetAsSystemProxy(ExplicitProxyEndPoint endPoint, ProxyProtocolType protocolType, - SystemProxySettings? settings) - { - if (SystemProxySettingsManager == null) - throw new NotSupportedException(SystemProxyNotSupportedMessage); - - ValidateEndPointAsSystemProxy(endPoint); - - // Validate bypass rules up front so a malformed rule cannot leave the proxy state half-applied. - settings?.Validate(); - - var isHttp = (protocolType & ProxyProtocolType.Http) > 0; - var isHttps = (protocolType & ProxyProtocolType.Https) > 0; - - if (isHttps) - { - CertificateManager.EnsureRootCertificate(); - - // If certificate was trusted by the machine - if (!CertificateManager.CertValidated) - { - protocolType = protocolType & ~ProxyProtocolType.Https; - isHttps = false; - } - } - - // clear any settings previously added - if (isHttp) ProxyEndPoints.OfType().ToList().ForEach(x => x.IsSystemHttpProxy = false); - - if (isHttps) ProxyEndPoints.OfType().ToList().ForEach(x => x.IsSystemHttpsProxy = false); - - string? proxyOverride = null; - if (settings != null) - { - var currentProxyOverride = SystemProxySettingsManager.GetCurrentProxyOverride(); - proxyOverride = settings.BuildProxyOverride(currentProxyOverride); - } - - SystemProxySettingsManager.SetProxy( - Equals(endPoint.IpAddress, IPAddress.Any) || - Equals(endPoint.IpAddress, IPAddress.Loopback) - ? "localhost" - : endPoint.IpAddress.ToString(), - endPoint.Port, - protocolType, - proxyOverride); - - if (isHttp) endPoint.IsSystemHttpProxy = true; - - if (isHttps) endPoint.IsSystemHttpsProxy = true; - - string? proxyType = null; - switch (protocolType) - { - case ProxyProtocolType.Http: - proxyType = "HTTP"; - break; - case ProxyProtocolType.Https: - proxyType = "HTTPS"; - break; - case ProxyProtocolType.AllHttp: - proxyType = "HTTP and HTTPS"; - break; - } - - if (protocolType != ProxyProtocolType.None) - ProxyDiagnostics.ReportInformation(logger, - $"Set endpoint at Ip {endPoint.IpAddress} and port: {endPoint.Port} as System {proxyType} Proxy"); - } - - /// - /// Clear HTTP proxy settings of current machine. - /// - public void DisableSystemHttpProxy() - { - DisableSystemProxy(ProxyProtocolType.Http); - } - - /// - /// Clear HTTPS proxy settings of current machine. - /// - public void DisableSystemHttpsProxy() - { - DisableSystemProxy(ProxyProtocolType.Https); - } - - /// - /// Restores the original proxy settings. - /// - public void RestoreOriginalProxySettings() - { - if (SystemProxySettingsManager == null) - throw new NotSupportedException(SystemProxyNotSupportedMessage); - - SystemProxySettingsManager.RestoreOriginalSettings(); - - ClearEndpointSystemProxyFlags(ProxyProtocolType.AllHttp); - } - - /// - /// Clear the specified proxy setting for current machine. - /// - public void DisableSystemProxy(ProxyProtocolType protocolType) - { - if (SystemProxySettingsManager == null) - throw new NotSupportedException(SystemProxyNotSupportedMessage); - - SystemProxySettingsManager.RemoveProxy(protocolType); - - // Without this, an endpoint's IsSystemHttpProxy/IsSystemHttpsProxy stays true after the - // corresponding registry setting has already been cleared, so a later SetAsSystemProxy call - // for the other protocol - or Stop()'s own best-effort registry cleanup - can read stale flags - // that no longer reflect the actual system proxy configuration. - ClearEndpointSystemProxyFlags(protocolType); - } - - /// - /// Clear all proxy settings for current machine. - /// - public void DisableAllSystemProxies() - { - if (SystemProxySettingsManager == null) - throw new NotSupportedException(SystemProxyNotSupportedMessage); - - SystemProxySettingsManager.DisableAllProxy(); - - ClearEndpointSystemProxyFlags(ProxyProtocolType.AllHttp); - } - - /// - /// Clears / - /// on every endpoint for the protocol(s) - /// named in , so those flags never outlive the registry setting - /// they were tracking. - /// - private void ClearEndpointSystemProxyFlags(ProxyProtocolType protocolType) - { - var clearHttp = protocolType.HasFlag(ProxyProtocolType.Http); - var clearHttps = protocolType.HasFlag(ProxyProtocolType.Https); - if (!clearHttp && !clearHttps) return; - - foreach (var endPoint in ProxyEndPoints.OfType()) - { - if (clearHttp) endPoint.IsSystemHttpProxy = false; - if (clearHttps) endPoint.IsSystemHttpsProxy = false; - } - } - - /// - /// Start this proxy server instance. - /// - /// Transactional: if any endpoint fails to start, every listener this call already - /// started is stopped, the system-upstream-proxy resolver (if this call created one) is - /// disposed, and is left before the - /// exception propagates. A caller that catches the exception is left with an instance in - /// exactly the same state as before calling , not a partially-bound - /// proxy with some endpoints silently listening. - /// - /// - /// - /// Whether or not clear any system proxy settings which is pointing to our own endpoint (causing a cycle). - /// E.g due to ungracious proxy shutdown before. - /// - public void Start(bool changeSystemProxySettings = true) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - { - if (ProxyRunning) throw new InvalidOperationException("Proxy is already running."); - - // Freeze the active logging configuration for the duration of this run. - ApplyLoggingConfiguration(); - - SetThreadPoolMinThread(ThreadPoolWorkerThread); - - // Only create the root certificate when at least one endpoint will actually perform - // TLS decryption and does not already have a custom GenericCertificate. Endpoints - // whose DecryptSsl is false never need to generate leaf certificates, so creating a - // root PFX for them is unnecessary I/O and key-generation work. - if (ProxyEndPoints.Any(x => x.DecryptSsl && x.GenericCertificate == null)) - CertificateManager.EnsureRootCertificate(); - - if (changeSystemProxySettings && SystemProxySettingsManager != null) - { - var ownedPorts = ProxyEndPoints.Select(x => x.Port).ToHashSet(); - var protocolToRemove = SystemProxySettingsManager.GetStaleLocalProxyProtocols(ownedPorts); - if (protocolToRemove != ProxyProtocolType.None) - SystemProxySettingsManager.RemoveProxy(protocolToRemove, false); - } - - var assignedSystemUpStreamResolver = false; - if (RunTime.IsWindows && ForwardToUpstreamGateway && GetCustomUpStreamProxyFunc == null && - SystemProxySettingsManager != null) - { - systemProxyResolver = new WinHttpWebProxyFinder(); - if (UpstreamProxyConfigurationScript != null) - //Use the provided proxy configuration script - systemProxyResolver.UsePacFile(UpstreamProxyConfigurationScript); - else - // Use WinHttp to handle PAC/WAPD scripts. - systemProxyResolver.LoadFromIe(); - - GetCustomUpStreamProxyFunc = GetSystemUpStreamProxy; - assignedSystemUpStreamResolver = true; - } - - ProxyRunning = true; - - // Name only, per the plan's rollout section - never hosts, URLs or secrets. - ProxyLog.EffectiveProfileAtStartup(logger, profile, policyModes); - - _ = CertificateManager.ClearIdleCertificates(); - - var startedTcpEndPoints = new List(); - var startedQuicEndPoints = new List(); - var createdQuicListenerCts = false; - - try - { - var hasUdpOnlyQuic = ProxyEndPoints.OfType().Any(); - var hasDualListenHttp3 = ProxyEndPoints.OfType() - .Any(e => e.EnableHttp3); - var needsInboundHttp3 = EnableHttp3 && (hasUdpOnlyQuic || hasDualListenHttp3); - - if (needsInboundHttp3) - quicListenerCts = new CancellationTokenSource(); - else if (EnableHttp3) - { - // Explicit/SOCKS endpoints speak TCP to the client; EnableHttp3 still correctly - // arms origin-side QUIC (Alt-Svc / H2↔H3 bridge). That is the Inspector/CLI happy - // path — do not warn. Warn only when nothing can use either inbound or origin H3 - // (no client-facing TCP endpoints), which usually means a misconfigured Start(). - var hasTcpClientFacingEndpoint = ProxyEndPoints.Any(e => - e is ExplicitProxyEndPoint or SocksProxyEndPoint or TransparentProxyEndPoint); - if (!hasTcpClientFacingEndpoint) - { - Logger.LogWarning( - "EnableHttp3 is true but no inbound HTTP/3 endpoint is registered. " + - "Add a TransparentQuicProxyEndPoint, or a TransparentProxyEndPoint with EnableHttp3, " + - "before calling Start()."); - } - } - - // UDP-only transparent QUIC first (no TCP on that port). - if (needsInboundHttp3 && hasUdpOnlyQuic) - { - createdQuicListenerCts = true; - foreach (var quicEndPoint in ProxyEndPoints.OfType()) - { - ListenQuic(quicEndPoint); - startedQuicEndPoints.Add(quicEndPoint); - } - } - - // TCP endpoints. Dual-listen reverse H3: bind TCP first (assign ephemeral port), then UDP - // on the same IP:port so HttpClient can discover H3 via Alt-Svc or RequestVersionExact. - // Windows TCP and UDP port spaces are independent — ephemeral TCP can land on a UDP port - // that is already taken / excluded (WSAEADDRINUSE). Retry ephemeral dual-listen binds. - foreach (var endPoint in ProxyEndPoints) - { - if (endPoint is TransparentQuicProxyEndPoint) - continue; - - if (endPoint is TransparentProxyEndPoint { EnableHttp3: true } dualListenGate) - { - if (!EnableHttp3) - throw new InvalidOperationException( - "TransparentProxyEndPoint.EnableHttp3 requires ProxyServer.EnableHttp3 = true."); - if (!dualListenGate.DecryptSsl) - throw new InvalidOperationException( - "TransparentProxyEndPoint.EnableHttp3 requires DecryptSsl = true."); - } - - var wantDualQuic = EnableHttp3 && endPoint is TransparentProxyEndPoint { EnableHttp3: true }; - var ephemeralDual = wantDualQuic && endPoint.Port == 0; - const int maxDualListenAttempts = 20; - var dualAttempts = 0; - while (true) - { - dualAttempts++; - Listen(endPoint); - - if (!wantDualQuic) - { - startedTcpEndPoints.Add(endPoint); - break; - } - - var dual = (TransparentProxyEndPoint)endPoint; - try - { - createdQuicListenerCts = true; - quicListenerCts ??= new CancellationTokenSource(); - ListenQuic(dual); - startedTcpEndPoints.Add(endPoint); - startedQuicEndPoints.Add(dual); - break; - } - catch (Exception ex) when (ephemeralDual && dualAttempts < maxDualListenAttempts - && IsAddressAlreadyInUse(ex)) - { - SafeRollback(() => QuitListenQuic(dual)); - SafeRollback(() => QuitListen(endPoint)); - endPoint.Port = 0; - } - } - } - } - catch (Exception startEx) - { - // Roll back, in reverse dependency order, everything this call already started. - // QuitListen/QuitListenQuic tolerate a listener that never started (no-op), so it is - // safe to call them uniformly rather than re-deriving exactly how far each one got. - ProxyDiagnostics.ReportCaught(logger, - "ProxyServer.Start failed; rolling back and rethrowing", startEx); - foreach (var quicEndPoint in startedQuicEndPoints) SafeRollback(() => QuitListenQuic(quicEndPoint)); - foreach (var endPoint in startedTcpEndPoints) SafeRollback(() => QuitListen(endPoint)); - - if (createdQuicListenerCts) - { - SafeRollback(() => quicListenerCts?.Cancel()); - SafeRollback(() => quicListenerCts?.Dispose()); - quicListenerCts = null; - } - - if (assignedSystemUpStreamResolver) - { - if (OperatingSystem.IsWindows()) - try - { - systemProxyResolver?.Dispose(); - } - catch (Exception ex) - { - OnException(null, ex); - } - - systemProxyResolver = null; - GetCustomUpStreamProxyFunc = null; - } - - ProxyRunning = false; - - throw; - } - } - - /// - /// Runs a single rollback step, reporting rather than propagating a - /// failure so one misbehaving teardown step cannot mask the original failure or abandon the - /// rest of the rollback. - /// - private void SafeRollback(Action rollbackStep) - { - try - { - rollbackStep(); - } - catch (Exception ex) - { - OnException(null, ex); - } - } - - /// - /// Stop this proxy server instance. - /// Endpoints remain registered so can re-listen on the same ports. - /// In-flight sessions are cancelled; pooled upstream connections are cleared. The connection - /// factory itself stays usable for a subsequent Start (it is only disposed with the proxy). - /// - public void Stop() - { - StopCore(cancelSessions: true, clearPools: true); - } - - /// - /// Asynchronously stop this proxy server, cancel in-flight sessions, and wait briefly for - /// client connection count to drain before clearing the upstream pool. - /// - /// - /// Maximum time to wait for active client handlers to exit after cancellation. - /// Defaults to 5 seconds. - /// - public async Task StopAsync(TimeSpan? drainTimeout = null) - { - if (!ProxyRunning) throw new InvalidOperationException("Proxy is not running."); - - StopCore(cancelSessions: true, clearPools: false); - - var timeout = drainTimeout ?? TimeSpan.FromSeconds(5); - var deadline = DateTime.UtcNow + timeout; - // Http3ClientConnectionCount tracks inbound QUIC clients separately from - // ClientConnectionCount (TCP-based H1/H2); draining only the former would let this - // return, and the pools below get cleared, while HTTP/3 streams are still in flight. - while ((ClientConnectionCount > 0 || Http3ClientConnectionCount > 0) && DateTime.UtcNow < deadline) - // StopCore already cancelled quicListenerCts; drain polling must not share that token. - await Task.Delay(50, CancellationToken.None).ConfigureAwait(false); - - TcpConnectionFactory.ClearPools(); - await QuicConnectionPool.DrainAsync(); - await Http2OriginConnectionPool.DrainAsync(); - } - - private void StopCore(bool cancelSessions, bool clearPools) // NOSONAR S3776 -- This protocol/state-machine path shares mutable parsing or transport state; splitting it further would create disproportionate regression risk. - { - if (!ProxyRunning) throw new InvalidOperationException("Proxy is not running."); - - if (SystemProxySettingsManager != null) - { - var systemProxyEndPoints = ProxyEndPoints.OfType() - .Where(x => x.IsSystemHttpProxy || x.IsSystemHttpsProxy) - .ToList(); - - if (systemProxyEndPoints.Count > 0) - { - SystemProxySettingsManager.RestoreOriginalSettings(); - foreach (var endPoint in systemProxyEndPoints) - { - endPoint.IsSystemHttpProxy = false; - endPoint.IsSystemHttpsProxy = false; - } - } - } - - // Prevent accept callbacks from scheduling another accept while listeners are stopping. - ProxyRunning = false; - - if (cancelSessions) CancelActiveSessions(); - foreach (var endPoint in ProxyEndPoints) - { - if (endPoint is TransparentQuicProxyEndPoint) - continue; - QuitListen(endPoint); - } - // Cancel and wait for QUIC accept loops to exit (UDP-only + dual-listen reverse). - quicListenerCts?.Cancel(); - foreach (var quicEndPoint in ProxyEndPoints.OfType()) - QuitListenQuic(quicEndPoint); - foreach (var dual in ProxyEndPoints.OfType().Where(e => e.EnableHttp3)) - QuitListenQuic(dual); - quicListenerCts?.Dispose(); - quicListenerCts = null; - - // Keep ProxyEndPoints so Start() can re-bind the same listeners (issue #799). - - CertificateManager?.StopClearIdleCertificates(); - - if (clearPools) TcpConnectionFactory.ClearPools(); - if (clearPools) DrainPoolBlocking(QuicConnectionPool.DrainAsync()); - if (clearPools) DrainPoolBlocking(Http2OriginConnectionPool.DrainAsync()); - - // Start() may have wired GetCustomUpStreamProxyFunc to GetSystemUpStreamProxy and created - // systemProxyResolver to back it. Undo both together: leaving the callback in place while - // disposing its resolver below would make a subsequent Start() see GetCustomUpStreamProxyFunc - // != null and skip creating a fresh resolver, so the callback would call into a disposed - // WinHttpWebProxyFinder on the first request after restart. Only clear the callback if it is - // still the delegate we assigned - a caller who has since replaced it with their own must not - // have that overwritten here. - if (Equals(GetCustomUpStreamProxyFunc, (Func>)GetSystemUpStreamProxy)) - GetCustomUpStreamProxyFunc = null; - - // Release the WinHTTP session handle acquired during Start() (Windows-only type). - if (OperatingSystem.IsWindows()) - systemProxyResolver?.Dispose(); - systemProxyResolver = null; - } /// /// When false, session instances are not tracked for @@ -2052,6 +1466,22 @@ private void ValidateEndPointAsSystemProxy(ExplicitProxyEndPoint endPoint) if (!ProxyRunning) throw new InvalidOperationException("Cannot set system proxy settings before proxy has been started."); } + /// + /// Hostname written into OS system-proxy settings for . + /// IPv4 loopback uses 127.0.0.1 (not localhost) so clients that prefer + /// IPv6 ::1 still reach an IPv4-only listener. + /// + internal static string FormatSystemProxyHostname(IPAddress address) + { + if (Equals(address, IPAddress.IPv6Any) || Equals(address, IPAddress.IPv6Loopback)) + return "::1"; + + if (Equals(address, IPAddress.Any) || Equals(address, IPAddress.Loopback)) + return "127.0.0.1"; + + return address.ToString(); + } + /// /// Gets the system up stream proxy. /// @@ -2429,113 +1859,6 @@ private static bool IsAddressAlreadyInUse(Exception ex) return false; } - /// - /// Update client connection count. - /// - /// Should we increment/decrement? - internal void UpdateClientConnectionCount(bool increment) - { - if (increment) - Interlocked.Increment(ref clientConnectionCount); - else - Interlocked.Decrement(ref clientConnectionCount); - - try - { - ClientConnectionCountChanged?.Invoke(this, EventArgs.Empty); - } - catch (Exception ex) - { - OnException(null, ex); - } - } - - /// - /// Update server connection count. - /// - /// Should we increment/decrement? - internal void UpdateServerConnectionCount(bool increment) - { - if (increment) - Interlocked.Increment(ref serverConnectionCount); - else - Interlocked.Decrement(ref serverConnectionCount); - - try - { - ServerConnectionCountChanged?.Invoke(this, EventArgs.Empty); - } - catch (Exception ex) - { - OnException(null, ex); - } - } - - /// - /// Update inbound HTTP/3 client connection count. - /// - /// Should we increment/decrement? - internal void UpdateHttp3ClientConnectionCount(bool increment) - { - if (increment) - Interlocked.Increment(ref http3ClientConnectionCount); - else - Interlocked.Decrement(ref http3ClientConnectionCount); - - try - { - Http3ClientConnectionCountChanged?.Invoke(this, EventArgs.Empty); - } - catch (Exception ex) - { - OnException(null, ex); - } - } - - /// - /// Update upstream HTTP/3 server connection count. - /// - /// Should we increment/decrement? - internal void UpdateHttp3ServerConnectionCount(bool increment) - { - if (increment) - Interlocked.Increment(ref http3ServerConnectionCount); - else - Interlocked.Decrement(ref http3ServerConnectionCount); - - try - { - Http3ServerConnectionCountChanged?.Invoke(this, EventArgs.Empty); - } - catch (Exception ex) - { - OnException(null, ex); - } - } - - /// - /// Invoke client tcp connection events if subscribed by API user. - /// - /// The TcpClient object. - /// - internal Task InvokeClientConnectionCreateEvent(Socket clientSocket) - { - return OnClientConnectionCreate != null - ? OnClientConnectionCreate.InvokeAsync(this, clientSocket, logger) - : Task.CompletedTask; - } - - /// - /// Invoke server tcp connection events if subscribed by API user. - /// - /// The Socket object. - /// - internal Task InvokeServerConnectionCreateEvent(Socket serverSocket) - { - return OnServerConnectionCreate != null - ? OnServerConnectionCreate.InvokeAsync(this, serverSocket, logger) - : Task.CompletedTask; - } /// /// Connection retry policy that respects the per-session @@ -2547,95 +1870,4 @@ private RetryPolicy RetryPolicy(SessionEventArgs? sessionOverride) where T return new RetryPolicy(attempts, TcpConnectionFactory); } - private bool disposed; - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - [SuppressMessage("ApiDesign", "RS0016:Add public types and members to the declared API", - Justification = "Protected Dispose(bool) is required by the standard IDisposable pattern but is not public API.")] - protected virtual void Dispose(bool disposing) - { - if (disposed) return; - - if (disposing) - { - // No finalizer: Stop()/certificate/buffer disposal must only run on the explicit - // Dispose path. Callers that omit Dispose leave OS sockets to safe-handle cleanup. - StopIfRunning(); - DisposeConnectionResources(); - DisposeOwnedLoggerFactory(); - } - - disposed = true; - } - - private void StopIfRunning() - { - if (!ProxyRunning) return; - - try - { - Stop(); - } - catch - { - // ignore - } - } - - private void DisposeConnectionResources() - { - try - { - TcpConnectionFactory.Dispose(); - } - catch - { - // ignore - } - - try - { - DrainPoolBlocking(QuicConnectionPool.DrainAsync()); - } - catch - { - // ignore - } - - try - { - DrainPoolBlocking(Http2OriginConnectionPool.DrainAsync()); - } - catch - { - // ignore - } - - CertificateManager?.Dispose(); - BufferPool?.Dispose(); - _svcbDiscoveryCoordinator?.Dispose(); - - // SystemProxyManager is [SupportedOSPlatform("windows")]; the platform analyzer cannot - // prove that from a null-conditional access alone, so guard explicitly. - SystemProxySettingsManager?.Dispose(); - } - - private void DisposeOwnedLoggerFactory() - { - if (!ownsActiveLoggerFactory) return; - - try - { - activeLoggerFactory.Dispose(); - } - catch - { - // A misbehaving sink must never prevent proxy disposal from completing. - } - } } diff --git a/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt b/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt index 21430d4c6..25b962f29 100644 --- a/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt +++ b/src/Titanium.Web.Proxy/PublicAPI.Unshipped.txt @@ -1,4 +1,6 @@ #nullable enable +Titanium.Web.Proxy.ClientProcessId +static Titanium.Web.Proxy.ClientProcessId.IsSupported.get -> bool Titanium.Web.Proxy.Diagnostics.TunnelConnectTiming Titanium.Web.Proxy.Diagnostics.TunnelConnectTiming.BrowserTlsCompletedAt.get -> System.DateTime? Titanium.Web.Proxy.Diagnostics.TunnelConnectTiming.BrowserTlsDuration.get -> System.TimeSpan? @@ -15,6 +17,7 @@ Titanium.Web.Proxy.Diagnostics.TunnelConnectTiming.OriginCapabilitySource.get -> Titanium.Web.Proxy.Diagnostics.TunnelConnectTiming.OriginCapabilityStartedAt.get -> System.DateTime? Titanium.Web.Proxy.Diagnostics.TunnelConnectTiming.StartedAt.get -> System.DateTime Titanium.Web.Proxy.Diagnostics.TunnelConnectTiming.TotalDuration.get -> System.TimeSpan? +Titanium.Web.Proxy.EventArguments.SessionEventArgsBase.UpstreamDestinationId.get -> string? Titanium.Web.Proxy.EventArguments.TunnelConnectSessionEventArgs.ConnectTiming.get -> Titanium.Web.Proxy.Diagnostics.TunnelConnectTiming? Titanium.Web.Proxy.Network.CertificateKeyAlgorithm Titanium.Web.Proxy.Network.CertificateKeyAlgorithm.EcdsaP256 = 1 -> Titanium.Web.Proxy.Network.CertificateKeyAlgorithm @@ -25,6 +28,7 @@ Titanium.Web.Proxy.Network.CertificateManager.ApplyFastColdStartLeafSettings() - static Titanium.Web.Proxy.Network.DefaultCertificateDiskCache.GetSharedLeafCertificateDirectory() -> string! static Titanium.Web.Proxy.Network.CertificateManager.LeafRsaKeyPairBufferSize.get -> int static Titanium.Web.Proxy.Network.CertificateManager.LeafRsaKeyPairBufferSize.set -> void +static Titanium.Web.Proxy.Network.CertificateManager.AreInteractiveRootStoreMutationsSuppressed.get -> bool static Titanium.Web.Proxy.Network.CertificateManager.SuppressInteractiveRootStoreMutations.get -> bool static Titanium.Web.Proxy.Network.CertificateManager.SuppressInteractiveRootStoreMutations.set -> void Titanium.Web.Proxy.Options.ProxyResourceLimits.MaxCertificateDiskCacheEntries.get -> int? @@ -79,6 +83,8 @@ Titanium.Web.Proxy.Models.TransparentProxyEndPoint.BeforeHttpAuthenticate -> Tit static readonly Titanium.Web.Proxy.Http.KnownHeaders.AltSvc -> Titanium.Web.Proxy.Http.KnownHeader! Titanium.Web.Proxy.ProxyServer.EnableHttpInterception.get -> bool Titanium.Web.Proxy.ProxyServer.EnableHttpInterception.set -> void +Titanium.Web.Proxy.ProxyServer.IgnoreServerCertificateErrors.get -> bool +Titanium.Web.Proxy.ProxyServer.IgnoreServerCertificateErrors.set -> void Titanium.Web.Proxy.ProxyServer.TryEnableHttp3IfSupported() -> bool Titanium.Web.Proxy.ProxyServer.SetHttp3Enabled(bool enabled) -> bool Titanium.Web.Proxy.ProxyServer.MaxConcurrentHttp11HttpsOriginCreates.get -> int @@ -155,3 +161,77 @@ Titanium.Web.Proxy.Transforms.TransformEngine.ApplyRequestTransforms(System.Coll Titanium.Web.Proxy.Transforms.TransformEngine.TransformEngine() -> void Titanium.Web.Proxy.ProxyServer.ReverseProxy.get -> Titanium.Web.Proxy.Abstractions.ReverseProxyOptions? Titanium.Web.Proxy.ProxyServer.ReverseProxy.set -> void +Titanium.Web.Proxy.Network.CertificateOsTrustKind +Titanium.Web.Proxy.Network.CertificateOsTrustKind.Succeeded = 0 -> Titanium.Web.Proxy.Network.CertificateOsTrustKind +Titanium.Web.Proxy.Network.CertificateOsTrustKind.CertutilMissing = 1 -> Titanium.Web.Proxy.Network.CertificateOsTrustKind +Titanium.Web.Proxy.Network.CertificateOsTrustKind.NssFailed = 2 -> Titanium.Web.Proxy.Network.CertificateOsTrustKind +Titanium.Web.Proxy.Network.CertificateOsTrustKind.MacKeychainFailed = 3 -> Titanium.Web.Proxy.Network.CertificateOsTrustKind +Titanium.Web.Proxy.Network.CertificateOsTrustKind.MacNeedsManualTrustConfirm = 4 -> Titanium.Web.Proxy.Network.CertificateOsTrustKind +Titanium.Web.Proxy.Network.CertificateOsTrustKind.HomebrewMissing = 5 -> Titanium.Web.Proxy.Network.CertificateOsTrustKind +Titanium.Web.Proxy.Network.CertificateOsTrustKind.Unsupported = 6 -> Titanium.Web.Proxy.Network.CertificateOsTrustKind +Titanium.Web.Proxy.Network.CertificateOsTrustKind.Cancelled = 7 -> Titanium.Web.Proxy.Network.CertificateOsTrustKind +Titanium.Web.Proxy.Network.CertificateOsTrustKind.Failed = 8 -> Titanium.Web.Proxy.Network.CertificateOsTrustKind +Titanium.Web.Proxy.Network.CertificateOsTrustResult +Titanium.Web.Proxy.Network.CertificateOsTrustResult.BrewAvailable.get -> bool +Titanium.Web.Proxy.Network.CertificateOsTrustResult.CertificateOsTrustResult(Titanium.Web.Proxy.Network.CertificateOsTrustKind kind, string! message, string? packageHint = null, bool brewAvailable = false) -> void +Titanium.Web.Proxy.Network.CertificateOsTrustResult.Kind.get -> Titanium.Web.Proxy.Network.CertificateOsTrustKind +Titanium.Web.Proxy.Network.CertificateOsTrustResult.Message.get -> string! +Titanium.Web.Proxy.Network.CertificateOsTrustResult.PackageHint.get -> string? +Titanium.Web.Proxy.Network.CertificateOsTrustResult.Succeeded.get -> bool +static Titanium.Web.Proxy.Network.CertificateOsTrustResult.Fail(Titanium.Web.Proxy.Network.CertificateOsTrustKind kind, string! message, string? packageHint = null, bool brewAvailable = false) -> Titanium.Web.Proxy.Network.CertificateOsTrustResult! +static Titanium.Web.Proxy.Network.CertificateOsTrustResult.Ok(string! message = "Trusted") -> Titanium.Web.Proxy.Network.CertificateOsTrustResult! +Titanium.Web.Proxy.Network.CertificateManager.LastOsTrustResult.get -> Titanium.Web.Proxy.Network.CertificateOsTrustResult? +Titanium.Web.Proxy.Network.CertificateManager.InstallNssCertutilAndRetryUserTrust() -> Titanium.Web.Proxy.Network.CertificateOsTrustResult! +Titanium.Web.Proxy.Network.CertificateManager.OpenMacKeychainGuidance() -> string? +Titanium.Web.Proxy.Network.CertificateManager.VerifyOsUserSslTrust() -> bool +Titanium.Web.Proxy.Network.CertificateManager.IsRootInLoginKeychain() -> bool +Titanium.Web.Proxy.Network.CertificateManager.IsOsRootStillPresent() -> bool +Titanium.Web.Proxy.Network.FirefoxCertificateTrust +static Titanium.Web.Proxy.Network.FirefoxCertificateTrust.IsFirefoxProcessRunning() -> bool +static Titanium.Web.Proxy.Network.FirefoxCertificateTrust.IsFirefoxProfilePresent() -> bool +static Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryClearWindowsEnterpriseRoots() -> bool +static Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryEnableEnterpriseRootsUserPref() -> Titanium.Web.Proxy.Network.CertificateOsTrustResult! +static Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryEnableWindowsEnterpriseRoots() -> Titanium.Web.Proxy.Network.CertificateOsTrustResult! +static Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryRequestFirefoxQuit(System.TimeSpan? waitForExit = null) -> bool +static Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TryResolveDefaultProfileDirectory(out string! profileDirectory, out string? error) -> bool +static Titanium.Web.Proxy.Network.FirefoxCertificateTrust.TrustDefaultProfile(System.Security.Cryptography.X509Certificates.X509Certificate2! certificate, string! friendlyName) -> Titanium.Web.Proxy.Network.CertificateOsTrustResult! +static Titanium.Web.Proxy.Network.FirefoxCertificateTrust.UntrustDefaultProfile(string! friendlyName) -> bool +Titanium.Web.Proxy.MitmExclusionDefaults +static readonly Titanium.Web.Proxy.MitmExclusionDefaults.SystemProxyBypassRules -> string![]! +static readonly Titanium.Web.Proxy.MitmExclusionDefaults.TunnelOnlyPinningDomains -> string![]! +static Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint! endPoint, System.Func! decryptHttpsEnabled) -> void +static Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint! endPoint, System.Func! decryptHttpsEnabled, System.Collections.Generic.IEnumerable? decryptSkipHosts, System.Collections.Generic.IEnumerable? decryptOnlyHosts) -> void +static Titanium.Web.Proxy.MitmExclusionDefaults.ApplyDecryptExclusions(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint! endPoint, System.Func! decryptHttpsEnabled, System.Collections.Generic.IEnumerable? decryptSkipHosts, System.Collections.Generic.IEnumerable? decryptOnlyHosts, Titanium.Web.Proxy.MitmExclusionMode mode) -> void +static Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings() -> Titanium.Web.Proxy.SystemProxySettings! +static Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings(bool proxyLoopback) -> Titanium.Web.Proxy.SystemProxySettings! +static Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings(bool proxyLoopback, System.Collections.Generic.IEnumerable? additionalBypassRules) -> Titanium.Web.Proxy.SystemProxySettings! +static Titanium.Web.Proxy.MitmExclusionDefaults.CreateSystemProxySettings(bool proxyLoopback, System.Collections.Generic.IEnumerable? bypassRules, Titanium.Web.Proxy.MitmExclusionMode mode) -> Titanium.Web.Proxy.SystemProxySettings! +static Titanium.Web.Proxy.MitmExclusionDefaults.HostnameMatches(string! hostname, string! pattern) -> bool +static Titanium.Web.Proxy.MitmExclusionDefaults.IsBuiltInSslBypass(string! hostname) -> bool +static Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt(string? hostname) -> bool +static Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt(string? hostname, System.Collections.Generic.IEnumerable? userSkipHosts, System.Collections.Generic.IEnumerable? userOnlyHosts) -> bool +static Titanium.Web.Proxy.MitmExclusionDefaults.ShouldDisableSslDecrypt(string? hostname, System.Collections.Generic.IEnumerable? userSkipHosts, System.Collections.Generic.IEnumerable? userOnlyHosts, Titanium.Web.Proxy.MitmExclusionMode mode) -> bool +Titanium.Web.Proxy.MitmExclusionMode +Titanium.Web.Proxy.MitmExclusionMode.Merge = 0 -> Titanium.Web.Proxy.MitmExclusionMode +Titanium.Web.Proxy.MitmExclusionMode.Replace = 1 -> Titanium.Web.Proxy.MitmExclusionMode +Titanium.Web.Proxy.SystemProxySettings.BuildProxyOverride(string? currentProxyOverride) -> string! +Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper +static Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.IsLocalHost(string? host) -> bool +static Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToCommaSeparated(string? winInetProxyOverride) -> string! +static Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToGsettingsArray(string? winInetProxyOverride) -> string! +static Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToNoProxyEnv(string? winInetProxyOverride) -> string! +static Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToNoProxyEnv(string? winInetProxyOverride, bool proxyLoopback) -> string! +static Titanium.Web.Proxy.Helpers.UnixProxyBypassMapper.ToUnixBypassHosts(string? winInetProxyOverride) -> System.Collections.Generic.IReadOnlyList! +Titanium.Web.Proxy.Http3.Http3NativeBootstrap +static Titanium.Web.Proxy.Http3.Http3NativeBootstrap.EnsureAppLocalMsQuicVisible(string![]? args = null) -> void +Titanium.Web.Proxy.SystemProxyChangeResult +Titanium.Web.Proxy.SystemProxyChangeResult.Message.get -> string! +Titanium.Web.Proxy.SystemProxyChangeResult.Succeeded.get -> bool +Titanium.Web.Proxy.SystemProxyChangeResult.SystemProxyChangeResult() -> void +Titanium.Web.Proxy.SystemProxyChangeResult.SystemProxyChangeResult(bool succeeded, string! message) -> void +static Titanium.Web.Proxy.SystemProxyChangeResult.Fail(string! message) -> Titanium.Web.Proxy.SystemProxyChangeResult +static Titanium.Web.Proxy.SystemProxyChangeResult.Ok(string! message) -> Titanium.Web.Proxy.SystemProxyChangeResult +Titanium.Web.Proxy.ProxyServer.TryDisableAllSystemProxies() -> Titanium.Web.Proxy.SystemProxyChangeResult +Titanium.Web.Proxy.ProxyServer.TryDisableSystemProxy(Titanium.Web.Proxy.Models.ProxyProtocolType protocolType) -> Titanium.Web.Proxy.SystemProxyChangeResult +Titanium.Web.Proxy.ProxyServer.TryRestoreOriginalProxySettings() -> Titanium.Web.Proxy.SystemProxyChangeResult +Titanium.Web.Proxy.ProxyServer.TrySetAsSystemProxy(Titanium.Web.Proxy.Models.ExplicitProxyEndPoint! endPoint, Titanium.Web.Proxy.Models.ProxyProtocolType protocolType, Titanium.Web.Proxy.SystemProxySettings? settings = null) -> Titanium.Web.Proxy.SystemProxyChangeResult diff --git a/src/Titanium.Web.Proxy/Routing/ReverseProxySessionDispatch.cs b/src/Titanium.Web.Proxy/Routing/ReverseProxySessionDispatch.cs index cba2ac70a..1d9f73128 100644 --- a/src/Titanium.Web.Proxy/Routing/ReverseProxySessionDispatch.cs +++ b/src/Titanium.Web.Proxy/Routing/ReverseProxySessionDispatch.cs @@ -67,7 +67,7 @@ destination is null || if (route.Transforms is { Count: > 0 }) { - ApplyTransforms(options, route.Transforms, request); + ApplyTransforms(options, route.Transforms, request, session); } return true; @@ -132,7 +132,8 @@ public static bool AllowsStickyForwardUpstream(ProxyServer server, ProxyEndPoint private static void ApplyTransforms( Titanium.Web.Proxy.Abstractions.ReverseProxyOptions options, IReadOnlyList transforms, - Request request) + Request request, + SessionEventArgsBase session) { var engine = options.TransformEngine ?? new TransformEngine(); var path = request.RequestUriString8.GetString(); @@ -151,9 +152,51 @@ private static void ApplyTransforms( request.RequestUriString8 = (ByteString)ctx.Path; } + foreach (var name in ctx.HeadersToRemove) + { + request.Headers.RemoveHeader(name); + } + foreach (var pair in ctx.Headers) { request.Headers.SetOrAddHeaderValue(pair.Key, pair.Value); } + + if (ctx.ResponseHeadersToSet.Count > 0 || ctx.ResponseHeadersToRemove.Count > 0) + { + session.ResponseHeaderTransformPlan = + new TransformResponseHeaderPlan(ctx.ResponseHeadersToSet, ctx.ResponseHeadersToRemove); + } + } + + /// Applies staged response header transforms when present. + public static void ApplyResponseTransforms(SessionEventArgsBase session) + { + if (session.ResponseHeaderTransformPlan is TransformResponseHeaderPlan plan) + { + plan.Apply(session.HttpClient.Response.Headers); + } + } +} + +/// Stashed on the session so response transforms apply without an always-on middleware. +internal sealed class TransformResponseHeaderPlan( + Dictionary set, + HashSet remove) +{ + public Dictionary Set { get; } = set; + public HashSet Remove { get; } = remove; + + public void Apply(HeaderCollection headers) + { + foreach (var name in Remove) + { + headers.RemoveHeader(name); + } + + foreach (var pair in Set) + { + headers.SetOrAddHeaderValue(pair.Key, pair.Value); + } } } diff --git a/src/Titanium.Web.Proxy/SystemProxyChangeResult.cs b/src/Titanium.Web.Proxy/SystemProxyChangeResult.cs new file mode 100644 index 000000000..257dfcefd --- /dev/null +++ b/src/Titanium.Web.Proxy/SystemProxyChangeResult.cs @@ -0,0 +1,22 @@ +namespace Titanium.Web.Proxy; + +/// +/// Outcome of enabling or disabling OS system proxy. Callers must treat failure as +/// non-fatal: log and continue (do not crash the process). +/// +public readonly struct SystemProxyChangeResult +{ + public SystemProxyChangeResult(bool succeeded, string message) + { + Succeeded = succeeded; + Message = message ?? string.Empty; + } + + public bool Succeeded { get; } + + public string Message { get; } + + public static SystemProxyChangeResult Ok(string message) => new(true, message); + + public static SystemProxyChangeResult Fail(string message) => new(false, message); +} diff --git a/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj b/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj index a52a3869d..b50305b71 100644 --- a/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj +++ b/src/Titanium.Web.Proxy/Titanium.Web.Proxy.csproj @@ -13,7 +13,7 @@ - 7.0.4 + 7.0.5 + + + + diff --git a/tools/RpsLoadProbe/ServeHosts.cs b/tools/RpsLoadProbe/ServeHosts.cs index ce44e0281..810f476e1 100644 --- a/tools/RpsLoadProbe/ServeHosts.cs +++ b/tools/RpsLoadProbe/ServeHosts.cs @@ -87,8 +87,9 @@ private static async Task WaitUntilCanceledAsync(CancellationToken cancella internal static class ServeProxyHost { public static async Task RunAsync(ProbeMode mode, int originHttpPort, int originHttpsPort, - int originQuicPort, IReadOnlyList extraHttpsPorts, string? nginxPath, int? maxCachedConnections, - CancellationToken cancellationToken, WorkloadOptions? workload = null) + int originQuicPort, IReadOnlyList extraHttpsPorts, string? nginxPath, string? haproxyPath, + string? envoyPath, int? maxCachedConnections, CancellationToken cancellationToken, + WorkloadOptions? workload = null) { workload ??= WorkloadOptions.TinyGet; if (mode is ProbeMode.OriginDirect) @@ -99,13 +100,15 @@ public static async Task RunAsync(ProbeMode mode, int originHttpPort, int o if (mode is ProbeMode.Compare or ProbeMode.CompareHttp2 or ProbeMode.CompareTls or ProbeMode.CompareTerminate or ProbeMode.CompareSame or ProbeMode.CompareBridges - or ProbeMode.CompareHttp3Cleartext + or ProbeMode.CompareHttp3Cleartext or ProbeMode.CompareNginxHttps or ProbeMode.CompareHaproxySmoke + or ProbeMode.CompareEnvoySmoke or ProbeMode.CompareMitm or ProbeMode.CompareMatrix or ProbeMode.CompareProduct or ProbeMode.CompareProductSmoke or ProbeMode.CompareCeiling or ProbeMode.CompareBodies or ProbeMode.ComparePost or ProbeMode.CompareLossy or ProbeMode.CompareTlsCost or ProbeMode.CompareArch or ProbeMode.CompareSaturation or ProbeMode.CompareEditions - or ProbeMode.CompareCrossVersion or ProbeMode.CompareSpot or ProbeMode.ExplicitPoolSweep) + or ProbeMode.CompareCrossVersion or ProbeMode.CompareSpot or ProbeMode.ExplicitPoolSweep + or ProbeMode.CompareGrpc) { ProbeLog.Error("--serve-proxy requires a single arm mode"); return 2; @@ -117,13 +120,71 @@ or ProbeMode.CompareArch or ProbeMode.CompareSaturation or ProbeMode.CompareEdit string targetForClient; var extraClientTargets = new List(); string? nginxVersion = null; + string? haproxyVersion = null; + string? envoyVersion = null; string? yarpVersion = null; string? cliControlPlaneUrl = null; string? cliDashboardUrl = null; string? cliAuthorizationBearer = null; string? cliDiscoveryFile = null; - switch (mode) + if (PeerWire.TryGet(mode, out var nativeWire)) + { + var originPort = PeerWire.OriginPort(nativeWire, originHttpPort, originHttpsPort, originQuicPort); + if (originPort <= 0) + { + throw new ArgumentException(nativeWire.Origin is PeerOriginProto.H3 + ? "origin-quic-port required" + : nativeWire.Origin is PeerOriginProto.H1Tls or PeerOriginProto.H2Tls + ? "origin-https-port required" + : "origin-http-port required"); + } + + switch (nativeWire.Product) + { + case PeerProduct.Nginx: + { + var nginx = (nativeWire.Inbound, nativeWire.Origin) switch + { + (PeerInboundProto.H2c, PeerOriginProto.H1c) => + await NginxHost.TryStartH2cToH1Async(originPort, nginxPath), + (PeerInboundProto.H2c, PeerOriginProto.H1Tls) => + await NginxHost.TryStartH2cToHttpsAsync(originPort, nginxPath), + _ => throw new ArgumentOutOfRangeException(nameof(mode)) + } ?? throw new InvalidOperationException(NginxHost.NginxMissingMessage()); + proxy = nginx; + listenUrl = nginx.ListenUrl; + targetForClient = nginx.ListenUrl; + nginxVersion = nginx.Version; + break; + } + case PeerProduct.Haproxy: + { + var haproxy = await HaproxyHost.TryStartWireAsync(nativeWire.Inbound, nativeWire.Origin, + originPort, haproxyPath) + ?? throw new InvalidOperationException(HaproxyHost.HaproxyMissingMessage()); + proxy = haproxy; + listenUrl = haproxy.ListenUrl; + targetForClient = haproxy.ListenUrl; + haproxyVersion = haproxy.Version; + break; + } + case PeerProduct.Envoy: + { + var envoy = await EnvoyHost.TryStartWireAsync(nativeWire.Inbound, nativeWire.Origin, + originPort, envoyPath) + ?? throw new InvalidOperationException(EnvoyHost.EnvoyMissingMessage()); + proxy = envoy; + listenUrl = envoy.ListenUrl; + targetForClient = envoy.ListenUrl; + envoyVersion = envoy.Version; + break; + } + default: + throw new ArgumentOutOfRangeException(nameof(mode)); + } + } + else switch (mode) { case ProbeMode.ReverseHttp1: { @@ -205,6 +266,28 @@ or ProbeMode.CompareArch or ProbeMode.CompareSaturation or ProbeMode.CompareEdit nginxVersion = nginx.Version; break; } + case ProbeMode.HaproxyReverseHttp1: + { + if (originHttpPort <= 0) throw new ArgumentException("origin-http-port required"); + var haproxy = await HaproxyHost.TryStartHttp1Async(originHttpPort, haproxyPath) + ?? throw new InvalidOperationException(HaproxyHost.HaproxyMissingMessage()); + proxy = haproxy; + listenUrl = haproxy.ListenUrl; + targetForClient = haproxy.ListenUrl; + haproxyVersion = haproxy.Version; + break; + } + case ProbeMode.EnvoyReverseHttp1: + { + if (originHttpPort <= 0) throw new ArgumentException("origin-http-port required"); + var envoy = await EnvoyHost.TryStartHttp1Async(originHttpPort, envoyPath) + ?? throw new InvalidOperationException(EnvoyHost.EnvoyMissingMessage()); + proxy = envoy; + listenUrl = envoy.ListenUrl; + targetForClient = envoy.ListenUrl; + envoyVersion = envoy.Version; + break; + } case ProbeMode.YarpReverseHttp1: { if (originHttpPort <= 0) throw new ArgumentException("origin-http-port required"); @@ -254,6 +337,94 @@ or ProbeMode.CompareArch or ProbeMode.CompareSaturation or ProbeMode.CompareEdit nginxVersion = nginx.Version; break; } + case ProbeMode.NginxReverseHttp1ToHttps: + { + if (originHttpsPort <= 0) throw new ArgumentException("origin-https-port required"); + var nginx = await NginxHost.TryStartHttp1ToHttpsAsync(originHttpsPort, nginxPath) + ?? throw new InvalidOperationException(NginxHost.NginxMissingMessage()); + proxy = nginx; + listenUrl = nginx.ListenUrl; + targetForClient = nginx.ListenUrl; + nginxVersion = nginx.Version; + break; + } + case ProbeMode.NginxReverseHttp1TlsToHttps: + { + if (originHttpsPort <= 0) throw new ArgumentException("origin-https-port required"); + var nginx = await NginxHost.TryStartHttp1TlsToHttpsAsync(originHttpsPort, nginxPath) + ?? throw new InvalidOperationException(NginxHost.NginxMissingMessage()); + proxy = nginx; + listenUrl = nginx.ListenUrl; + targetForClient = nginx.ListenUrl; + nginxVersion = nginx.Version; + break; + } + case ProbeMode.HaproxyReverseHttp1Tls: + { + if (originHttpPort <= 0) throw new ArgumentException("origin-http-port required"); + var haproxy = await HaproxyHost.TryStartHttp1TlsAsync(originHttpPort, haproxyPath) + ?? throw new InvalidOperationException(HaproxyHost.HaproxyMissingMessage()); + proxy = haproxy; + listenUrl = haproxy.ListenUrl; + targetForClient = haproxy.ListenUrl; + haproxyVersion = haproxy.Version; + break; + } + case ProbeMode.EnvoyReverseHttp1Tls: + { + if (originHttpPort <= 0) throw new ArgumentException("origin-http-port required"); + var envoy = await EnvoyHost.TryStartHttp1TlsAsync(originHttpPort, envoyPath) + ?? throw new InvalidOperationException(EnvoyHost.EnvoyMissingMessage()); + proxy = envoy; + listenUrl = envoy.ListenUrl; + targetForClient = envoy.ListenUrl; + envoyVersion = envoy.Version; + break; + } + case ProbeMode.HaproxyReverseHttp1ToHttps: + { + if (originHttpsPort <= 0) throw new ArgumentException("origin-https-port required"); + var haproxy = await HaproxyHost.TryStartHttp1ToHttpsAsync(originHttpsPort, haproxyPath) + ?? throw new InvalidOperationException(HaproxyHost.HaproxyMissingMessage()); + proxy = haproxy; + listenUrl = haproxy.ListenUrl; + targetForClient = haproxy.ListenUrl; + haproxyVersion = haproxy.Version; + break; + } + case ProbeMode.EnvoyReverseHttp1ToHttps: + { + if (originHttpsPort <= 0) throw new ArgumentException("origin-https-port required"); + var envoy = await EnvoyHost.TryStartHttp1ToHttpsAsync(originHttpsPort, envoyPath) + ?? throw new InvalidOperationException(EnvoyHost.EnvoyMissingMessage()); + proxy = envoy; + listenUrl = envoy.ListenUrl; + targetForClient = envoy.ListenUrl; + envoyVersion = envoy.Version; + break; + } + case ProbeMode.HaproxyReverseHttp1TlsToHttps: + { + if (originHttpsPort <= 0) throw new ArgumentException("origin-https-port required"); + var haproxy = await HaproxyHost.TryStartHttp1TlsToHttpsAsync(originHttpsPort, haproxyPath) + ?? throw new InvalidOperationException(HaproxyHost.HaproxyMissingMessage()); + proxy = haproxy; + listenUrl = haproxy.ListenUrl; + targetForClient = haproxy.ListenUrl; + haproxyVersion = haproxy.Version; + break; + } + case ProbeMode.EnvoyReverseHttp1TlsToHttps: + { + if (originHttpsPort <= 0) throw new ArgumentException("origin-https-port required"); + var envoy = await EnvoyHost.TryStartHttp1TlsToHttpsAsync(originHttpsPort, envoyPath) + ?? throw new InvalidOperationException(EnvoyHost.EnvoyMissingMessage()); + proxy = envoy; + listenUrl = envoy.ListenUrl; + targetForClient = envoy.ListenUrl; + envoyVersion = envoy.Version; + break; + } case ProbeMode.YarpReverseHttp1Tls: { if (originHttpPort <= 0) throw new ArgumentException("origin-http-port required"); @@ -353,6 +524,28 @@ or ProbeMode.CompareArch or ProbeMode.CompareSaturation or ProbeMode.CompareEdit nginxVersion = nginx.Version; break; } + case ProbeMode.NginxReverseGrpc: + { + if (originHttpsPort <= 0) throw new ArgumentException("origin-https-port required"); + var nginx = await NginxHost.TryStartGrpcAsync(originHttpsPort, nginxPath) + ?? throw new InvalidOperationException(NginxHost.NginxMissingMessage()); + proxy = nginx; + listenUrl = nginx.ListenUrl; + targetForClient = nginx.ListenUrl; + nginxVersion = nginx.Version; + break; + } + case ProbeMode.NginxReverseHttp2ToHttpsHttp1: + { + if (originHttpsPort <= 0) throw new ArgumentException("origin-https-port required"); + var nginx = await NginxHost.TryStartHttp2ToHttpsHttp1Async(originHttpsPort, nginxPath) + ?? throw new InvalidOperationException(NginxHost.NginxMissingMessage()); + proxy = nginx; + listenUrl = nginx.ListenUrl; + targetForClient = nginx.ListenUrl; + nginxVersion = nginx.Version; + break; + } case ProbeMode.NginxReverseHttp3Cleartext: { if (originHttpPort <= 0) throw new ArgumentException("origin-http-port required"); @@ -366,6 +559,113 @@ or ProbeMode.CompareArch or ProbeMode.CompareSaturation or ProbeMode.CompareEdit nginxVersion = nginx.Version; break; } + case ProbeMode.NginxReverseHttp3ToHttpsHttp1: + { + if (originHttpsPort <= 0) throw new ArgumentException("origin-https-port required"); + var nginx = await NginxHost.TryStartHttp3ToHttpsHttp1Async(originHttpsPort, nginxPath) + ?? throw new InvalidOperationException( + "nginx HTTP/3 is not available (need --with-http_v3_module). " + + NginxHost.NginxMissingMessage()); + proxy = nginx; + listenUrl = nginx.ListenUrl; + targetForClient = nginx.ListenUrl; + nginxVersion = nginx.Version; + break; + } + case ProbeMode.HaproxyReverseHttp2: + { + if (originHttpPort <= 0) throw new ArgumentException("origin-http-port required"); + var haproxy = await HaproxyHost.TryStartHttp2Async(originHttpPort, haproxyPath) + ?? throw new InvalidOperationException(HaproxyHost.HaproxyMissingMessage()); + proxy = haproxy; + listenUrl = haproxy.ListenUrl; + targetForClient = haproxy.ListenUrl; + haproxyVersion = haproxy.Version; + break; + } + case ProbeMode.EnvoyReverseHttp2: + { + if (originHttpPort <= 0) throw new ArgumentException("origin-http-port required"); + var envoy = await EnvoyHost.TryStartHttp2Async(originHttpPort, envoyPath) + ?? throw new InvalidOperationException(EnvoyHost.EnvoyMissingMessage()); + proxy = envoy; + listenUrl = envoy.ListenUrl; + targetForClient = envoy.ListenUrl; + envoyVersion = envoy.Version; + break; + } + case ProbeMode.HaproxyReverseHttp2ToHttpsHttp1: + { + if (originHttpsPort <= 0) throw new ArgumentException("origin-https-port required"); + var haproxy = await HaproxyHost.TryStartHttp2ToHttpsHttp1Async(originHttpsPort, haproxyPath) + ?? throw new InvalidOperationException(HaproxyHost.HaproxyMissingMessage()); + proxy = haproxy; + listenUrl = haproxy.ListenUrl; + targetForClient = haproxy.ListenUrl; + haproxyVersion = haproxy.Version; + break; + } + case ProbeMode.EnvoyReverseHttp2ToHttpsHttp1: + { + if (originHttpsPort <= 0) throw new ArgumentException("origin-https-port required"); + var envoy = await EnvoyHost.TryStartHttp2ToHttpsHttp1Async(originHttpsPort, envoyPath) + ?? throw new InvalidOperationException(EnvoyHost.EnvoyMissingMessage()); + proxy = envoy; + listenUrl = envoy.ListenUrl; + targetForClient = envoy.ListenUrl; + envoyVersion = envoy.Version; + break; + } + case ProbeMode.HaproxyReverseHttp3Cleartext: + { + if (originHttpPort <= 0) throw new ArgumentException("origin-http-port required"); + var haproxy = await HaproxyHost.TryStartHttp3CleartextAsync(originHttpPort, haproxyPath) + ?? throw new InvalidOperationException( + "haproxy HTTP/3 is not available (need USE_QUIC). " + + HaproxyHost.HaproxyMissingMessage()); + proxy = haproxy; + listenUrl = haproxy.ListenUrl; + targetForClient = haproxy.ListenUrl; + haproxyVersion = haproxy.Version; + break; + } + case ProbeMode.EnvoyReverseHttp3Cleartext: + { + if (originHttpPort <= 0) throw new ArgumentException("origin-http-port required"); + var envoy = await EnvoyHost.TryStartHttp3CleartextAsync(originHttpPort, envoyPath) + ?? throw new InvalidOperationException( + "envoy HTTP/3 is not available. " + EnvoyHost.EnvoyMissingMessage()); + proxy = envoy; + listenUrl = envoy.ListenUrl; + targetForClient = envoy.ListenUrl; + envoyVersion = envoy.Version; + break; + } + case ProbeMode.HaproxyReverseHttp3ToHttpsHttp1: + { + if (originHttpsPort <= 0) throw new ArgumentException("origin-https-port required"); + var haproxy = await HaproxyHost.TryStartHttp3ToHttpsHttp1Async(originHttpsPort, haproxyPath) + ?? throw new InvalidOperationException( + "haproxy HTTP/3 is not available (need USE_QUIC). " + + HaproxyHost.HaproxyMissingMessage()); + proxy = haproxy; + listenUrl = haproxy.ListenUrl; + targetForClient = haproxy.ListenUrl; + haproxyVersion = haproxy.Version; + break; + } + case ProbeMode.EnvoyReverseHttp3ToHttpsHttp1: + { + if (originHttpsPort <= 0) throw new ArgumentException("origin-https-port required"); + var envoy = await EnvoyHost.TryStartHttp3ToHttpsHttp1Async(originHttpsPort, envoyPath) + ?? throw new InvalidOperationException( + "envoy HTTP/3 is not available. " + EnvoyHost.EnvoyMissingMessage()); + proxy = envoy; + listenUrl = envoy.ListenUrl; + targetForClient = envoy.ListenUrl; + envoyVersion = envoy.Version; + break; + } case ProbeMode.ReverseHttp3Cleartext: { if (originHttpPort <= 0) throw new ArgumentException("origin-http-port required"); @@ -753,10 +1053,15 @@ or ProbeMode.CompareArch or ProbeMode.CompareSaturation or ProbeMode.CompareEdit throw new ArgumentOutOfRangeException(nameof(mode)); } - var httpVersion = mode switch + var httpVersion = PeerWire.TryGet(mode, out var httpWire) + ? PeerWire.ClientHttpVersion(httpWire) + : mode switch { ProbeMode.ReverseHttp2 or ProbeMode.ReverseHttp2Cleartext or ProbeMode.ReverseHttp2ToH2c or ProbeMode.ReverseHttp2ToHttp3 or ProbeMode.NginxReverseHttp2 + or ProbeMode.NginxReverseHttp2ToHttpsHttp1 or ProbeMode.NginxReverseGrpc + or ProbeMode.HaproxyReverseHttp2 or ProbeMode.HaproxyReverseHttp2ToHttpsHttp1 + or ProbeMode.EnvoyReverseHttp2 or ProbeMode.EnvoyReverseHttp2ToHttpsHttp1 or ProbeMode.YarpReverseHttp2 or ProbeMode.YarpReverseHttp2ToH2c or ProbeMode.YarpReverseHttp2ToHttps or ProbeMode.YarpReverseHttp2ToHttp3 or ProbeMode.YarpReverseHttp2ToHttpsHttp1 @@ -769,7 +1074,9 @@ ProbeMode.ReverseHttp3 or ProbeMode.ReverseHttp3Cleartext or ProbeMode.ReverseHt or ProbeMode.ReverseHttp3ToH2c or ProbeMode.YarpReverseHttp3ToH2c or ProbeMode.YarpReverseHttp3Cleartext or ProbeMode.YarpReverseHttp3ToHttp2 or ProbeMode.YarpReverseHttp3ToHttp3 or ProbeMode.YarpReverseHttp3ToHttpsHttp1 - or ProbeMode.NginxReverseHttp3Cleartext + or ProbeMode.NginxReverseHttp3Cleartext or ProbeMode.NginxReverseHttp3ToHttpsHttp1 + or ProbeMode.HaproxyReverseHttp3Cleartext or ProbeMode.HaproxyReverseHttp3ToHttpsHttp1 + or ProbeMode.EnvoyReverseHttp3Cleartext or ProbeMode.EnvoyReverseHttp3ToHttpsHttp1 or ProbeMode.MitmHttp3ToHttp1 => "3.0", // H1 client arms (including H1→H2/H3 bridges) must stay 1.1 so ALPN negotiate is http/1.1. _ => "1.1" @@ -792,6 +1099,10 @@ or ProbeMode.NginxReverseHttp3Cleartext await ProbeLog.WriteProtocolLineAsync($"origin_quic_port={originQuicPort}", cancellationToken); if (nginxVersion != null) await ProbeLog.WriteProtocolLineAsync($"nginx={nginxVersion}", cancellationToken); + if (haproxyVersion != null) + await ProbeLog.WriteProtocolLineAsync($"haproxy={haproxyVersion}", cancellationToken); + if (envoyVersion != null) + await ProbeLog.WriteProtocolLineAsync($"envoy={envoyVersion}", cancellationToken); if (yarpVersion != null) await ProbeLog.WriteProtocolLineAsync($"yarp={yarpVersion}", cancellationToken); if (maxCachedConnections is { } m) @@ -823,16 +1134,21 @@ await ProbeLog.WriteProtocolLineAsync($"authorization_bearer={cliAuthorizationBe return 0; } - internal static string ModeName(ProbeMode mode) => mode switch + internal static string ModeName(ProbeMode mode) => + PeerWire.TryGet(mode, out var wire) ? wire.Name : mode switch { ProbeMode.ReverseHttp1 => "reverse-http1", ProbeMode.BareReverseHttp1 => "bare-reverse-http1", ProbeMode.NginxReverseHttp1 => "nginx-reverse-http1", + ProbeMode.HaproxyReverseHttp1 => "haproxy-reverse-http1", + ProbeMode.EnvoyReverseHttp1 => "envoy-reverse-http1", ProbeMode.YarpReverseHttp1 => "yarp-reverse-http1", ProbeMode.ReverseHttp1Tls => "reverse-http1-tls", ProbeMode.ReverseHttp1ToHttps => "reverse-http1-to-https", ProbeMode.BareReverseHttp1Tls => "bare-reverse-http1-tls", ProbeMode.NginxReverseHttp1Tls => "nginx-reverse-http1-tls", + ProbeMode.HaproxyReverseHttp1Tls => "haproxy-reverse-http1-tls", + ProbeMode.EnvoyReverseHttp1Tls => "envoy-reverse-http1-tls", ProbeMode.YarpReverseHttp1Tls => "yarp-reverse-http1-tls", ProbeMode.YarpReverseHttp1ToHttps => "yarp-reverse-http1-to-https", ProbeMode.HttpsMitm => "https-mitm", @@ -849,9 +1165,29 @@ await ProbeLog.WriteProtocolLineAsync($"authorization_bearer={cliAuthorizationBe ProbeMode.ReverseH2cToH1 => "reverse-h2c-to-h1", ProbeMode.YarpReverseH2cToH1 => "yarp-reverse-h2c-to-h1", ProbeMode.ReverseH2cToH3 => "reverse-h2c-to-h3", + ProbeMode.NginxReverseHttp1ToHttps => "nginx-reverse-http1-to-https", + ProbeMode.NginxReverseHttp1TlsToHttps => "nginx-reverse-http1-tls-to-https", + ProbeMode.HaproxyReverseHttp1ToHttps => "haproxy-reverse-http1-to-https", + ProbeMode.HaproxyReverseHttp1TlsToHttps => "haproxy-reverse-http1-tls-to-https", + ProbeMode.EnvoyReverseHttp1ToHttps => "envoy-reverse-http1-to-https", + ProbeMode.EnvoyReverseHttp1TlsToHttps => "envoy-reverse-http1-tls-to-https", ProbeMode.YarpReverseH2cToH3 => "yarp-reverse-h2c-to-h3", ProbeMode.NginxReverseHttp2 => "nginx-reverse-http2", + ProbeMode.NginxReverseGrpc => "nginx-grpc-http2", + ProbeMode.NginxReverseHttp2ToHttpsHttp1 => "nginx-reverse-http2-to-https-http1", ProbeMode.NginxReverseHttp3Cleartext => "nginx-reverse-http3-cleartext", + ProbeMode.NginxReverseHttp3ToHttpsHttp1 => "nginx-reverse-http3-to-https-http1", + ProbeMode.HaproxyReverseHttp2 => "haproxy-reverse-http2", + ProbeMode.HaproxyReverseHttp2ToHttpsHttp1 => "haproxy-reverse-http2-to-https-http1", + ProbeMode.HaproxyReverseHttp3Cleartext => "haproxy-reverse-http3-cleartext", + ProbeMode.HaproxyReverseHttp3ToHttpsHttp1 => "haproxy-reverse-http3-to-https-http1", + ProbeMode.EnvoyReverseHttp2 => "envoy-reverse-http2", + ProbeMode.EnvoyReverseHttp2ToHttpsHttp1 => "envoy-reverse-http2-to-https-http1", + ProbeMode.EnvoyReverseHttp3Cleartext => "envoy-reverse-http3-cleartext", + ProbeMode.EnvoyReverseHttp3ToHttpsHttp1 => "envoy-reverse-http3-to-https-http1", + ProbeMode.CompareNginxHttps => "compare-nginx-https", + ProbeMode.CompareHaproxySmoke => "compare-haproxy-smoke", + ProbeMode.CompareEnvoySmoke => "compare-envoy-smoke", ProbeMode.YarpReverseHttp2 => "yarp-reverse-http2", ProbeMode.YarpReverseHttp2ToHttps => "yarp-reverse-http2-to-https", ProbeMode.ReverseHttp3 => "reverse-http3", @@ -932,8 +1268,8 @@ await ProbeLog.WriteProtocolLineAsync($"authorization_bearer={cliAuthorizationBe internal static class ServeHost { - public static async Task RunAsync(ProbeMode mode, string? nginxPath, int? maxCachedConnections, - CancellationToken cancellationToken, WorkloadOptions? workload = null) + public static async Task RunAsync(ProbeMode mode, string? nginxPath, string? haproxyPath, string? envoyPath, + int? maxCachedConnections, CancellationToken cancellationToken, WorkloadOptions? workload = null) { workload ??= WorkloadOptions.TinyGet; if (mode is ProbeMode.OriginDirect) @@ -944,7 +1280,8 @@ public static async Task RunAsync(ProbeMode mode, string? nginxPath, int? m if (mode is ProbeMode.Compare or ProbeMode.CompareHttp2 or ProbeMode.CompareTls or ProbeMode.CompareTerminate or ProbeMode.CompareSame or ProbeMode.CompareBridges - or ProbeMode.CompareHttp3Cleartext + or ProbeMode.CompareHttp3Cleartext or ProbeMode.CompareNginxHttps or ProbeMode.CompareHaproxySmoke + or ProbeMode.CompareEnvoySmoke or ProbeMode.CompareMitm or ProbeMode.CompareMatrix or ProbeMode.CompareProduct or ProbeMode.CompareProductSmoke or ProbeMode.CompareCeiling or ProbeMode.CompareBodies @@ -955,18 +1292,46 @@ or ProbeMode.ComparePost or ProbeMode.CompareLossy or ProbeMode.CompareTlsCost return 2; } - if ((mode is ProbeMode.NginxReverseHttp1 or ProbeMode.NginxReverseHttp1Tls or ProbeMode.NginxReverseHttp2 - or ProbeMode.NginxReverseHttp3Cleartext) + if ((mode is ProbeMode.NginxReverseHttp1 or ProbeMode.NginxReverseHttp1Tls + or ProbeMode.NginxReverseHttp1ToHttps or ProbeMode.NginxReverseHttp1TlsToHttps + or ProbeMode.NginxReverseHttp2 or ProbeMode.NginxReverseHttp2ToHttpsHttp1 + or ProbeMode.NginxReverseGrpc + or ProbeMode.NginxReverseHttp3Cleartext or ProbeMode.NginxReverseHttp3ToHttpsHttp1 + || PeerWire.IsProduct(mode, PeerProduct.Nginx)) && NginxHost.ResolveNginxExecutable(nginxPath) == null) { ProbeLog.Error(NginxHost.NginxMissingMessage()); return 3; } + if ((mode is ProbeMode.HaproxyReverseHttp1 or ProbeMode.HaproxyReverseHttp1Tls + or ProbeMode.HaproxyReverseHttp1ToHttps or ProbeMode.HaproxyReverseHttp1TlsToHttps + or ProbeMode.HaproxyReverseHttp2 or ProbeMode.HaproxyReverseHttp2ToHttpsHttp1 + or ProbeMode.HaproxyReverseHttp3Cleartext or ProbeMode.HaproxyReverseHttp3ToHttpsHttp1 + || PeerWire.IsProduct(mode, PeerProduct.Haproxy)) + && HaproxyHost.ResolveHaproxyExecutable(haproxyPath) == null) + { + // On Windows Resolve always returns null (no official port) — message explains that. + ProbeLog.Error(HaproxyHost.HaproxyMissingMessage()); + return 3; + } + + if ((mode is ProbeMode.EnvoyReverseHttp1 or ProbeMode.EnvoyReverseHttp1Tls + or ProbeMode.EnvoyReverseHttp1ToHttps or ProbeMode.EnvoyReverseHttp1TlsToHttps + or ProbeMode.EnvoyReverseHttp2 or ProbeMode.EnvoyReverseHttp2ToHttpsHttp1 + or ProbeMode.EnvoyReverseHttp3Cleartext or ProbeMode.EnvoyReverseHttp3ToHttpsHttp1 + || PeerWire.IsProduct(mode, PeerProduct.Envoy)) + && EnvoyHost.ResolveEnvoyExecutable(envoyPath) == null) + { + ProbeLog.Error(EnvoyHost.EnvoyMissingMessage()); + return 3; + } + ServeStack stack; try { - stack = await ServeStack.StartAsync(mode, nginxPath, maxCachedConnections, cancellationToken, workload); + stack = await ServeStack.StartAsync(mode, nginxPath, haproxyPath, envoyPath, maxCachedConnections, + cancellationToken, workload); } catch (Exception ex) { @@ -978,7 +1343,7 @@ or ProbeMode.ComparePost or ProbeMode.CompareLossy or ProbeMode.CompareTlsCost await using (stack) { - ProbeLog.Info(MachineInfo.FormatReport(stack.NginxVersion)); + ProbeLog.Info(MachineInfo.FormatReport(stack.NginxVersion, stack.HaproxyVersion, stack.EnvoyVersion)); await ProbeLog.WriteProtocolLineAsync($"mode={ServeProxyHost.ModeName(mode)}", cancellationToken); await ProbeLog.WriteProtocolLineAsync($"origin_http={stack.OriginHttpUrl}", cancellationToken); if (stack.OriginHttpsUrl != null) @@ -1001,6 +1366,10 @@ or ProbeMode.ComparePost or ProbeMode.CompareLossy or ProbeMode.CompareTlsCost await ProbeLog.WriteProtocolLineAsync($"origin_quic_port={oqp}", cancellationToken); if (stack.NginxVersion != null) await ProbeLog.WriteProtocolLineAsync($"nginx={stack.NginxVersion}", cancellationToken); + if (stack.HaproxyVersion != null) + await ProbeLog.WriteProtocolLineAsync($"haproxy={stack.HaproxyVersion}", cancellationToken); + if (stack.EnvoyVersion != null) + await ProbeLog.WriteProtocolLineAsync($"envoy={stack.EnvoyVersion}", cancellationToken); if (stack.YarpVersion != null) await ProbeLog.WriteProtocolLineAsync($"yarp={stack.YarpVersion}", cancellationToken); if (maxCachedConnections is { } m) @@ -1044,6 +1413,8 @@ internal sealed class ServeStack : IAsyncDisposable public string ClientTargetUrl { get; } public IReadOnlyList ClientTargetUrls { get; } public string? NginxVersion { get; } + public string? HaproxyVersion { get; } + public string? EnvoyVersion { get; } public string? YarpVersion { get; } public string? HttpVersion { get; } public string? LoadGenerator { get; } @@ -1055,7 +1426,8 @@ private ServeStack(IAsyncDisposable origin, IDisposable? proxy, TwpProxyHost? tw string? originHttpsUrl, IReadOnlyList extraOriginHttpsUrls, string listenUrl, string? explicitProxyUrl, string clientTargetUrl, IReadOnlyList clientTargetUrls, string? nginxVersion, string? httpVersion, string? loadGenerator = null, int? quicPort = null, - int? originQuicPort = null, string? yarpVersion = null) + int? originQuicPort = null, string? yarpVersion = null, string? haproxyVersion = null, + string? envoyVersion = null) { this.origin = origin; this.proxy = proxy; @@ -1068,6 +1440,8 @@ private ServeStack(IAsyncDisposable origin, IDisposable? proxy, TwpProxyHost? tw ClientTargetUrl = clientTargetUrl; ClientTargetUrls = clientTargetUrls; NginxVersion = nginxVersion; + HaproxyVersion = haproxyVersion; + EnvoyVersion = envoyVersion; YarpVersion = yarpVersion; HttpVersion = httpVersion; LoadGenerator = loadGenerator; @@ -1076,11 +1450,150 @@ private ServeStack(IAsyncDisposable origin, IDisposable? proxy, TwpProxyHost? tw ServerConnectionProbe = twp == null ? null : () => twp.Server.ServerConnectionCount; } - public static async Task StartAsync(ProbeMode mode, string? nginxPath, - int? maxCachedConnections, CancellationToken cancellationToken, WorkloadOptions? workload = null) + private static async Task StartNativePeerServeStackAsync(NativePeerWire wire, + string? nginxPath, string? haproxyPath, string? envoyPath, int responseBytes, + CancellationToken cancellationToken, WorkloadOptions? workload) + { + if (wire.Inbound == PeerInboundProto.H3 || wire.Origin == PeerOriginProto.H3) + { + if (!System.Net.Quic.QuicListener.IsSupported) + throw new PlatformNotSupportedException("QuicListener is not supported."); + } + + IAsyncDisposable origin; + int originPort; + string originHttpUrl; + string? originHttpsUrl; + int? originQuicPort = null; + switch (wire.Origin) + { + case PeerOriginProto.H1c: + { + var httpOrigin = await OriginServer.StartAsync(false, responseBytes, cancellationToken, workload); + origin = httpOrigin; + originPort = httpOrigin.HttpPort; + originHttpUrl = httpOrigin.HttpUrl; + originHttpsUrl = null; + break; + } + case PeerOriginProto.H2c: + { + var h2cOrigin = await OriginServer.StartAsync(new OriginListenOptions + { + EnableHttp = true, + EnableHttps = false, + HttpProtocols = HttpProtocols.Http2, + ResponseBytes = responseBytes + }, cancellationToken, workload); + origin = h2cOrigin; + originPort = h2cOrigin.HttpPort; + originHttpUrl = h2cOrigin.HttpUrl; + originHttpsUrl = null; + break; + } + case PeerOriginProto.H1Tls: + { + var tlsOrigin = await OriginServer.StartAsync(new OriginListenOptions + { + EnableHttp = false, + EnableHttps = true, + HttpsProtocols = HttpProtocols.Http1, + ResponseBytes = responseBytes + }, cancellationToken, workload); + origin = tlsOrigin; + originPort = tlsOrigin.HttpsPort; + originHttpUrl = tlsOrigin.HttpUrl; + originHttpsUrl = tlsOrigin.HttpsUrl; + break; + } + case PeerOriginProto.H2Tls: + { + var h2Origin = await OriginServer.StartAsync(new OriginListenOptions + { + EnableHttp = false, + EnableHttps = true, + HttpsProtocols = HttpProtocols.Http1AndHttp2, + ResponseBytes = responseBytes + }, cancellationToken, workload); + origin = h2Origin; + originPort = h2Origin.HttpsPort; + originHttpUrl = h2Origin.HttpUrl; + originHttpsUrl = h2Origin.HttpsUrl; + break; + } + case PeerOriginProto.H3: + { + var quic = new QuicHttp3OriginHost(responseBytes); + origin = quic; + originPort = quic.Port; + originHttpUrl = $"quic://localhost:{quic.Port}/"; + originHttpsUrl = originHttpUrl; + originQuicPort = quic.Port; + break; + } + default: + throw new ArgumentOutOfRangeException(nameof(wire)); + } + + IDisposable proxy; + string listenUrl; + string? nginxVersion = null; + string? haproxyVersion = null; + string? envoyVersion = null; + switch (wire.Product) + { + case PeerProduct.Nginx: + { + var nginx = (wire.Inbound, wire.Origin) switch + { + (PeerInboundProto.H2c, PeerOriginProto.H1c) => + await NginxHost.TryStartH2cToH1Async(originPort, nginxPath), + (PeerInboundProto.H2c, PeerOriginProto.H1Tls) => + await NginxHost.TryStartH2cToHttpsAsync(originPort, nginxPath), + _ => throw new ArgumentOutOfRangeException(nameof(wire)) + } ?? throw new InvalidOperationException("nginx not available."); + proxy = nginx; + listenUrl = nginx.ListenUrl; + nginxVersion = nginx.Version; + break; + } + case PeerProduct.Haproxy: + { + var haproxy = await HaproxyHost.TryStartWireAsync(wire.Inbound, wire.Origin, originPort, haproxyPath) + ?? throw new InvalidOperationException("haproxy is not available."); + proxy = haproxy; + listenUrl = haproxy.ListenUrl; + haproxyVersion = haproxy.Version; + break; + } + case PeerProduct.Envoy: + { + var envoy = await EnvoyHost.TryStartWireAsync(wire.Inbound, wire.Origin, originPort, envoyPath) + ?? throw new InvalidOperationException("envoy is not available."); + proxy = envoy; + listenUrl = envoy.ListenUrl; + envoyVersion = envoy.Version; + break; + } + default: + throw new ArgumentOutOfRangeException(nameof(wire)); + } + + var httpVersion = PeerWire.ClientHttpVersion(wire); + return new ServeStack(origin, proxy, null, originHttpUrl, originHttpsUrl, [], listenUrl, null, + listenUrl, [listenUrl], nginxVersion, httpVersion, originQuicPort: originQuicPort, + haproxyVersion: haproxyVersion, envoyVersion: envoyVersion); + } + + public static async Task StartAsync(ProbeMode mode, string? nginxPath, string? haproxyPath, + string? envoyPath, int? maxCachedConnections, CancellationToken cancellationToken, + WorkloadOptions? workload = null) { workload ??= WorkloadOptions.TinyGet; var responseBytes = workload.ResponseBytes; + if (PeerWire.TryGet(mode, out var nativeWire)) + return await StartNativePeerServeStackAsync(nativeWire, nginxPath, haproxyPath, envoyPath, + responseBytes, cancellationToken, workload); switch (mode) { case ProbeMode.ReverseHttp1: @@ -1105,6 +1618,22 @@ public static async Task StartAsync(ProbeMode mode, string? nginxPat return new ServeStack(origin, nginx, null, origin.HttpUrl, null, [], nginx.ListenUrl, null, nginx.ListenUrl, [nginx.ListenUrl], nginx.Version, "1.1"); } + case ProbeMode.HaproxyReverseHttp1: + { + var origin = await OriginServer.StartAsync(false, responseBytes, cancellationToken, workload); + var haproxy = await HaproxyHost.TryStartHttp1Async(origin.HttpPort, haproxyPath) + ?? throw new InvalidOperationException("haproxy not available."); + return new ServeStack(origin, haproxy, null, origin.HttpUrl, null, [], haproxy.ListenUrl, null, + haproxy.ListenUrl, [haproxy.ListenUrl], null, "1.1", haproxyVersion: haproxy.Version); + } + case ProbeMode.EnvoyReverseHttp1: + { + var origin = await OriginServer.StartAsync(false, responseBytes, cancellationToken, workload); + var envoy = await EnvoyHost.TryStartHttp1Async(origin.HttpPort, envoyPath) + ?? throw new InvalidOperationException("envoy not available."); + return new ServeStack(origin, envoy, null, origin.HttpUrl, null, [], envoy.ListenUrl, null, + envoy.ListenUrl, [envoy.ListenUrl], null, "1.1", envoyVersion: envoy.Version); + } case ProbeMode.HttpsMitm: { var origin = await OriginServer.StartAsync(true, responseBytes, cancellationToken, workload); @@ -1155,6 +1684,74 @@ public static async Task StartAsync(ProbeMode mode, string? nginxPat return new ServeStack(origin, nginx, null, origin.HttpUrl, null, [], nginx.ListenUrl, null, nginx.ListenUrl, [nginx.ListenUrl], nginx.Version, "1.1"); } + case ProbeMode.NginxReverseHttp1ToHttps: + { + var origin = await OriginServer.StartAsync(true, responseBytes, cancellationToken, workload); + var nginx = await NginxHost.TryStartHttp1ToHttpsAsync(origin.HttpsPort, nginxPath) + ?? throw new InvalidOperationException("nginx not available."); + return new ServeStack(origin, nginx, null, origin.HttpUrl, origin.HttpsUrl, [], nginx.ListenUrl, + null, nginx.ListenUrl, [nginx.ListenUrl], nginx.Version, "1.1"); + } + case ProbeMode.NginxReverseHttp1TlsToHttps: + { + var origin = await OriginServer.StartAsync(true, responseBytes, cancellationToken, workload); + var nginx = await NginxHost.TryStartHttp1TlsToHttpsAsync(origin.HttpsPort, nginxPath) + ?? throw new InvalidOperationException("nginx not available."); + return new ServeStack(origin, nginx, null, origin.HttpUrl, origin.HttpsUrl, [], nginx.ListenUrl, + null, nginx.ListenUrl, [nginx.ListenUrl], nginx.Version, "1.1"); + } + case ProbeMode.HaproxyReverseHttp1Tls: + { + var origin = await OriginServer.StartAsync(false, responseBytes, cancellationToken, workload); + var haproxy = await HaproxyHost.TryStartHttp1TlsAsync(origin.HttpPort, haproxyPath) + ?? throw new InvalidOperationException("haproxy not available."); + return new ServeStack(origin, haproxy, null, origin.HttpUrl, null, [], haproxy.ListenUrl, + null, haproxy.ListenUrl, [haproxy.ListenUrl], null, "1.1", haproxyVersion: haproxy.Version); + } + case ProbeMode.EnvoyReverseHttp1Tls: + { + var origin = await OriginServer.StartAsync(false, responseBytes, cancellationToken, workload); + var envoy = await EnvoyHost.TryStartHttp1TlsAsync(origin.HttpPort, envoyPath) + ?? throw new InvalidOperationException("envoy not available."); + return new ServeStack(origin, envoy, null, origin.HttpUrl, null, [], envoy.ListenUrl, + null, envoy.ListenUrl, [envoy.ListenUrl], null, "1.1", envoyVersion: envoy.Version); + } + case ProbeMode.HaproxyReverseHttp1ToHttps: + { + var origin = await OriginServer.StartAsync(true, responseBytes, cancellationToken, workload); + var haproxy = await HaproxyHost.TryStartHttp1ToHttpsAsync(origin.HttpsPort, haproxyPath) + ?? throw new InvalidOperationException("haproxy not available."); + return new ServeStack(origin, haproxy, null, origin.HttpUrl, origin.HttpsUrl, [], + haproxy.ListenUrl, null, haproxy.ListenUrl, [haproxy.ListenUrl], null, "1.1", + haproxyVersion: haproxy.Version); + } + case ProbeMode.EnvoyReverseHttp1ToHttps: + { + var origin = await OriginServer.StartAsync(true, responseBytes, cancellationToken, workload); + var envoy = await EnvoyHost.TryStartHttp1ToHttpsAsync(origin.HttpsPort, envoyPath) + ?? throw new InvalidOperationException("envoy not available."); + return new ServeStack(origin, envoy, null, origin.HttpUrl, origin.HttpsUrl, [], + envoy.ListenUrl, null, envoy.ListenUrl, [envoy.ListenUrl], null, "1.1", + envoyVersion: envoy.Version); + } + case ProbeMode.HaproxyReverseHttp1TlsToHttps: + { + var origin = await OriginServer.StartAsync(true, responseBytes, cancellationToken, workload); + var haproxy = await HaproxyHost.TryStartHttp1TlsToHttpsAsync(origin.HttpsPort, haproxyPath) + ?? throw new InvalidOperationException("haproxy not available."); + return new ServeStack(origin, haproxy, null, origin.HttpUrl, origin.HttpsUrl, [], + haproxy.ListenUrl, null, haproxy.ListenUrl, [haproxy.ListenUrl], null, "1.1", + haproxyVersion: haproxy.Version); + } + case ProbeMode.EnvoyReverseHttp1TlsToHttps: + { + var origin = await OriginServer.StartAsync(true, responseBytes, cancellationToken, workload); + var envoy = await EnvoyHost.TryStartHttp1TlsToHttpsAsync(origin.HttpsPort, envoyPath) + ?? throw new InvalidOperationException("envoy not available."); + return new ServeStack(origin, envoy, null, origin.HttpUrl, origin.HttpsUrl, [], + envoy.ListenUrl, null, envoy.ListenUrl, [envoy.ListenUrl], null, "1.1", + envoyVersion: envoy.Version); + } case ProbeMode.ReverseHttp2: { var origin = await OriginServer.StartAsync(new OriginListenOptions @@ -1258,6 +1855,35 @@ public static async Task StartAsync(ProbeMode mode, string? nginxPat return new ServeStack(origin, nginx, null, origin.HttpUrl, null, [], nginx.ListenUrl, null, nginx.ListenUrl, [nginx.ListenUrl], nginx.Version, "2.0"); } + case ProbeMode.NginxReverseGrpc: + { + var origin = await OriginServer.StartAsync(new OriginListenOptions + { + EnableHttp = false, + EnableHttps = true, + HttpsProtocols = HttpProtocols.Http2, + EnableGrpc = true, + ResponseBytes = responseBytes + }, cancellationToken, workload ?? WorkloadOptions.ForGrpc()); + var nginx = await NginxHost.TryStartGrpcAsync(origin.HttpsPort, nginxPath) + ?? throw new InvalidOperationException("nginx not available."); + return new ServeStack(origin, nginx, null, origin.HttpUrl, origin.HttpsUrl, [], nginx.ListenUrl, + null, nginx.ListenUrl, [nginx.ListenUrl], nginx.Version, "2.0"); + } + case ProbeMode.NginxReverseHttp2ToHttpsHttp1: + { + var origin = await OriginServer.StartAsync(new OriginListenOptions + { + EnableHttp = false, + EnableHttps = true, + HttpsProtocols = HttpProtocols.Http1, + ResponseBytes = responseBytes + }, cancellationToken, workload); + var nginx = await NginxHost.TryStartHttp2ToHttpsHttp1Async(origin.HttpsPort, nginxPath) + ?? throw new InvalidOperationException("nginx not available."); + return new ServeStack(origin, nginx, null, origin.HttpUrl, origin.HttpsUrl, [], nginx.ListenUrl, + null, nginx.ListenUrl, [nginx.ListenUrl], nginx.Version, "2.0"); + } case ProbeMode.NginxReverseHttp3Cleartext: { if (!System.Net.Quic.QuicListener.IsSupported) @@ -1270,6 +1896,132 @@ public static async Task StartAsync(ProbeMode mode, string? nginxPat return new ServeStack(origin, nginx, null, origin.HttpUrl, null, [], nginx.ListenUrl, null, nginx.ListenUrl, [nginx.ListenUrl], nginx.Version, "3.0"); } + case ProbeMode.NginxReverseHttp3ToHttpsHttp1: + { + if (!System.Net.Quic.QuicListener.IsSupported) + throw new PlatformNotSupportedException("QuicListener is not supported."); + + var origin = await OriginServer.StartAsync(new OriginListenOptions + { + EnableHttp = false, + EnableHttps = true, + HttpsProtocols = HttpProtocols.Http1, + ResponseBytes = responseBytes + }, cancellationToken, workload); + var nginx = await NginxHost.TryStartHttp3ToHttpsHttp1Async(origin.HttpsPort, nginxPath) + ?? throw new InvalidOperationException( + "nginx HTTP/3 is not available (need --with-http_v3_module)."); + return new ServeStack(origin, nginx, null, origin.HttpUrl, origin.HttpsUrl, [], nginx.ListenUrl, + null, nginx.ListenUrl, [nginx.ListenUrl], nginx.Version, "3.0"); + } + case ProbeMode.HaproxyReverseHttp2: + { + var origin = await OriginServer.StartAsync(false, responseBytes, cancellationToken, workload); + var haproxy = await HaproxyHost.TryStartHttp2Async(origin.HttpPort, haproxyPath) + ?? throw new InvalidOperationException("haproxy not available."); + return new ServeStack(origin, haproxy, null, origin.HttpUrl, null, [], haproxy.ListenUrl, + null, haproxy.ListenUrl, [haproxy.ListenUrl], null, "2.0", + haproxyVersion: haproxy.Version); + } + case ProbeMode.EnvoyReverseHttp2: + { + var origin = await OriginServer.StartAsync(false, responseBytes, cancellationToken, workload); + var envoy = await EnvoyHost.TryStartHttp2Async(origin.HttpPort, envoyPath) + ?? throw new InvalidOperationException("envoy not available."); + return new ServeStack(origin, envoy, null, origin.HttpUrl, null, [], envoy.ListenUrl, + null, envoy.ListenUrl, [envoy.ListenUrl], null, "2.0", envoyVersion: envoy.Version); + } + case ProbeMode.HaproxyReverseHttp2ToHttpsHttp1: + { + var origin = await OriginServer.StartAsync(new OriginListenOptions + { + EnableHttp = false, + EnableHttps = true, + HttpsProtocols = HttpProtocols.Http1, + ResponseBytes = responseBytes + }, cancellationToken, workload); + var haproxy = await HaproxyHost.TryStartHttp2ToHttpsHttp1Async(origin.HttpsPort, haproxyPath) + ?? throw new InvalidOperationException("haproxy not available."); + return new ServeStack(origin, haproxy, null, origin.HttpUrl, origin.HttpsUrl, [], + haproxy.ListenUrl, null, haproxy.ListenUrl, [haproxy.ListenUrl], null, "2.0", + haproxyVersion: haproxy.Version); + } + case ProbeMode.EnvoyReverseHttp2ToHttpsHttp1: + { + var origin = await OriginServer.StartAsync(new OriginListenOptions + { + EnableHttp = false, + EnableHttps = true, + HttpsProtocols = HttpProtocols.Http1, + ResponseBytes = responseBytes + }, cancellationToken, workload); + var envoy = await EnvoyHost.TryStartHttp2ToHttpsHttp1Async(origin.HttpsPort, envoyPath) + ?? throw new InvalidOperationException("envoy not available."); + return new ServeStack(origin, envoy, null, origin.HttpUrl, origin.HttpsUrl, [], + envoy.ListenUrl, null, envoy.ListenUrl, [envoy.ListenUrl], null, "2.0", + envoyVersion: envoy.Version); + } + case ProbeMode.HaproxyReverseHttp3Cleartext: + { + if (!System.Net.Quic.QuicListener.IsSupported) + throw new PlatformNotSupportedException("QuicListener is not supported."); + + var origin = await OriginServer.StartAsync(false, responseBytes, cancellationToken, workload); + var haproxy = await HaproxyHost.TryStartHttp3CleartextAsync(origin.HttpPort, haproxyPath) + ?? throw new InvalidOperationException( + "haproxy HTTP/3 is not available (need USE_QUIC)."); + return new ServeStack(origin, haproxy, null, origin.HttpUrl, null, [], haproxy.ListenUrl, + null, haproxy.ListenUrl, [haproxy.ListenUrl], null, "3.0", + haproxyVersion: haproxy.Version); + } + case ProbeMode.EnvoyReverseHttp3Cleartext: + { + if (!System.Net.Quic.QuicListener.IsSupported) + throw new PlatformNotSupportedException("QuicListener is not supported."); + + var origin = await OriginServer.StartAsync(false, responseBytes, cancellationToken, workload); + var envoy = await EnvoyHost.TryStartHttp3CleartextAsync(origin.HttpPort, envoyPath) + ?? throw new InvalidOperationException("envoy HTTP/3 is not available."); + return new ServeStack(origin, envoy, null, origin.HttpUrl, null, [], envoy.ListenUrl, + null, envoy.ListenUrl, [envoy.ListenUrl], null, "3.0", envoyVersion: envoy.Version); + } + case ProbeMode.HaproxyReverseHttp3ToHttpsHttp1: + { + if (!System.Net.Quic.QuicListener.IsSupported) + throw new PlatformNotSupportedException("QuicListener is not supported."); + + var origin = await OriginServer.StartAsync(new OriginListenOptions + { + EnableHttp = false, + EnableHttps = true, + HttpsProtocols = HttpProtocols.Http1, + ResponseBytes = responseBytes + }, cancellationToken, workload); + var haproxy = await HaproxyHost.TryStartHttp3ToHttpsHttp1Async(origin.HttpsPort, haproxyPath) + ?? throw new InvalidOperationException( + "haproxy HTTP/3 is not available (need USE_QUIC)."); + return new ServeStack(origin, haproxy, null, origin.HttpUrl, origin.HttpsUrl, [], + haproxy.ListenUrl, null, haproxy.ListenUrl, [haproxy.ListenUrl], null, "3.0", + haproxyVersion: haproxy.Version); + } + case ProbeMode.EnvoyReverseHttp3ToHttpsHttp1: + { + if (!System.Net.Quic.QuicListener.IsSupported) + throw new PlatformNotSupportedException("QuicListener is not supported."); + + var origin = await OriginServer.StartAsync(new OriginListenOptions + { + EnableHttp = false, + EnableHttps = true, + HttpsProtocols = HttpProtocols.Http1, + ResponseBytes = responseBytes + }, cancellationToken, workload); + var envoy = await EnvoyHost.TryStartHttp3ToHttpsHttp1Async(origin.HttpsPort, envoyPath) + ?? throw new InvalidOperationException("envoy HTTP/3 is not available."); + return new ServeStack(origin, envoy, null, origin.HttpUrl, origin.HttpsUrl, [], + envoy.ListenUrl, null, envoy.ListenUrl, [envoy.ListenUrl], null, "3.0", + envoyVersion: envoy.Version); + } case ProbeMode.ReverseHttp3: { if (!System.Net.Quic.QuicListener.IsSupported) diff --git a/tools/RpsLoadProbe/WorkloadOptions.cs b/tools/RpsLoadProbe/WorkloadOptions.cs index 1e0dca745..254da2858 100644 --- a/tools/RpsLoadProbe/WorkloadOptions.cs +++ b/tools/RpsLoadProbe/WorkloadOptions.cs @@ -23,6 +23,8 @@ internal sealed class WorkloadOptions public int EarlyResponseAfterBytes { get; init; } public bool IsDuplexHttp { get; init; } public bool IsWebSocket { get; init; } + /// Unary gRPC Echo over H2 TLS (compare-grpc arms). + public bool IsGrpc { get; init; } /// Optional extra request headers (e.g. Authorization Bearer for JWT edition arm). public IReadOnlyDictionary? ExtraHeaders { get; init; } @@ -118,6 +120,13 @@ internal sealed class WorkloadOptions IsWebSocket = true }; + public static WorkloadOptions ForGrpc() => new() + { + Method = "GET", + KeepAlive = true, + IsGrpc = true + }; + public WorkloadOptions WithCaptureTlsTiming(bool capture) => Copy(captureTlsTiming: capture); public WorkloadOptions WithExtraHeaders(IReadOnlyDictionary? headers) => @@ -136,6 +145,7 @@ public WorkloadOptions Copy( int? earlyResponseAfterBytes = null, bool? isDuplexHttp = null, bool? isWebSocket = null, + bool? isGrpc = null, IReadOnlyDictionary? extraHeaders = null, bool replaceExtraHeaders = false) => new() { @@ -151,6 +161,7 @@ public WorkloadOptions Copy( EarlyResponseAfterBytes = earlyResponseAfterBytes ?? EarlyResponseAfterBytes, IsDuplexHttp = isDuplexHttp ?? IsDuplexHttp, IsWebSocket = isWebSocket ?? IsWebSocket, + IsGrpc = isGrpc ?? IsGrpc, ExtraHeaders = replaceExtraHeaders ? extraHeaders : (extraHeaders ?? ExtraHeaders) }; @@ -171,18 +182,28 @@ public double ResolveP99SloMs(double http1, double http2, double http3, double h ProbeMode.HttpsMitm or ProbeMode.ReverseHttp1Mitm or ProbeMode.ExplicitHttp1Multi or ProbeMode.ExplicitHttp2Multi => httpsMitm, + _ when PeerWire.TryGet(mode, out var wire) && + wire.Inbound is PeerInboundProto.H2c or PeerInboundProto.H2Tls => + http2, + _ when PeerWire.TryGet(mode, out var h3Wire) && h3Wire.Inbound == PeerInboundProto.H3 => + http3, ProbeMode.ReverseHttp2 or ProbeMode.ReverseHttp2Cleartext or ProbeMode.ReverseHttp2ToH2c or ProbeMode.YarpReverseHttp2 or ProbeMode.YarpReverseHttp2ToH2c or ProbeMode.YarpReverseHttp2ToHttps or ProbeMode.YarpReverseHttp2ToHttpsHttp1 or ProbeMode.ReverseH2c or ProbeMode.ReverseH2cToH2c or ProbeMode.ReverseH2cToH1 or ProbeMode.ReverseH2cToHttps or ProbeMode.YarpReverseH2cToHttps or ProbeMode.YarpReverseH2c or ProbeMode.YarpReverseH2cToH2c or ProbeMode.YarpReverseH2cToH1 - or ProbeMode.NginxReverseHttp2 or ProbeMode.ReverseHttp2ToHttp3 or ProbeMode.YarpReverseHttp2ToHttp3 + or ProbeMode.NginxReverseHttp2 or ProbeMode.NginxReverseHttp2ToHttpsHttp1 + or ProbeMode.HaproxyReverseHttp2 or ProbeMode.HaproxyReverseHttp2ToHttpsHttp1 + or ProbeMode.EnvoyReverseHttp2 or ProbeMode.EnvoyReverseHttp2ToHttpsHttp1 + or ProbeMode.ReverseHttp2ToHttp3 or ProbeMode.YarpReverseHttp2ToHttp3 or ProbeMode.ReverseH2cToH3 or ProbeMode.YarpReverseH2cToH3 or ProbeMode.MitmHttp2ToHttp1 => http2, ProbeMode.ReverseHttp3 or ProbeMode.ReverseHttp3Cleartext or ProbeMode.YarpReverseHttp3Cleartext - or ProbeMode.NginxReverseHttp3Cleartext + or ProbeMode.NginxReverseHttp3Cleartext or ProbeMode.NginxReverseHttp3ToHttpsHttp1 + or ProbeMode.HaproxyReverseHttp3Cleartext or ProbeMode.HaproxyReverseHttp3ToHttpsHttp1 + or ProbeMode.EnvoyReverseHttp3Cleartext or ProbeMode.EnvoyReverseHttp3ToHttpsHttp1 or ProbeMode.ReverseHttp3ToHttp2 or ProbeMode.YarpReverseHttp3ToHttp2 or ProbeMode.ReverseHttp3ToH2c or ProbeMode.YarpReverseHttp3ToH2c or ProbeMode.YarpReverseHttp3ToHttp3 or ProbeMode.YarpReverseHttp3ToHttpsHttp1 @@ -209,6 +230,8 @@ public string Suffix suffix += "-duplex"; if (IsWebSocket) suffix += "-ws"; + if (IsGrpc) + suffix += "-grpc"; return suffix; } } diff --git a/tools/RpsLoadProbe/YarpProxyHost.cs b/tools/RpsLoadProbe/YarpProxyHost.cs index 6ebcfda0c..e065736b5 100644 --- a/tools/RpsLoadProbe/YarpProxyHost.cs +++ b/tools/RpsLoadProbe/YarpProxyHost.cs @@ -157,7 +157,7 @@ public static Task StartH2cToHttp3Async(int originQuicPort) => { UseTls = false, InboundProtocols = HttpProtocols.Http2, - DestinationAddress = $"https://127.0.0.1:{originQuicPort}/", + DestinationAddress = Http3OriginAddress(originQuicPort), OutboundVersion = HttpVersion.Version30, OutboundVersionPolicy = HttpVersionPolicy.RequestVersionExact, AcceptAnyServerCertificate = true @@ -192,7 +192,7 @@ public static Task StartHttp1ToHttp3Async(int originQuicPort) => { UseTls = true, InboundProtocols = HttpProtocols.Http1, - DestinationAddress = $"https://127.0.0.1:{originQuicPort}/", + DestinationAddress = Http3OriginAddress(originQuicPort), OutboundVersion = HttpVersion.Version30, OutboundVersionPolicy = HttpVersionPolicy.RequestVersionExact, AcceptAnyServerCertificate = true @@ -204,7 +204,7 @@ public static Task StartHttp2ToHttp3Async(int originQuicPort) => { UseTls = true, InboundProtocols = HttpProtocols.Http1AndHttp2, - DestinationAddress = $"https://127.0.0.1:{originQuicPort}/", + DestinationAddress = Http3OriginAddress(originQuicPort), OutboundVersion = HttpVersion.Version30, OutboundVersionPolicy = HttpVersionPolicy.RequestVersionExact, AcceptAnyServerCertificate = true @@ -273,7 +273,7 @@ public static Task StartHttp1PlainToHttp3Async(int originQuicPort { UseTls = false, InboundProtocols = HttpProtocols.Http1, - DestinationAddress = $"https://127.0.0.1:{originQuicPort}/", + DestinationAddress = Http3OriginAddress(originQuicPort), OutboundVersion = HttpVersion.Version30, OutboundVersionPolicy = HttpVersionPolicy.RequestVersionExact, AcceptAnyServerCertificate = true @@ -333,12 +333,19 @@ public static Task StartHttp3ToHttp3Async(int originQuicPort) => { UseTls = true, InboundProtocols = HttpProtocols.Http1AndHttp2AndHttp3, - DestinationAddress = $"https://127.0.0.1:{originQuicPort}/", + DestinationAddress = Http3OriginAddress(originQuicPort), OutboundVersion = HttpVersion.Version30, OutboundVersionPolicy = HttpVersionPolicy.RequestVersionExact, AcceptAnyServerCertificate = true }); + /// + /// Darwin/MsQuic HttpClient H3 to an IP literal often fails SNI (same class as inbound + /// ListenUrl using localhost). TWP H3 origin arms already use ForwardHost=localhost. + /// + private static string Http3OriginAddress(int originQuicPort) => + $"https://localhost:{originQuicPort}/"; + private sealed class YarpListenOptions { public bool UseTls { get; init; } diff --git a/tools/RpsLoadProbe/apply-wiki-paste.ps1 b/tools/RpsLoadProbe/apply-wiki-paste.ps1 index 6044a579a..869f3ffb9 100644 --- a/tools/RpsLoadProbe/apply-wiki-paste.ps1 +++ b/tools/RpsLoadProbe/apply-wiki-paste.ps1 @@ -52,33 +52,47 @@ $runUrl = "https://github.com/justcoding121/titanium-web-proxy/actions/runs/$Pri $productLine = "- Product refresh: ``compare-product`` @ ``$HeadSha`` $em [$PrimaryRunId]($runUrl). Heavier/saturation/tls:" $wiki = [regex]::Replace($wiki, '- Product refresh:.*', [System.Text.RegularExpressions.MatchEvaluator]{ $productLine }, 1) -$winRevHeader = "Median of **3 repeats** on ``windows-latest`` (4 vCPU / 16 GiB). Bare reverse 5${mul}5 @ ``$HeadSha`` $em ``compare-product`` [$PrimaryRunId]($runUrl). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Prefer TWP${div}peer ratios over absolute RPS. **RPS cells** include median RSS / CPU at the peak-RPS step as ``
                          (MiB / CPU%)``. nginx terminate peers use ``keepalive 256`` + streaming buffers. Laptop High-perf / cool-paired numbers stay on the [local lab](Performance-Local-Lab)." +$tinyGetNote = "Product 5${mul}5 is **~56-byte JSON keep-alive GET**; H2/H3 same-protocol cells are mostly header work with a tiny body (Titanium best case) $em see [Why this comparison is fair](#why-this-comparison-is-fair)." -$linRevHeader = "Median of **3 repeats** on ``ubuntu-latest`` (4 vCPU / 16 GiB). Bare reverse 5${mul}5 @ ``$HeadSha`` $em ``compare-product`` [$PrimaryRunId]($runUrl). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. **Linux nginx is the authoritative nginx baseline.** nginx terminate peers use ``keepalive 256`` + streaming buffers. The RPS workflow installs nginx.org mainline (``http_v3_module``) and ``libmsquic``. Prefer ratios over absolute RPS." +$winRevHeader = "Median of **3 repeats** on ``windows-latest`` (4 vCPU / 16 GiB). Bare reverse 5${mul}5 @ ``$HeadSha`` $em ``compare-product`` [$PrimaryRunId]($runUrl). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Prefer TWP${div}peer ratios over absolute RPS. **RPS cells** include median RSS / CPU at the peak-RPS step as ``
                          (MiB / CPU%)``. nginx terminate peers use ``keepalive 256`` + streaming buffers. **HAProxy / Envoy are Linux-only peers** (no official Windows port). Laptop High-perf / cool-paired numbers stay on the [local lab](Performance-Local-Lab). $tinyGetNote" -$macRevHeader = "Median of **3 repeats** on ``macos-15-intel`` (4-core / 14 GB). Bare reverse 5${mul}5 @ ``$HeadSha`` $em ``compare-product`` [$PrimaryRunId]($runUrl). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Prefer TWP${div}peer ratios over absolute RPS. **RPS cells** include median RSS / CPU at the peak-RPS step as ``
                          (MiB / CPU%)``. The RPS workflow installs Homebrew nginx (``http_v3_module``), Homebrew ``libmsquic`` (+ ``DYLD_*``), and YARP. Do not publish from ``macos-latest`` (3-core / 7 GB)." +$linRevHeader = "Median of **3 repeats** on ``ubuntu-latest`` (4 vCPU / 16 GiB). Bare reverse 5${mul}5 @ ``$HeadSha`` $em ``compare-product`` [$PrimaryRunId]($runUrl). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. **Linux nginx is the authoritative nginx baseline.** HAProxy (3.2 ``USE_QUIC``) and Envoy (GitHub release, HTTP/3 compiled in) run on the same loopback shape as nginx/YARP. nginx terminate peers use ``keepalive 256`` + streaming buffers. The RPS workflow installs nginx.org mainline (``http_v3_module``), a QUIC-enabled HAProxy, Envoy, and ``libmsquic``. Prefer ratios over absolute RPS. $tinyGetNote" + +$macRevHeader = "Median of **3 repeats** on ``macos-15-intel`` (4-core / 14 GB). Bare reverse 5${mul}5 @ ``$HeadSha`` $em ``compare-product`` [$PrimaryRunId]($runUrl). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Prefer TWP${div}peer ratios over absolute RPS. **RPS cells** include median RSS / CPU at the peak-RPS step as ``
                          (MiB / CPU%)``. The RPS workflow installs Homebrew nginx (``http_v3_module``), Homebrew HAProxy with ``USE_QUIC`` (3.2 source fallback), Envoy (Homebrew bottle or pinned darwin-amd64 1.36.7), Homebrew ``libmsquic`` (+ ``DYLD_*``), and YARP. Do not publish from ``macos-latest`` (3-core / 7 GB). $tinyGetNote" $mitmNote = @( - "Same Client${mul}Origin wires with interception on (``compare-product`` [$PrimaryRunId]($runUrl)). **Lite** = no-op handlers (unchanged-lite finish). **Full** = append-only header mutation (harness: one probe header each way; product: generic append-only relay via ``MitmCompressedRelayHelper``). nginx/YARP cannot MITM. **Lite${div}Reverse** / **Full${div}Reverse** vs bare reverse (same job). Completion gate: Lite and Full ${ge} **0.70${mul}** reverse sustain @ c=64 (median of 3 GHA runs)." + "Same Client${mul}Origin wires with interception on (``compare-product`` [$PrimaryRunId]($runUrl)). **Lite** = no-op handlers (unchanged-lite finish). **Full** = append-only header mutation (harness: one probe header each way; product: generic append-only relay via ``MitmCompressedRelayHelper``). nginx/HAProxy/Envoy/YARP cannot MITM. **Lite${div}Reverse** / **Full${div}Reverse** vs bare reverse (**same job / comparison-group shard**). Completion gate: Lite ${ge} **0.50${mul}** and Full ${ge} **0.50${mul}** reverse sustain @ c=64 (median of 3 GHA runs); reverse TWP${div}YARP ${ge} **0.70${mul}** (no terminate-peer gate)." "" "**v1 append-only relay (2026-08-27):** Pre-fix H2${rarr}H2 Full${div}Reverse was **0.13${endash}0.16${mul}** ([32960766249](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32960766249)). Post-fix @ ``df172718``: H2 plain${rarr}H2 plain Full **0.77${endash}0.79${mul}**, H3${rarr}H1 Full **0.91${endash}0.93${mul}**, all MITM arms ${ge} **0.70${mul}** on median of [33041445371](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33041445371), [33055267086](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33055267086), [33055272140](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33055272140)." "" "**v2 drop-only + non-unique append (2026-08-27):** ``MitmStaticRebuildHelper`` rebuilds static HPACK/QPACK after 1${endash}4 unique header drops; trailing non-unique appends stay on compressed relay. @ ``$HeadSha``: all MITM arms ${ge} **0.70${mul}** on GHA median ([33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622), [33105885748](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33105885748) Linux; [33087085235](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087085235), [33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622) Windows). H2 plain${rarr}H2 plain Full **0.77${endash}0.79${mul}** (Win) / **0.78${mul}** (Lin)." ) -join "`n" -$winHdr = "## Windows $em Titanium vs nginx vs YARP" -$linHdr = "## Linux $em Titanium vs nginx vs YARP" -$macHdr = "## macOS $em Titanium vs nginx vs YARP" +$winHdrNew = "## Windows $em Titanium vs nginx vs HAProxy vs Envoy vs YARP" +$linHdrNew = "## Linux $em Titanium vs nginx vs HAProxy vs Envoy vs YARP" +$macHdrNew = "## macOS $em Titanium vs nginx vs HAProxy vs Envoy vs YARP" +# Use \r?$ so CRLF wiki files still match (.$ alone can eat CR; bare $ before \n fails when \r remains). +$productHdrPattern = '(?m)^## (Windows|Linux|macOS) .+ Titanium vs nginx(?: vs HAProxy vs Envoy)? vs YARP\r?$' # After Linux product tables: macOS, then Editions / Heavier / Cross-version depending on wiki shape. $afterLinuxLookahead = '(?=\r?\n## (?:macOS|Editions|Heavier|Cross-version))' $afterMacLookahead = '(?=\r?\n## (?:Editions|Heavier|Cross-version))' +# Normalize product section headings (old 3-peer or new 5-peer titles). +$wiki = [regex]::Replace($wiki, $productHdrPattern, { + param($m) + switch ($m.Groups[1].Value) { + 'Windows' { $winHdrNew } + 'Linux' { $linHdrNew } + 'macOS' { $macHdrNew } + } +}) + # Allow optional intro lines between section heading and ### Reverse (Windows has a Client/Origin blurb). $wiki = [regex]::Replace($wiki, - "(?s)($([regex]::Escape($winHdr))\r?\n(?:.*?\r?\n)?### Reverse\r?\n\r?\n).*?(?=\r?\n### MITM)", + "(?ms)(^## Windows .+? Titanium vs nginx(?: vs HAProxy vs Envoy)? vs YARP\r?\n(?:.*?\r?\n)*?### Reverse\r?\n\r?\n).*?(?=\r?\n### MITM)", [System.Text.RegularExpressions.MatchEvaluator]{ param($m) - $loadGen = "**Load generators:** Reverse inbound H3 arms use **``dotnet-httpclient``** (``http_version=3.0``, ``RequestVersionExact``). nginx/Windows is same-OS only (no QUIC)." + $loadGen = "**Load generators:** Reverse inbound H3 arms use **``dotnet-httpclient``** (``http_version=3.0``, ``RequestVersionExact``). nginx/Windows is same-OS only (no QUIC). HAProxy/Envoy are Linux-only terminate peers." $m.Groups[1].Value + $winRevHeader + "`n`n" + $loadGen + "`n`n" + $winRev + "`n" }, 1) @@ -91,14 +105,15 @@ $wiki = [regex]::Replace($wiki, 1) $wiki = [regex]::Replace($wiki, - "(?s)($([regex]::Escape($linHdr))\r?\n(?:.*?\r?\n)?### Reverse\r?\n\r?\n).*?(?=\r?\n### MITM)", + "(?ms)(^## Linux .+? Titanium vs nginx(?: vs HAProxy vs Envoy)? vs YARP\r?\n(?:.*?\r?\n)*?### Reverse\r?\n\r?\n).*?(?=\r?\n### MITM)", [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $m.Groups[1].Value + $linRevHeader + "`n`n" + $linRev + "`n" }, 1) -$idx = $wiki.IndexOf($linHdr) -if ($idx -lt 0) { throw "Missing wiki heading: $linHdr" } +$linuxHdrMatch = [regex]::Match($wiki, '(?m)^## Linux .+ Titanium vs nginx(?: vs HAProxy vs Envoy)? vs YARP\r?$') +if (-not $linuxHdrMatch.Success) { throw "Missing wiki heading: Linux product section" } +$idx = $linuxHdrMatch.Index $head = $wiki.Substring(0, $idx) $tail = $wiki.Substring($idx) @@ -110,7 +125,7 @@ $tail = [regex]::Replace($tail, 1) $macBlock = @( - $macHdr + $macHdrNew '' '### Reverse' '' @@ -126,14 +141,17 @@ $macBlock = @( '' ) -join "`n" -if ($tail.Contains($macHdr)) { +$macHdrMatch = [regex]::Match($tail, '(?m)^## macOS .+ Titanium vs nginx(?: vs HAProxy vs Envoy)? vs YARP\r?$') +if ($macHdrMatch.Success) { $tail = [regex]::Replace($tail, - "(?s)($([regex]::Escape($macHdr))\r?\n(?:.*?\r?\n)?### Reverse\r?\n\r?\n).*?(?=\r?\n### MITM)", + "(?ms)(^## macOS .+? Titanium vs nginx(?: vs HAProxy vs Envoy)? vs YARP\r?\n(?:.*?\r?\n)*?### Reverse\r?\n\r?\n).*?(?=\r?\n### MITM)", [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $m.Groups[1].Value + $macRevHeader + "`n`n" + $macRev + "`n" }, 1) - $macIdx = $tail.IndexOf($macHdr) + $macHdrMatch2 = [regex]::Match($tail, '(?m)^## macOS .+ Titanium vs nginx(?: vs HAProxy vs Envoy)? vs YARP\r?$') + if (-not $macHdrMatch2.Success) { throw "Missing wiki heading: macOS product section after reverse paste" } + $macIdx = $macHdrMatch2.Index $macHead = $tail.Substring(0, $macIdx) $macTail = $tail.Substring($macIdx) $macTail = [regex]::Replace($macTail, diff --git a/tools/RpsLoadProbe/paste-compare-product-wiki.ps1 b/tools/RpsLoadProbe/paste-compare-product-wiki.ps1 index 590a4c6bc..76f9e97de 100644 --- a/tools/RpsLoadProbe/paste-compare-product-wiki.ps1 +++ b/tools/RpsLoadProbe/paste-compare-product-wiki.ps1 @@ -1,4 +1,6 @@ # Emit wiki markdown for compare-product Reverse + MITM tables (median of 3 GHA runs). +# Pass every product shard run id: -RunIds 111,222,333 (union by arm name; same SHA only). +# Lite÷Reverse stays same-job because comparison-group shards co-locate peers on one VM. param( [Parameter(Mandatory)] [string[]] $RunIds, [string] $ResultsRoot = 'tools/RpsLoadProbe/results/gha-dl', @@ -25,6 +27,7 @@ function Get-ArmMetrics([string]$CsvPath, [string]$Arm) { $rows = @(Import-Csv $CsvPath | Where-Object { $_.arm -eq $Arm }) if ($rows.Count -eq 0) { return $null } $sustains = @(); $peaks = @(); $rss = @(); $cpu = @() + $sawC64 = $false for ($i = 0; $i + $Steps -le $rows.Count; $i += $Steps) { $chunk = $rows[$i..($i + $Steps - 1)] $c64Ok = $chunk | Where-Object { $_.concurrency -eq '64' -and $_.meets_slo -eq '1' } | Select-Object -Last 1 @@ -33,20 +36,42 @@ function Get-ArmMetrics([string]$CsvPath, [string]$Arm) { $peaks += [double]$c64Ok.rps $rss += [double]$c64Ok.proxy_rss_peak_bytes $cpu += [double]$c64Ok.proxy_cpu_avg_pct + $sawC64 = $true continue } - # SLO miss (e.g. Linux nginx H3): still publish peak @ c=64 with sustain 0. + # SLO miss: do not mix 0 into the sustain median (noisy Mac peer-fix arms). $c64Any = $chunk | Where-Object { $_.concurrency -eq '64' } | Select-Object -Last 1 if ($c64Any) { - $sustains += 0 + $sawC64 = $true $peaks += [double]$c64Any.rps $rss += [double]$c64Any.proxy_rss_peak_bytes $cpu += [double]$c64Any.proxy_cpu_avg_pct } } - if ($peaks.Count -eq 0) { return $null } + if ($sustains.Count -gt 0) { + return @{ + Sustain = Median $sustains + Peak = Median $peaks + Rss = Median $rss + Cpu = Median $cpu + } + } + # Misaligned repeats (missing c=64 in a pass): use any SLO-pass c=64 rows. + $okAny = @($rows | Where-Object { $_.concurrency -eq '64' -and $_.meets_slo -eq '1' }) + if ($okAny.Count -gt 0) { + $s = @($okAny | ForEach-Object { [double]$_.rps }) + $r = @($okAny | ForEach-Object { [double]$_.proxy_rss_peak_bytes }) + $c = @($okAny | ForEach-Object { [double]$_.proxy_cpu_avg_pct }) + return @{ + Sustain = Median $s + Peak = Median $s + Rss = Median $r + Cpu = Median $c + } + } + if (-not $sawC64 -or $peaks.Count -eq 0) { return $null } return @{ - Sustain = Median $sustains + Sustain = 0 Peak = Median $peaks Rss = Median $rss Cpu = Median $cpu @@ -54,21 +79,29 @@ function Get-ArmMetrics([string]$CsvPath, [string]$Arm) { } function Get-MedianMetrics([string]$OsFolder, [string]$Arm) { - $s = @(); $p = @(); $r = @(); $c = @() + # Across run IDs: take the best sustain/peak so peer-fix overlays beat older 0-RPS + # product rows for the same arm (median of [0, 50k] would publish 0). + $best = $null foreach ($runId in $RunIds) { $dir = Join-Path $ResultsRoot $runId - $csv = Get-ChildItem "$dir/rps-csv-$OsFolder/*.csv", "$dir/$OsFolder/*.csv" -ErrorAction SilentlyContinue | + # Exact OS folder or shard-suffixed (rps-csv-ubuntu-latest-shard-1-3). + # Also accept flat artifact layouts (csv directly under runId). + $csv = Get-ChildItem ` + "$dir/rps-csv-$OsFolder/*.csv", ` + "$dir/rps-csv-$OsFolder-*/*.csv", ` + "$dir/$OsFolder/*.csv", ` + "$dir/*.csv" ` + -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $csv) { continue } $m = Get-ArmMetrics $csv.FullName $Arm - if ($m) { - $s += $m.Sustain; $p += $m.Peak; $r += $m.Rss; $c += $m.Cpu + if (-not $m) { continue } + if ($null -eq $best -or $m.Sustain -gt $best.Sustain -or + ($m.Sustain -eq $best.Sustain -and $m.Peak -gt $best.Peak)) { + $best = $m } } - if ($s.Count -eq 0) { return $null } - return @{ - Sustain = Median $s; Peak = Median $p; Rss = Median $r; Cpu = Median $c - } + return $best } function Format-RpsCell($metrics, [switch]$Medal, [switch]$Peak) { @@ -86,61 +119,92 @@ function Format-Impossible([string]$Reason = 'Not possible') { $wires = @( @{ C='HTTP/1 · plain'; O='HTTP/1 · plain'; Rev='twp-reverse-http1'; Yarp='yarp-reverse-http1'; Nginx='nginx-reverse-http1'; Lite='twp-mitm-http1'; Full='twp-mitm-full-http1' }, - @{ C='HTTP/1 · plain'; O='HTTP/1 · TLS'; Rev='twp-reverse-http1-to-https'; Yarp='yarp-reverse-http1-to-https'; Nginx=$null; Lite='twp-mitm-http1-to-https'; Full='twp-mitm-full-http1-to-https' }, - @{ C='HTTP/1 · plain'; O='HTTP/2 · plain'; Rev='twp-reverse-http1-plain-to-h2c'; Yarp='yarp-reverse-http1-plain-to-h2c'; Nginx=$null; Lite='twp-mitm-http1-plain-to-h2c'; Full='twp-mitm-full-http1-plain-to-h2c' }, - @{ C='HTTP/1 · plain'; O='HTTP/2 · TLS'; Rev='twp-reverse-http1-plain-to-http2'; Yarp='yarp-reverse-http1-plain-to-http2'; Nginx=$null; Lite='twp-mitm-http1-plain-to-http2'; Full='twp-mitm-full-http1-plain-to-http2' }, - @{ C='HTTP/1 · plain'; O='HTTP/3 · QUIC'; Rev='twp-reverse-http1-plain-to-http3'; Yarp='yarp-reverse-http1-plain-to-http3'; Nginx=$null; Lite='twp-mitm-http1-plain-to-http3'; Full='twp-mitm-full-http1-plain-to-http3' }, + @{ C='HTTP/1 · plain'; O='HTTP/1 · TLS'; Rev='twp-reverse-http1-to-https'; Yarp='yarp-reverse-http1-to-https'; Nginx='nginx-reverse-http1-to-https'; Lite='twp-mitm-http1-to-https'; Full='twp-mitm-full-http1-to-https' }, + @{ C='HTTP/1 · plain'; O='HTTP/2 · plain'; Rev='twp-reverse-http1-plain-to-h2c'; Yarp='yarp-reverse-http1-plain-to-h2c'; Nginx=$null; Haproxy='haproxy-reverse-http1-plain-to-h2c'; Envoy='envoy-reverse-http1-plain-to-h2c'; NginxImpossible='Not possible (no H2 upstream)'; Lite='twp-mitm-http1-plain-to-h2c'; Full='twp-mitm-full-http1-plain-to-h2c' }, + @{ C='HTTP/1 · plain'; O='HTTP/2 · TLS'; Rev='twp-reverse-http1-plain-to-http2'; Yarp='yarp-reverse-http1-plain-to-http2'; Nginx=$null; Haproxy='haproxy-reverse-http1-plain-to-http2'; Envoy='envoy-reverse-http1-plain-to-http2'; NginxImpossible='Not possible (no H2 upstream)'; Lite='twp-mitm-http1-plain-to-http2'; Full='twp-mitm-full-http1-plain-to-http2' }, + @{ C='HTTP/1 · plain'; O='HTTP/3 · QUIC'; Rev='twp-reverse-http1-plain-to-http3'; Yarp='yarp-reverse-http1-plain-to-http3'; Nginx=$null; Haproxy='haproxy-reverse-http1-plain-to-http3'; Envoy='envoy-reverse-http1-plain-to-http3'; NginxImpossible='Not possible (no H3 upstream)'; Lite='twp-mitm-http1-plain-to-http3'; Full='twp-mitm-full-http1-plain-to-http3' }, @{ C='HTTP/1 · TLS'; O='HTTP/1 · plain'; Rev='twp-reverse-http1-tls'; Yarp='yarp-reverse-http1-tls'; Nginx='nginx-reverse-http1-tls'; Lite='twp-mitm-http1-tls'; Full='twp-mitm-full-http1-tls' }, - @{ C='HTTP/1 · TLS'; O='HTTP/1 · TLS'; Rev='twp-reverse-http1-mitm'; Yarp='yarp-reverse-http1-tls-to-https'; Nginx=$null; Lite='twp-mitm-http1-tls-to-https'; Full='twp-mitm-full-http1-tls-to-https' }, - @{ C='HTTP/1 · TLS'; O='HTTP/2 · plain'; Rev='twp-reverse-http1-to-h2c'; Yarp='yarp-reverse-http1-to-h2c'; Nginx=$null; Lite='twp-mitm-http1-to-h2c'; Full='twp-mitm-full-http1-to-h2c' }, - @{ C='HTTP/1 · TLS'; O='HTTP/2 · TLS'; Rev='twp-reverse-http11-to-http2'; Yarp='yarp-reverse-http11-to-http2'; Nginx=$null; Lite='twp-mitm-http11-to-http2'; Full='twp-mitm-full-http11-to-http2' }, - @{ C='HTTP/1 · TLS'; O='HTTP/3 · QUIC'; Rev='twp-reverse-http1-to-http3'; Yarp='yarp-reverse-http1-to-http3'; Nginx=$null; Lite='twp-mitm-http1-to-http3'; Full='twp-mitm-full-http1-to-http3' }, - @{ C='HTTP/2 · plain'; O='HTTP/1 · plain'; Rev='twp-reverse-h2c-to-h1'; Yarp='yarp-reverse-h2c-to-h1'; Nginx=$null; Lite='twp-mitm-h2c-to-h1'; Full='twp-mitm-full-h2c-to-h1' }, - @{ C='HTTP/2 · plain'; O='HTTP/1 · TLS'; Rev='twp-reverse-h2c-to-https'; Yarp='yarp-reverse-h2c-to-https'; Nginx=$null; Lite='twp-mitm-h2c-to-https'; Full='twp-mitm-full-h2c-to-https' }, - @{ C='HTTP/2 · plain'; O='HTTP/2 · plain'; Rev='twp-reverse-h2c-to-h2c'; Yarp='yarp-reverse-h2c-to-h2c'; Nginx=$null; Lite='twp-mitm-h2c-to-h2c'; Full='twp-mitm-full-h2c-to-h2c' }, - @{ C='HTTP/2 · plain'; O='HTTP/2 · TLS'; Rev='twp-reverse-h2c'; Yarp='yarp-reverse-h2c'; Nginx=$null; Lite='twp-mitm-h2c'; Full='twp-mitm-full-h2c' }, - @{ C='HTTP/2 · plain'; O='HTTP/3 · QUIC'; Rev='twp-reverse-h2c-to-h3'; Yarp='yarp-reverse-h2c-to-h3'; Nginx=$null; Lite='twp-mitm-h2c-to-h3'; Full='twp-mitm-full-h2c-to-h3' }, + @{ C='HTTP/1 · TLS'; O='HTTP/1 · TLS'; Rev='twp-reverse-http1-mitm'; Yarp='yarp-reverse-http1-tls-to-https'; Nginx='nginx-reverse-http1-tls-to-https'; Lite='twp-mitm-http1-tls-to-https'; Full='twp-mitm-full-http1-tls-to-https' }, + @{ C='HTTP/1 · TLS'; O='HTTP/2 · plain'; Rev='twp-reverse-http1-to-h2c'; Yarp='yarp-reverse-http1-to-h2c'; Nginx=$null; Haproxy='haproxy-reverse-http1-to-h2c'; Envoy='envoy-reverse-http1-to-h2c'; NginxImpossible='Not possible (no H2 upstream)'; Lite='twp-mitm-http1-to-h2c'; Full='twp-mitm-full-http1-to-h2c' }, + @{ C='HTTP/1 · TLS'; O='HTTP/2 · TLS'; Rev='twp-reverse-http11-to-http2'; Yarp='yarp-reverse-http11-to-http2'; Nginx=$null; Haproxy='haproxy-reverse-http11-to-http2'; Envoy='envoy-reverse-http11-to-http2'; NginxImpossible='Not possible (no H2 upstream)'; Lite='twp-mitm-http11-to-http2'; Full='twp-mitm-full-http11-to-http2' }, + @{ C='HTTP/1 · TLS'; O='HTTP/3 · QUIC'; Rev='twp-reverse-http1-to-http3'; Yarp='yarp-reverse-http1-to-http3'; Nginx=$null; Haproxy='haproxy-reverse-http1-to-http3'; Envoy='envoy-reverse-http1-to-http3'; NginxImpossible='Not possible (no H3 upstream)'; Lite='twp-mitm-http1-to-http3'; Full='twp-mitm-full-http1-to-http3' }, + @{ C='HTTP/2 · plain'; O='HTTP/1 · plain'; Rev='twp-reverse-h2c-to-h1'; Yarp='yarp-reverse-h2c-to-h1'; Nginx='nginx-reverse-h2c-to-h1'; Lite='twp-mitm-h2c-to-h1'; Full='twp-mitm-full-h2c-to-h1' }, + @{ C='HTTP/2 · plain'; O='HTTP/1 · TLS'; Rev='twp-reverse-h2c-to-https'; Yarp='yarp-reverse-h2c-to-https'; Nginx='nginx-reverse-h2c-to-https'; Lite='twp-mitm-h2c-to-https'; Full='twp-mitm-full-h2c-to-https' }, + @{ C='HTTP/2 · plain'; O='HTTP/2 · plain'; Rev='twp-reverse-h2c-to-h2c'; Yarp='yarp-reverse-h2c-to-h2c'; Nginx=$null; Haproxy='haproxy-reverse-h2c-to-h2c'; Envoy='envoy-reverse-h2c-to-h2c'; NginxImpossible='Not possible (no H2 upstream)'; Lite='twp-mitm-h2c-to-h2c'; Full='twp-mitm-full-h2c-to-h2c' }, + @{ C='HTTP/2 · plain'; O='HTTP/2 · TLS'; Rev='twp-reverse-h2c'; Yarp='yarp-reverse-h2c'; Nginx=$null; Haproxy='haproxy-reverse-h2c'; Envoy='envoy-reverse-h2c'; NginxImpossible='Not possible (no H2 upstream)'; Lite='twp-mitm-h2c'; Full='twp-mitm-full-h2c' }, + @{ C='HTTP/2 · plain'; O='HTTP/3 · QUIC'; Rev='twp-reverse-h2c-to-h3'; Yarp='yarp-reverse-h2c-to-h3'; Nginx=$null; Haproxy='haproxy-reverse-h2c-to-h3'; Envoy='envoy-reverse-h2c-to-h3'; NginxImpossible='Not possible (no H3 upstream)'; Lite='twp-mitm-h2c-to-h3'; Full='twp-mitm-full-h2c-to-h3' }, @{ C='HTTP/2 · TLS'; O='HTTP/1 · plain'; Rev='twp-reverse-http2-cleartext'; Yarp='yarp-reverse-http2'; Nginx='nginx-reverse-http2'; Lite='twp-mitm-http2-cleartext'; Full='twp-mitm-full-http2-cleartext' }, - @{ C='HTTP/2 · TLS'; O='HTTP/1 · TLS'; Rev='twp-reverse-http2-to-https-http1'; Yarp='yarp-reverse-http2-to-https-http1'; Nginx=$null; Lite='twp-mitm-http2-to-http1'; Full='twp-mitm-full-http2-to-http1' }, - @{ C='HTTP/2 · TLS'; O='HTTP/2 · plain'; Rev='twp-reverse-http2-to-h2c'; Yarp='yarp-reverse-http2-to-h2c'; Nginx=$null; Lite='twp-mitm-http2-to-h2c'; Full='twp-mitm-full-http2-to-h2c' }, - @{ C='HTTP/2 · TLS'; O='HTTP/2 · TLS'; Rev='twp-reverse-http2'; Yarp='yarp-reverse-http2-to-https'; Nginx=$null; Lite='twp-mitm-http2'; Full='twp-mitm-full-http2' }, - @{ C='HTTP/2 · TLS'; O='HTTP/3 · QUIC'; Rev='twp-reverse-http2-to-http3'; Yarp='yarp-reverse-http2-to-http3'; Nginx=$null; Lite='twp-mitm-http2-to-http3'; Full='twp-mitm-full-http2-to-http3' }, + @{ C='HTTP/2 · TLS'; O='HTTP/1 · TLS'; Rev='twp-reverse-http2-to-https-http1'; Yarp='yarp-reverse-http2-to-https-http1'; Nginx='nginx-reverse-http2-to-https-http1'; Lite='twp-mitm-http2-to-http1'; Full='twp-mitm-full-http2-to-http1' }, + @{ C='HTTP/2 · TLS'; O='HTTP/2 · plain'; Rev='twp-reverse-http2-to-h2c'; Yarp='yarp-reverse-http2-to-h2c'; Nginx=$null; Haproxy='haproxy-reverse-http2-to-h2c'; Envoy='envoy-reverse-http2-to-h2c'; NginxImpossible='Not possible (no H2 upstream)'; Lite='twp-mitm-http2-to-h2c'; Full='twp-mitm-full-http2-to-h2c' }, + @{ C='HTTP/2 · TLS'; O='HTTP/2 · TLS'; Rev='twp-reverse-http2'; Yarp='yarp-reverse-http2-to-https'; Nginx=$null; Haproxy='haproxy-reverse-http2-to-https'; Envoy='envoy-reverse-http2-to-https'; NginxImpossible='Not possible (no H2 upstream)'; Lite='twp-mitm-http2'; Full='twp-mitm-full-http2' }, + @{ C='HTTP/2 · TLS'; O='HTTP/3 · QUIC'; Rev='twp-reverse-http2-to-http3'; Yarp='yarp-reverse-http2-to-http3'; Nginx=$null; Haproxy='haproxy-reverse-http2-to-http3'; Envoy='envoy-reverse-http2-to-http3'; NginxImpossible='Not possible (no H3 upstream)'; Lite='twp-mitm-http2-to-http3'; Full='twp-mitm-full-http2-to-http3' }, @{ C='HTTP/3 · QUIC'; O='HTTP/1 · plain'; Rev='twp-reverse-http3-cleartext'; Yarp='yarp-reverse-http3-cleartext'; Nginx='nginx-reverse-http3-cleartext'; Lite='twp-mitm-http3-cleartext'; Full='twp-mitm-full-http3-cleartext' }, - @{ C='HTTP/3 · QUIC'; O='HTTP/1 · TLS'; Rev='twp-reverse-http3-to-https-http1'; Yarp='yarp-reverse-http3-to-https-http1'; Nginx=$null; Lite='twp-mitm-http3-to-http1'; Full='twp-mitm-full-http3-to-http1' }, - @{ C='HTTP/3 · QUIC'; O='HTTP/2 · plain'; Rev='twp-reverse-http3-to-h2c'; Yarp='yarp-reverse-http3-to-h2c'; Nginx=$null; Lite='twp-mitm-http3-to-h2c'; Full='twp-mitm-full-http3-to-h2c' }, - @{ C='HTTP/3 · QUIC'; O='HTTP/2 · TLS'; Rev='twp-reverse-http3-to-http2'; Yarp='yarp-reverse-http3-to-http2'; Nginx=$null; Lite='twp-mitm-http3-to-http2'; Full='twp-mitm-full-http3-to-http2' }, - @{ C='HTTP/3 · QUIC'; O='HTTP/3 · QUIC'; Rev='twp-reverse-http3'; Yarp='yarp-reverse-http3-to-http3'; Nginx=$null; Lite='twp-mitm-http3'; Full='twp-mitm-full-http3' } + @{ C='HTTP/3 · QUIC'; O='HTTP/1 · TLS'; Rev='twp-reverse-http3-to-https-http1'; Yarp='yarp-reverse-http3-to-https-http1'; Nginx='nginx-reverse-http3-to-https-http1'; Lite='twp-mitm-http3-to-http1'; Full='twp-mitm-full-http3-to-http1' }, + @{ C='HTTP/3 · QUIC'; O='HTTP/2 · plain'; Rev='twp-reverse-http3-to-h2c'; Yarp='yarp-reverse-http3-to-h2c'; Nginx=$null; Haproxy='haproxy-reverse-http3-to-h2c'; Envoy='envoy-reverse-http3-to-h2c'; NginxImpossible='Not possible (no H2 upstream)'; Lite='twp-mitm-http3-to-h2c'; Full='twp-mitm-full-http3-to-h2c' }, + @{ C='HTTP/3 · QUIC'; O='HTTP/2 · TLS'; Rev='twp-reverse-http3-to-http2'; Yarp='yarp-reverse-http3-to-http2'; Nginx=$null; Haproxy='haproxy-reverse-http3-to-http2'; Envoy='envoy-reverse-http3-to-http2'; NginxImpossible='Not possible (no H2 upstream)'; Lite='twp-mitm-http3-to-http2'; Full='twp-mitm-full-http3-to-http2' }, + @{ C='HTTP/3 · QUIC'; O='HTTP/3 · QUIC'; Rev='twp-reverse-http3'; Yarp='yarp-reverse-http3-to-http3'; Nginx=$null; Haproxy='haproxy-reverse-http3-to-http3'; Envoy='envoy-reverse-http3-to-http3'; NginxImpossible='Not possible (no H3 upstream)'; Lite='twp-mitm-http3'; Full='twp-mitm-full-http3' } ) +foreach ($w in $wires) { + # Derive HAProxy/Envoy from nginx when the terminate-to-H1 arm already exists. + # Product-possible / harness-absent cells set Haproxy/Envoy explicitly (see + # product-arm-matrix.py). Do not copy nginx=$null onto those two — that used + # to label H3→H2 as *Not possible (no H3 to H2)* for HAProxy/Envoy. + if ($w.Nginx) { + if (-not $w.Haproxy) { $w.Haproxy = $w.Nginx -replace '^nginx-', 'haproxy-' } + if (-not $w.Envoy) { $w.Envoy = $w.Nginx -replace '^nginx-', 'envoy-' } + } +} + +function Get-PeerImpossibleReason([hashtable]$w, [string]$PeerKey, [string]$Arm) { + if ($Arm) { return $null } + if ($PeerKey -eq 'Nginx' -and $w.NginxImpossible) { return $w.NginxImpossible } + if ($w.O -match 'QUIC') { return 'Not possible (no H3 upstream)' } + if ($w.O -match 'HTTP/2') { return 'Not possible (no H2 upstream)' } + return 'Not possible' +} + +function Format-TerminatePeerCell( + [string]$OsFolder, + [hashtable]$w, + [string]$PeerKey, + $metrics, + [switch]$Medal, + [switch]$Peak +) { + if ($PeerKey -in @('Haproxy', 'Envoy') -and $OsFolder -eq 'windows-latest') { + return Format-Impossible 'Not possible' + } + $arm = $w[$PeerKey] + if ($arm) { + return Format-RpsCell $metrics -Medal:$Medal -Peak:$Peak + } + $reason = Get-PeerImpossibleReason $w $PeerKey $arm + return Format-Impossible $reason +} + function Emit-ReverseTable([string]$OsFolder) { - Write-Output '| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak |' - Write-Output '|---|---|---:|---:|---:|---:|---:|---:|' + Write-Output '| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | HAProxy sustain | HAProxy peak | Envoy sustain | Envoy peak | YARP sustain | YARP peak |' + Write-Output '|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|' foreach ($w in $wires) { $twp = Get-MedianMetrics $OsFolder $w.Rev $yarp = Get-MedianMetrics $OsFolder $w.Yarp $nginx = if ($w.Nginx) { Get-MedianMetrics $OsFolder $w.Nginx } else { $null } + $haproxy = if ($w.Haproxy) { Get-MedianMetrics $OsFolder $w.Haproxy } else { $null } + $envoy = if ($w.Envoy) { Get-MedianMetrics $OsFolder $w.Envoy } else { $null } $candidates = @(@{ M = $twp; K = 'twp' }, @{ M = $yarp; K = 'yarp' }) - if ($nginx -and $nginx.Sustain -gt 0) { $candidates += @{ M = $nginx; K = 'nginx' } } - $best = ($candidates | Where-Object { $_.M } | Sort-Object { $_.M.Sustain } -Descending | Select-Object -First 1).K - if ($nginx) { - $nS = Format-RpsCell $nginx -Medal:($best -eq 'nginx') - $nP = Format-RpsCell $nginx -Medal:($best -eq 'nginx') -Peak - } - elseif ($w.Nginx -eq 'nginx-reverse-http3-cleartext' -or ($w.O -match 'QUIC' -and -not $w.Nginx)) { - $nS = Format-Impossible 'Not possible (no QUIC)' - $nP = $nS - } - elseif ($w.C -match 'HTTP/3' -and $w.O -match 'HTTP/2') { - $nS = Format-Impossible 'Not possible (no H3 to H2)' - $nP = $nS + foreach ($pair in @(@{ M = $nginx; K = 'nginx' }, @{ M = $haproxy; K = 'haproxy' }, @{ M = $envoy; K = 'envoy' })) { + if ($pair.M -and $pair.M.Sustain -gt 0) { $candidates += $pair } } - else { - $nS = Format-Impossible - $nP = $nS - } - Write-Output ("| {0} | {1} | {2} | {3} | {4} | {5} | {6} | {7} |" -f $w.C, $w.O, + $best = ($candidates | Where-Object { $_.M } | Sort-Object { $_.M.Sustain } -Descending | Select-Object -First 1).K + Write-Output ("| {0} | {1} | {2} | {3} | {4} | {5} | {6} | {7} | {8} | {9} | {10} | {11} |" -f $w.C, $w.O, (Format-RpsCell $twp -Medal:($best -eq 'twp')), (Format-RpsCell $twp -Medal:($best -eq 'twp') -Peak), - $nS, $nP, + (Format-TerminatePeerCell $OsFolder $w 'Nginx' $nginx -Medal:($best -eq 'nginx')), + (Format-TerminatePeerCell $OsFolder $w 'Nginx' $nginx -Medal:($best -eq 'nginx') -Peak), + (Format-TerminatePeerCell $OsFolder $w 'Haproxy' $haproxy -Medal:($best -eq 'haproxy')), + (Format-TerminatePeerCell $OsFolder $w 'Haproxy' $haproxy -Medal:($best -eq 'haproxy') -Peak), + (Format-TerminatePeerCell $OsFolder $w 'Envoy' $envoy -Medal:($best -eq 'envoy')), + (Format-TerminatePeerCell $OsFolder $w 'Envoy' $envoy -Medal:($best -eq 'envoy') -Peak), (Format-RpsCell $yarp -Medal:($best -eq 'yarp')), (Format-RpsCell $yarp -Medal:($best -eq 'yarp') -Peak)) } } diff --git a/tools/RpsLoadProbe/paste-heavier-wiki.py b/tools/RpsLoadProbe/paste-heavier-wiki.py new file mode 100644 index 000000000..0393f1ca5 --- /dev/null +++ b/tools/RpsLoadProbe/paste-heavier-wiki.py @@ -0,0 +1,620 @@ +#!/usr/bin/env python3 +"""Paste heavier + saturation tables into wiki/Performance.md from gha-dl CSVs.""" +from __future__ import annotations + +import argparse +import csv +import re +from pathlib import Path +from typing import Dict, List, Optional, Tuple, Union + +RunIds = Union[int, List[int]] + +ROOT = Path("tools/RpsLoadProbe/results/gha-dl") +WIKI = Path("wiki/Performance.md") +HEAD = "9a2b3a1e" +RUNS = { + # Win+Linux (and shards) from the 2026-09-10 wiki-grade GHA batch. + "saturation": [34441539402, 34441541578], + "bodies": [34441570199, 34441572485, 34441574457, 34441576323], + "post": [34441591377, 34441593359], + "lossy": [34441595456, 34441597540], + "tls": [34441599658, 34441602032], + "arch": [34441578556, 34441580725, 34441583238, 34441585221, 34441587413, 34441589415], +} +MEDAL = "\U0001F947" +STEPS = 4 + + +def median(vals: List[float]) -> Optional[float]: + if not vals: + return None + s = sorted(vals) + return s[(len(s) - 1) // 2] + + +def arm_metrics(csv_path: Path, arm: str) -> Optional[dict]: + rows = [r for r in csv.DictReader(csv_path.open(newline="")) if r.get("arm") == arm] + if not rows: + return None + sustains: List[float] = [] + peaks: List[float] = [] + rss: List[float] = [] + cpu: List[float] = [] + i = 0 + while i + STEPS <= len(rows): + chunk = rows[i : i + STEPS] + c64_ok = [r for r in chunk if r.get("concurrency") == "64" and r.get("meets_slo") == "1"] + if c64_ok: + r = c64_ok[-1] + sustains.append(float(r["rps"])) + peaks.append(float(r["rps"])) + rss.append(float(r.get("proxy_rss_peak_bytes") or 0)) + cpu.append(float(r.get("proxy_cpu_avg_pct") or 0)) + else: + c64_any = [r for r in chunk if r.get("concurrency") == "64"] + if c64_any: + r = c64_any[-1] + sustains.append(0.0) + peaks.append(float(r["rps"])) + rss.append(float(r.get("proxy_rss_peak_bytes") or 0)) + cpu.append(float(r.get("proxy_cpu_avg_pct") or 0)) + i += STEPS + if not peaks: + c64 = [r for r in rows if r.get("concurrency") == "64"] + if not c64: + return None + r = c64[-1] + ok = r.get("meets_slo") == "1" + return { + "Sustain": float(r["rps"]) if ok else 0.0, + "Peak": float(r["rps"]), + "Rss": float(r.get("proxy_rss_peak_bytes") or 0), + "Cpu": float(r.get("proxy_cpu_avg_pct") or 0), + } + return { + "Sustain": median(sustains), + "Peak": median(peaks), + "Rss": median(rss), + "Cpu": median(cpu), + } + + +def _run_id_list(run_ids: RunIds) -> List[int]: + return [run_ids] if isinstance(run_ids, int) else list(run_ids) + + +def _csv_files_for_os(run_dir: Path, os_folder: str) -> List[Path]: + """Exact rps-csv- or shard-suffixed rps-csv--shard-* folders.""" + exact = run_dir / f"rps-csv-{os_folder}" + files = sorted(exact.glob("*.csv")) if exact.is_dir() else [] + if files: + return files + for d in sorted(run_dir.glob(f"rps-csv-{os_folder}-*")): + files.extend(sorted(d.glob("*.csv"))) + return files + + +def load_os(run_ids: RunIds, os_folder: str) -> Dict[str, dict]: + """Union arm metrics across shard run ids; first non-empty wins per arm.""" + merged: Dict[str, dict] = {} + for rid in _run_id_list(run_ids): + files = _csv_files_for_os(ROOT / str(rid), os_folder) + if not files: + continue + arms = {r["arm"] for r in csv.DictReader(files[0].open(newline=""))} + for a in arms: + if a in merged: + continue + m = arm_metrics(files[0], a) + if m is not None: + merged[a] = m + return merged + + +def primary_run_id(run_ids: RunIds) -> int: + return _run_id_list(run_ids)[0] + + +def parse_run_ids(text: str) -> RunIds: + parts = [p.strip() for p in text.split(",") if p.strip()] + ids = [int(p) for p in parts] + return ids[0] if len(ids) == 1 else ids + + +def fmt_cell(m: Optional[dict], medal: bool = False, peak: bool = False, impossible: Optional[str] = None) -> str: + if impossible: + return f"*{impossible}*" + if not m: + return "*Not measured*" + r = round(m["Peak"] if peak else m["Sustain"]) + mb = round(m["Rss"] / (1024 * 1024)) + cpu = round(m["Cpu"], 1) + prefix = f"{MEDAL} " if medal else "" + return f"{prefix}**{r:,}**
                          ({mb} MiB / {cpu}% CPU)" + + +def pick_medal(cands: List[Tuple[str, Optional[dict]]]) -> Optional[str]: + valid = [(k, m) for k, m in cands if m and (m["Sustain"] or 0) > 0] + if not valid: + return None + return min(valid, key=lambda km: (-km[1]["Sustain"], km[1]["Rss"], km[1]["Cpu"]))[0] + + +PEER_COLS = ( + "TWP sustain | TWP peak | nginx sustain | nginx peak | " + "HAProxy sustain | HAProxy peak | Envoy sustain | Envoy peak | YARP sustain | YARP peak" +) +PEER_RULE = "---:|---:|---:|---:|---:|---:|---:|---:|---:|---:" + + +def _peer_impossible(arm: Optional[str], nginx_a: Optional[str], win_no_quic: bool) -> Optional[str]: + if arm is not None: + if win_no_quic and nginx_a and "http3" in nginx_a: + return "Not possible (no QUIC)" + return None + return "Not possible" + + +def peer_row( + prefix: List[str], + twp_a: Optional[str], + nginx_a: Optional[str], + yarp_a: Optional[str], + data: dict, + win_no_quic: bool = False, + win_no_haproxy_envoy: bool = False, + haproxy_a: Optional[str] = None, + envoy_a: Optional[str] = None, +) -> str: + twp = data.get(twp_a) if twp_a else None + nginx = data.get(nginx_a) if nginx_a else None + if haproxy_a is None: + haproxy_a = nginx_a.replace("nginx-", "haproxy-", 1) if nginx_a else None + if envoy_a is None: + envoy_a = nginx_a.replace("nginx-", "envoy-", 1) if nginx_a else None + haproxy = data.get(haproxy_a) if haproxy_a else None + envoy = data.get(envoy_a) if envoy_a else None + yarp = data.get(yarp_a) if yarp_a else None + nginx_imp = _peer_impossible(nginx_a, nginx_a, win_no_quic) + haproxy_imp = "Not possible" if win_no_haproxy_envoy else _peer_impossible(haproxy_a, nginx_a, win_no_quic) + envoy_imp = "Not possible" if win_no_haproxy_envoy else _peer_impossible(envoy_a, nginx_a, win_no_quic) + if win_no_quic and nginx_a and "http3" in nginx_a: + nginx = None + nginx_imp = "Not possible (no QUIC)" + if win_no_haproxy_envoy: + haproxy = None + envoy = None + medal = pick_medal( + [("twp", twp), ("nginx", nginx), ("haproxy", haproxy), ("envoy", envoy), ("yarp", yarp)] + ) + cells = prefix + [ + fmt_cell(twp, medal=(medal == "twp")), + fmt_cell(twp, peak=True), + fmt_cell(nginx, medal=(medal == "nginx"), impossible=nginx_imp), + fmt_cell(nginx, peak=True, impossible=nginx_imp), + fmt_cell(haproxy, medal=(medal == "haproxy"), impossible=haproxy_imp), + fmt_cell(haproxy, peak=True, impossible=haproxy_imp), + fmt_cell(envoy, medal=(medal == "envoy"), impossible=envoy_imp), + fmt_cell(envoy, peak=True, impossible=envoy_imp), + fmt_cell(yarp, medal=(medal == "yarp")), + fmt_cell(yarp, peak=True), + ] + return "| " + " | ".join(cells) + " |" + + +def run_url(rid: int) -> str: + return f"https://github.com/justcoding121/titanium-web-proxy/actions/runs/{rid}" + + +def replace_table_at(text: str, start: int, new_table: str) -> str: + j = text.find("|", start) + if j < 0: + raise SystemExit(f"no table at {start}") + lines = text[j:].splitlines(keepends=True) + n = 0 + for line in lines: + if line.startswith("|"): + n += 1 + else: + break + end = j + sum(len(lines[i]) for i in range(n)) + return text[:j] + new_table + "\n" + text[end:] + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + for mode in RUNS: + ap.add_argument(f"--{mode}", help="Run id or comma-separated shard ids") + args = ap.parse_args() + + runs: Dict[str, RunIds] = dict(RUNS) + for mode in RUNS: + override = getattr(args, mode, None) + if override: + runs[mode] = parse_run_ids(override) + + win = {k: load_os(rid, "windows-latest") for k, rid in runs.items()} + lin = {k: load_os(rid, "ubuntu-latest") for k, rid in runs.items()} + text = WIKI.read_text(encoding="utf-8") + rid_b = primary_run_id(runs["bodies"]) + rid_p = primary_run_id(runs["post"]) + rid_l = primary_run_id(runs["lossy"]) + rid_a = primary_run_id(runs["arch"]) + rid_t = primary_run_id(runs["tls"]) + rid_s = primary_run_id(runs["saturation"]) + + body_spec = [ + ("64 KiB", "HTTP/1 · TLS", "HTTP/1 · plain", "twp-reverse-http1-tls-body64k", "nginx-reverse-http1-tls-body64k", "yarp-reverse-http1-tls-body64k"), + ("64 KiB", "HTTP/2 · TLS", "HTTP/1 · plain", "twp-reverse-http2-cleartext-body64k", "nginx-reverse-http2-body64k", "yarp-reverse-http2-body64k"), + ("64 KiB", "HTTP/3 · QUIC", "HTTP/1 · plain", "twp-reverse-http3-cleartext-body64k", "nginx-reverse-http3-cleartext-body64k", "yarp-reverse-http3-cleartext-body64k"), + ("256 KiB", "HTTP/1 · TLS", "HTTP/1 · plain", "twp-reverse-http1-tls-body256k", "nginx-reverse-http1-tls-body256k", "yarp-reverse-http1-tls-body256k"), + ("256 KiB", "HTTP/2 · TLS", "HTTP/1 · plain", "twp-reverse-http2-cleartext-body256k", "nginx-reverse-http2-body256k", "yarp-reverse-http2-body256k"), + ("256 KiB", "HTTP/3 · QUIC", "HTTP/1 · plain", "twp-reverse-http3-cleartext-body256k", "nginx-reverse-http3-cleartext-body256k", "yarp-reverse-http3-cleartext-body256k"), + ("64 KiB", "HTTP/2 · plain", "HTTP/1 · plain", "twp-reverse-h2c-to-h1-body64k", "nginx-reverse-h2c-to-h1-body64k", "yarp-reverse-h2c-to-h1-body64k"), + ("64 KiB", "HTTP/2 · TLS", "HTTP/2 · plain", "twp-reverse-http2-to-h2c-body64k", None, "yarp-reverse-http2-to-h2c-body64k"), + ("64 KiB", "HTTP/2 · TLS", "HTTP/2 · TLS", "twp-reverse-http2-to-https-body64k", None, "yarp-reverse-http2-to-https-body64k"), + ("64 KiB", "HTTP/3 · QUIC", "HTTP/2 · TLS", "twp-reverse-http3-to-http2-body64k", None, "yarp-reverse-http3-to-http2-body64k"), + ("64 KiB", "HTTP/3 · QUIC", "HTTP/1 · TLS", "twp-reverse-http3-to-https-http1-body64k", "nginx-reverse-http3-to-https-http1-body64k", "yarp-reverse-http3-to-https-http1-body64k"), + ("256 KiB", "HTTP/2 · plain", "HTTP/1 · plain", "twp-reverse-h2c-to-h1-body256k", "nginx-reverse-h2c-to-h1-body256k", "yarp-reverse-h2c-to-h1-body256k"), + ("256 KiB", "HTTP/2 · TLS", "HTTP/2 · plain", "twp-reverse-http2-to-h2c-body256k", None, "yarp-reverse-http2-to-h2c-body256k"), + ("256 KiB", "HTTP/2 · TLS", "HTTP/2 · TLS", "twp-reverse-http2-to-https-body256k", None, "yarp-reverse-http2-to-https-body256k"), + ("256 KiB", "HTTP/3 · QUIC", "HTTP/2 · TLS", "twp-reverse-http3-to-http2-body256k", None, "yarp-reverse-http3-to-http2-body256k"), + ("256 KiB", "HTTP/3 · QUIC", "HTTP/1 · TLS", "twp-reverse-http3-to-https-http1-body256k", "nginx-reverse-http3-to-https-http1-body256k", "yarp-reverse-http3-to-https-http1-body256k"), + ] + + def bodies_table(data: dict, is_win: bool) -> str: + rows = [ + f"| Body | Client | Origin | {PEER_COLS} |", + f"|---|---|---|{PEER_RULE}|", + ] + for body, c, o, t, n, y in body_spec: + extra = {} + if n is None: + stem = t.replace("twp-reverse-", "", 1) + extra = {"haproxy_a": f"haproxy-reverse-{stem}", "envoy_a": f"envoy-reverse-{stem}"} + rows.append( + peer_row([body, c, o], t, n, y, data, win_no_quic=is_win, win_no_haproxy_envoy=is_win, **extra) + ) + return "\n".join(rows) + + post_spec = [ + ("HTTP/1 · TLS", "HTTP/1 · plain", "twp-reverse-http1-tls-post64k", "nginx-reverse-http1-tls-post64k", "yarp-reverse-http1-tls-post64k"), + ("HTTP/2 · TLS", "HTTP/1 · plain", "twp-reverse-http2-cleartext-post64k", "nginx-reverse-http2-post64k", "yarp-reverse-http2-post64k"), + ("HTTP/3 · QUIC", "HTTP/1 · plain", "twp-reverse-http3-cleartext-post64k", "nginx-reverse-http3-cleartext-post64k", "yarp-reverse-http3-cleartext-post64k"), + ("HTTP/2 · plain", "HTTP/1 · plain", "twp-reverse-h2c-to-h1-post64k", "nginx-reverse-h2c-to-h1-post64k", "yarp-reverse-h2c-to-h1-post64k"), + ("HTTP/2 · TLS", "HTTP/2 · plain", "twp-reverse-http2-to-h2c-post64k", None, "yarp-reverse-http2-to-h2c-post64k"), + ("HTTP/2 · TLS", "HTTP/2 · TLS", "twp-reverse-http2-to-https-post64k", None, "yarp-reverse-http2-to-https-post64k"), + ("HTTP/3 · QUIC", "HTTP/2 · TLS", "twp-reverse-http3-to-http2-post64k", None, "yarp-reverse-http3-to-http2-post64k"), + ("HTTP/3 · QUIC", "HTTP/1 · TLS", "twp-reverse-http3-to-https-http1-post64k", "nginx-reverse-http3-to-https-http1-post64k", "yarp-reverse-http3-to-https-http1-post64k"), + ] + + def post_table(data: dict, is_win: bool) -> str: + rows = [ + f"| Client | Origin | {PEER_COLS} |", + f"|---|---|{PEER_RULE}|", + ] + for c, o, t, n, y in post_spec: + extra = {} + if n is None: + stem = t.replace("twp-reverse-", "", 1) + extra = {"haproxy_a": f"haproxy-reverse-{stem}", "envoy_a": f"envoy-reverse-{stem}"} + rows.append( + peer_row([c, o], t, n, y, data, win_no_quic=is_win, win_no_haproxy_envoy=is_win, **extra) + ) + return "\n".join(rows) + + lossy_spec = [ + ("HTTP/1 · TLS", "HTTP/1 · plain", "twp-reverse-http1-tls-lossy", "nginx-reverse-http1-tls-lossy", "yarp-reverse-http1-tls-lossy"), + ("HTTP/2 · TLS", "HTTP/1 · plain", "twp-reverse-http2-cleartext-lossy", "nginx-reverse-http2-lossy", "yarp-reverse-http2-lossy"), + ("HTTP/3 · QUIC", "HTTP/1 · plain", "twp-reverse-http3-cleartext-lossy", "nginx-reverse-http3-cleartext-lossy", "yarp-reverse-http3-cleartext-lossy"), + ("HTTP/2 · plain", "HTTP/1 · plain", "twp-reverse-h2c-to-h1-lossy", "nginx-reverse-h2c-to-h1-lossy", "yarp-reverse-h2c-to-h1-lossy"), + ("HTTP/2 · TLS", "HTTP/2 · plain", "twp-reverse-http2-to-h2c-lossy", None, "yarp-reverse-http2-to-h2c-lossy"), + ("HTTP/2 · TLS", "HTTP/2 · TLS", "twp-reverse-http2-to-https-lossy", None, "yarp-reverse-http2-to-https-lossy"), + ("HTTP/3 · QUIC", "HTTP/2 · TLS", "twp-reverse-http3-to-http2-lossy", None, "yarp-reverse-http3-to-http2-lossy"), + ("HTTP/3 · QUIC", "HTTP/1 · TLS", "twp-reverse-http3-to-https-http1-lossy", "nginx-reverse-http3-to-https-http1-lossy", "yarp-reverse-http3-to-https-http1-lossy"), + ] + + def lossy_table(data: dict, is_win: bool) -> str: + rows = [ + f"| Client | Origin | {PEER_COLS} |", + f"|---|---|{PEER_RULE}|", + ] + for c, o, t, n, y in lossy_spec: + extra = {} + if n is None: + stem = t.replace("twp-reverse-", "", 1) + extra = {"haproxy_a": f"haproxy-reverse-{stem}", "envoy_a": f"envoy-reverse-{stem}"} + rows.append( + peer_row([c, o], t, n, y, data, win_no_quic=is_win, win_no_haproxy_envoy=is_win, **extra) + ) + return "\n".join(rows) + + arch_spec = [ + ("Slow consumer (256 KiB GET, throttled client read)", "HTTP/1 · TLS", "HTTP/1 · plain", "twp-reverse-http1-tls-slow256k", "nginx-reverse-http1-tls-slow256k", "yarp-reverse-http1-tls-slow256k"), + ("Slow consumer (256 KiB GET, throttled client read)", "HTTP/2 · TLS", "HTTP/1 · plain", "twp-reverse-http2-cleartext-slow256k", "nginx-reverse-http2-slow256k", "yarp-reverse-http2-slow256k"), + ("Slow consumer (256 KiB GET, throttled client read)", "HTTP/3 · QUIC", "HTTP/1 · plain", "twp-reverse-http3-cleartext-slow256k", "nginx-reverse-http3-cleartext-slow256k", "yarp-reverse-http3-cleartext-slow256k"), + ("Early response (origin writes after first request chunk)", "HTTP/1 · TLS", "HTTP/1 · plain", "twp-reverse-http1-tls-early64k", "nginx-reverse-http1-tls-early64k", "yarp-reverse-http1-tls-early64k"), + ("Early response (origin writes after first request chunk)", "HTTP/2 · TLS", "HTTP/1 · plain", "twp-reverse-http2-cleartext-early64k", "nginx-reverse-http2-early64k", "yarp-reverse-http2-early64k"), + ("Early response (origin writes after first request chunk)", "HTTP/3 · QUIC", "HTTP/1 · plain", "twp-reverse-http3-cleartext-early64k", "nginx-reverse-http3-cleartext-early64k", "yarp-reverse-http3-cleartext-early64k"), + ("Slow consumer (256 KiB GET, throttled client read)", "HTTP/2 · plain", "HTTP/1 · plain", "twp-reverse-h2c-to-h1-slow256k", "nginx-reverse-h2c-to-h1-slow256k", "yarp-reverse-h2c-to-h1-slow256k"), + ("Slow consumer (256 KiB GET, throttled client read)", "HTTP/2 · TLS", "HTTP/2 · TLS", "twp-reverse-http2-to-https-slow256k", None, "yarp-reverse-http2-to-https-slow256k"), + ("Early response (origin writes after first request chunk)", "HTTP/2 · TLS", "HTTP/2 · TLS", "twp-reverse-http2-to-https-early64k", None, "yarp-reverse-http2-to-https-early64k"), + ("Duplex (both directions live)", "HTTP/2 · TLS", "HTTP/2 · TLS", "twp-reverse-http2-duplex-h2", None, "yarp-reverse-http2-to-https-duplex-h2"), + ("Duplex (WebSocket / extended CONNECT)", "HTTP/1 · TLS", "HTTP/1 · plain", "twp-reverse-http1-tls-duplex-ws", "nginx-reverse-http1-tls-duplex-ws", "yarp-reverse-http1-tls-duplex-ws"), + ] + + def arch_table(data: dict, is_win: bool) -> str: + rows = [ + f"| Scenario | Client | Origin | {PEER_COLS} |", + f"|---|---|---|{PEER_RULE}|", + ] + for sc, c, o, t, n, y in arch_spec: + extra = {} + if n is None: + stem = t.replace("twp-reverse-", "", 1) + extra = { + "haproxy_a": f"haproxy-reverse-{stem}", + "envoy_a": f"envoy-reverse-{stem}", + } + rows.append( + peer_row( + [sc, c, o], + t, + n, + y, + data, + win_no_quic=is_win and bool(n and "http3" in n), + win_no_haproxy_envoy=is_win, + **extra, + ) + ) + return "\n".join(rows) + + tls_spec = [ + ("Keep-alive · tiny GET", "twp-reverse-http1-tls-ka-tiny", "nginx-reverse-http1-tls-ka-tiny", "yarp-reverse-http1-tls-ka-tiny"), + ("New-connection · tiny GET", "twp-reverse-http1-tls-nc-tiny", "nginx-reverse-http1-tls-nc-tiny", "yarp-reverse-http1-tls-nc-tiny"), + ("Keep-alive · 256 KiB GET", "twp-reverse-http1-tls-ka-256k", "nginx-reverse-http1-tls-ka-256k", "yarp-reverse-http1-tls-ka-256k"), + ] + + def tls_table(data: dict, is_win: bool) -> str: + rows = [ + f"| Workload | {PEER_COLS} |", + f"|---|{PEER_RULE}|", + ] + for label, t, n, y in tls_spec: + rows.append(peer_row([label], t, n, y, data, win_no_haproxy_envoy=is_win)) + return "\n".join(rows) + + def sat_block_a(data: dict) -> str: + origin = data.get("origin-direct") + op = origin["Peak"] if origin else None + peer_keys = ["nginx-reverse-http1", "yarp-reverse-http1", "twp-reverse-http1"] + medal = pick_medal([(k, data.get(k)) for k in peer_keys]) + arms = [ + ("origin-direct", "dotnet-httpclient", False), + ("origin-direct-bombardier", "bombardier", False), + ("bare-reverse-http1", "dotnet-httpclient", False), + ("nginx-reverse-http1", "dotnet-httpclient", True), + ("yarp-reverse-http1", "dotnet-httpclient", True), + ("twp-reverse-http1", "dotnet-httpclient", True), + ] + rows = [ + "| Arm | Generator | Sustain | Peak | % of origin-HttpClient |", + "|---|---|---:|---:|---:|", + ] + for arm, gen, is_peer in arms: + m = data.get(arm) + med = is_peer and medal == arm + pct = "—" + if m and op and op > 0: + pct = f"**{m['Peak'] / op * 100:.1f}%**" + rows.append( + f"| {arm} | {gen} | {fmt_cell(m, medal=med)} | {fmt_cell(m, peak=True)} | {pct} |" + ) + return "\n".join(rows) + + def sat_block_bc(data: dict, nginx_a: str, yarp_a: str, twp_a: str, win_no_nginx: bool = False) -> str: + nginx = None if win_no_nginx else data.get(nginx_a) + yarp = data.get(yarp_a) + twp = data.get(twp_a) + medal = pick_medal([("nginx", nginx), ("yarp", yarp), ("twp", twp)]) + + def rdiv(num: Optional[dict], den: Optional[dict]) -> str: + if not num or not den or not den["Peak"]: + return "—" + return f"**{num['Peak'] / den['Peak']:.2f}×**" + + rows = [ + "| Arm | Generator | Sustain | Peak | ÷YARP | ÷nginx |", + "|---|---|---:|---:|---:|---:|", + ] + for arm, m, imp, key in [ + (nginx_a, nginx, "Not possible (no QUIC)" if win_no_nginx else None, "nginx"), + (yarp_a, yarp, None, "yarp"), + (twp_a, twp, None, "twp"), + ]: + if imp: + rows.append(f"| {arm} | dotnet-httpclient | *{imp}* | *{imp}* | — | — |") + continue + rows.append( + f"| {arm} | dotnet-httpclient | {fmt_cell(m, medal=(medal == key))} | {fmt_cell(m, peak=True)} | " + f"{rdiv(m, yarp)} | {rdiv(m, nginx)} |" + ) + return "\n".join(rows) + + # Saturation intro + sat_intro = ( + f"Calibration for the shared 4 vCPU loopback shape: how close client + origin are to saturated before ranking reverse peers. " + f"Tiny keep-alive GET. Median of **3** repeats @ `{HEAD}` — [{rid_s}]({run_url(rid_s)}). " + f"Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Block A **% of origin-HttpClient** uses median **peak** RPS. " + f"Blocks B/C use peer÷YARP / ÷nginx on median peak (not % of H1 origin). **RPS cells** embed median RSS / CPU for the **proxy child** plus its **full descendant tree** " + f"(serve-proxy → nginx master → workers); origin-direct samples the **origin** child. Product matrices below use matched `dotnet-httpclient` only (not bombardier).\n" + ) + text = re.sub( + r"Calibration for the shared 4 vCPU loopback shape:.*?(?=\n\n\n```powershell\npwsh tools/RpsLoadProbe/run-rps\.ps1 -Mode compare-saturation)", + sat_intro.rstrip() + "\n", + text, + count=1, + flags=re.S, + ) + + # Block A + a = text.find("#### Block A — H1 plain") + b = text.find("#### Block B — H2 TLS→H1") + block = text[a:b] + w = block.find("**Windows**") + l = block.find("**Linux**") + block = replace_table_at(block, block.find("| Arm | Generator | Sustain", w), sat_block_a(win["saturation"])) + l = block.find("**Linux**") + block = replace_table_at(block, block.find("| Arm | Generator | Sustain", l), sat_block_a(lin["saturation"])) + text = text[:a] + block + text[b:] + + # Block B + b = text.find("#### Block B — H2 TLS→H1") + c = text.find("#### Block C — H3→H1") + block = text[b:c] + w = block.find("**Windows**") + block = replace_table_at( + block, + block.find("| Arm |", w), + sat_block_bc(win["saturation"], "nginx-reverse-http2", "yarp-reverse-http2", "twp-reverse-http2-cleartext"), + ) + l = block.find("**Linux**") + block = replace_table_at( + block, + block.find("| Arm |", l), + sat_block_bc(lin["saturation"], "nginx-reverse-http2", "yarp-reverse-http2", "twp-reverse-http2-cleartext"), + ) + text = text[:b] + block + text[c:] + + # Block C + c = text.find("#### Block C — H3→H1") + how = text.find("**How to read the tables**") + block = text[c:how] + w = block.find("**Windows**") + block = replace_table_at( + block, + block.find("| Arm |", w), + sat_block_bc( + win["saturation"], + "nginx-reverse-http3-cleartext", + "yarp-reverse-http3-cleartext", + "twp-reverse-http3-cleartext", + win_no_nginx=True, + ), + ) + l = block.find("**Linux**") + block = replace_table_at( + block, + block.find("| Arm |", l), + sat_block_bc( + lin["saturation"], + "nginx-reverse-http3-cleartext", + "yarp-reverse-http3-cleartext", + "twp-reverse-http3-cleartext", + ), + ) + text = text[:c] + block + text[how:] + + def patch_heavier(heading: str, new_hdr: str, new_table: str) -> None: + nonlocal text + i = text.find(heading) + if i < 0: + raise SystemExit(f"missing {heading}") + # Find first data table after heading (Body/Client/Scenario/Workload) + m = re.search(r"\n(\| (?:Body|Client|Scenario|Workload) \|)", text[i:]) + if not m: + raise SystemExit(f"no table under {heading}") + tbl = i + m.start() + 1 + # Replace Median / Userspace header line(s) between heading and table + chunk = text[i:tbl] + chunk2 = re.sub( + r"(Median of \*\*3\*\*[^\n]*\n|Userspace[^\n]*\n(?:[^\n]*\n)?)", + new_hdr if new_hdr.endswith("\n") else new_hdr + "\n", + chunk, + count=1, + ) + text = text[:i] + chunk2 + text[tbl:] + tbl = text.find(m.group(1), i) + text = replace_table_at(text, tbl, new_table) + + patch_heavier( + "### Windows — heavier reverse GET (64 KiB / 256 KiB)", + f"Median of **3** repeats on `windows-latest` @ `{HEAD}`. Source: Actions [{rid_b}]({run_url(rid_b)}) (`compare-bodies`). Warmup 2s / measure 8s. **RPS cells** include `(MiB / CPU%)` footprints.\n", + bodies_table(win["bodies"], True), + ) + patch_heavier( + "### Linux — heavier reverse GET (64 KiB / 256 KiB)", + f"Median of **3** repeats @ `{HEAD}`. Source: Actions [{rid_b}]({run_url(rid_b)}) (`compare-bodies`). Warmup 2s / measure 8s.\n", + bodies_table(lin["bodies"], False), + ) + patch_heavier( + "### Windows — POST 64 KiB request + 64 KiB response", + f"Median of **3** repeats on `windows-latest` @ `{HEAD}`. Source: Actions [{rid_p}]({run_url(rid_p)}) (`compare-post`).\n", + post_table(win["post"], True), + ) + patch_heavier( + "### Linux — POST 64 KiB request + 64 KiB response", + f"Median of **3** repeats @ `{HEAD}`. Source: Actions [{rid_p}]({run_url(rid_p)}) (`compare-post`).\n", + post_table(lin["post"], False), + ) + patch_heavier( + "### Windows — lossy / high-RTT (H2 HOL / H3 loss)", + f"Userspace **5 ms** one-way delay + **1%** TCP connection stall (H1/H2) or UDP datagram drop (H3); **64 KiB** GET. Median of **3** repeats on `windows-latest` @ `{HEAD}` — [{rid_l}]({run_url(rid_l)}) (`compare-lossy`).\n", + lossy_table(win["lossy"], True), + ) + patch_heavier( + "### Linux — lossy / high-RTT (H2 HOL / H3 loss)", + f"Median of **3** repeats @ `{HEAD}`. Source: [{rid_l}]({run_url(rid_l)}) (`compare-lossy`; lossy H3 uses `quic-http3`).\n", + lossy_table(lin["lossy"], False), + ) + + text = re.sub( + r"Median of \*\*3\*\* repeats on matched 4 vCPU / 16 GiB runners @ `[^`]+` \(\[[0-9]+\]\([^)]+\)\) \(`compare-arch`\)\.", + f"Median of **3** repeats on matched 4 vCPU / 16 GiB runners @ `{HEAD}` ([{rid_a}]({run_url(rid_a)})) (`compare-arch`).", + text, + count=1, + ) + arch = text.find("### Architecture-sensitive") + w = text.find("#### Windows", arch) + arch_w = text.find("| Scenario |", w) + text = replace_table_at(text, arch_w, arch_table(win["arch"], True)) + l = text.find("#### Linux", text.find("### Architecture-sensitive")) + arch_l = text.find("| Scenario |", l) + text = replace_table_at(text, arch_l, arch_table(lin["arch"], False)) + + tls = text.find("### TLS termination cost") + w = text.find("#### Windows", tls) + text2 = text[w:] + text2 = re.sub( + r"Median of \*\*3\*\* repeats on `windows-latest` @ `[^`]+`\. Source: Actions \[[0-9]+\]\([^)]+\) \(`compare-tls-cost`\)\.[^\n]*\n", + f"Median of **3** repeats on `windows-latest` @ `{HEAD}`. Source: Actions [{rid_t}]({run_url(rid_t)}) (`compare-tls-cost`). Absolute RPS on GHA swings hard; prefer **TWP÷YARP**.\n", + text2, + count=1, + ) + tls_tbl_w = text2.find("| Workload |") + text2 = replace_table_at(text2, tls_tbl_w, tls_table(win["tls"], True)) + text = text[:w] + text2 + + tls = text.find("### TLS termination cost") + l = text.find("#### Linux", tls) + text2 = text[l:] + text2 = re.sub( + r"Median of \*\*3\*\* repeats @ `[^`]+`\. Source: Actions \[[0-9]+\]\([^)]+\) \(`compare-tls-cost`\)\.\n", + f"Median of **3** repeats @ `{HEAD}`. Source: Actions [{rid_t}]({run_url(rid_t)}) (`compare-tls-cost`).\n", + text2, + count=1, + ) + tls_tbl_l = text2.find("| Workload |") + text2 = replace_table_at(text2, tls_tbl_l, tls_table(lin["tls"], False)) + text = text[:l] + text2 + + WIKI.write_text(text, encoding="utf-8") + print("heavier+saturation pasted") + for s in ("9d7c2966", "32871900682", "32866709227", HEAD, str(rid_b)): + print(f" count {s}={text.count(s)}") + + +if __name__ == "__main__": + main() diff --git a/tools/RpsLoadProbe/product-arm-matrix.py b/tools/RpsLoadProbe/product-arm-matrix.py new file mode 100644 index 000000000..e5d4b6619 --- /dev/null +++ b/tools/RpsLoadProbe/product-arm-matrix.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +"""Product-possible vs harness-present reverse arms (compare-product 5×5). + +TWP and YARP already cover all 25 Client×Origin cells. This file tracks nginx / +HAProxy / Envoy: present ProbeMode, product-possible but no arm, or impossible. + + python3 tools/RpsLoadProbe/product-arm-matrix.py # gap table + python3 tools/RpsLoadProbe/product-arm-matrix.py --json + python3 tools/RpsLoadProbe/product-arm-matrix.py --counts + +Linux/macOS only for HAProxy and Envoy (no official Windows port). Windows nginx +has no QUIC — H3 inbound arms exist but cells are OS-impossible, not harness gaps. + +Heavier modes (`compare-bodies` / `post` / `lossy` / `tls-cost` / `arch` slow+early) +clone terminate-to-H1c plus remainder wires (h2c→H1, H2 TLS→h2c, H2 TLS→H2 TLS, +H3→H2 TLS, H3→H1 TLS). Arch duplex H2 TLS↔H2 TLS includes HAProxy/Envoy. + present ProbeMode + compare-product inclusion + absent product can do the wire; no host/ProbeMode/ramp arm yet + impossible stock product cannot speak that origin (or inbound) protocol +""" + +from __future__ import annotations + +import argparse +import json +from typing import Dict, List, Optional, Tuple + +H1C = "HTTP/1 · plain" +H1T = "HTTP/1 · TLS" +H2C = "HTTP/2 · plain" +H2T = "HTTP/2 · TLS" +H3 = "HTTP/3 · QUIC" + +CLIENTS = (H1C, H1T, H2C, H2T, H3) +ORIGINS = CLIENTS +PRODUCTS = ("nginx", "haproxy", "envoy") + +# Planned CSV names that now have ProbeModes (kept for docs / paste scripts). +IMPLEMENTED_REMAINDER: Dict[Tuple[str, str, str], str] = { + # nginx: prior-knowledge h2c inbound (mainline 1.25.1+ `http2 on` without ssl). + ("nginx", H2C, H1C): "nginx-reverse-h2c-to-h1", + ("nginx", H2C, H1T): "nginx-reverse-h2c-to-https", + # HAProxy / Envoy: every cell that is not one of the eight terminate-to-H1 arms. + ("haproxy", H1C, H2C): "haproxy-reverse-http1-plain-to-h2c", + ("haproxy", H1C, H2T): "haproxy-reverse-http1-plain-to-http2", + ("haproxy", H1C, H3): "haproxy-reverse-http1-plain-to-http3", + ("haproxy", H1T, H2C): "haproxy-reverse-http1-to-h2c", + ("haproxy", H1T, H2T): "haproxy-reverse-http11-to-http2", + ("haproxy", H1T, H3): "haproxy-reverse-http1-to-http3", + ("haproxy", H2C, H1C): "haproxy-reverse-h2c-to-h1", + ("haproxy", H2C, H1T): "haproxy-reverse-h2c-to-https", + ("haproxy", H2C, H2C): "haproxy-reverse-h2c-to-h2c", + ("haproxy", H2C, H2T): "haproxy-reverse-h2c", + ("haproxy", H2C, H3): "haproxy-reverse-h2c-to-h3", + ("haproxy", H2T, H2C): "haproxy-reverse-http2-to-h2c", + ("haproxy", H2T, H2T): "haproxy-reverse-http2-to-https", + ("haproxy", H2T, H3): "haproxy-reverse-http2-to-http3", + ("haproxy", H3, H2C): "haproxy-reverse-http3-to-h2c", + ("haproxy", H3, H2T): "haproxy-reverse-http3-to-http2", + ("haproxy", H3, H3): "haproxy-reverse-http3-to-http3", + ("envoy", H1C, H2C): "envoy-reverse-http1-plain-to-h2c", + ("envoy", H1C, H2T): "envoy-reverse-http1-plain-to-http2", + ("envoy", H1C, H3): "envoy-reverse-http1-plain-to-http3", + ("envoy", H1T, H2C): "envoy-reverse-http1-to-h2c", + ("envoy", H1T, H2T): "envoy-reverse-http11-to-http2", + ("envoy", H1T, H3): "envoy-reverse-http1-to-http3", + ("envoy", H2C, H1C): "envoy-reverse-h2c-to-h1", + ("envoy", H2C, H1T): "envoy-reverse-h2c-to-https", + ("envoy", H2C, H2C): "envoy-reverse-h2c-to-h2c", + ("envoy", H2C, H2T): "envoy-reverse-h2c", + ("envoy", H2C, H3): "envoy-reverse-h2c-to-h3", + ("envoy", H2T, H2C): "envoy-reverse-http2-to-h2c", + ("envoy", H2T, H2T): "envoy-reverse-http2-to-https", + ("envoy", H2T, H3): "envoy-reverse-http2-to-http3", + ("envoy", H3, H2C): "envoy-reverse-http3-to-h2c", + ("envoy", H3, H2T): "envoy-reverse-http3-to-http2", + ("envoy", H3, H3): "envoy-reverse-http3-to-http3", +} + +# Eight terminate-to-H1 (or H1 TLS) arms that already exist for all three peers. +PRESENT_ARMS: Dict[Tuple[str, str, str], str] = {} +for prefix in PRODUCTS: + PRESENT_ARMS[(prefix, H1C, H1C)] = f"{prefix}-reverse-http1" + PRESENT_ARMS[(prefix, H1C, H1T)] = f"{prefix}-reverse-http1-to-https" + PRESENT_ARMS[(prefix, H1T, H1C)] = f"{prefix}-reverse-http1-tls" + PRESENT_ARMS[(prefix, H1T, H1T)] = f"{prefix}-reverse-http1-tls-to-https" + PRESENT_ARMS[(prefix, H2T, H1C)] = f"{prefix}-reverse-http2" + PRESENT_ARMS[(prefix, H2T, H1T)] = f"{prefix}-reverse-http2-to-https-http1" + PRESENT_ARMS[(prefix, H3, H1C)] = f"{prefix}-reverse-http3-cleartext" + PRESENT_ARMS[(prefix, H3, H1T)] = f"{prefix}-reverse-http3-to-https-http1" + +PRESENT_ARMS.update(IMPLEMENTED_REMAINDER) +ABSENT_ARMS: Dict[Tuple[str, str, str], str] = {} + +# How to build the remainder wire (config hint). +ABSENT_HOW: Dict[Tuple[str, str], str] = { + ("nginx", H2C): "mainline 1.25.1+ `http2 on` without ssl (prior-knowledge h2c) → existing H1 origin", + ("haproxy", H2C): "`bind ... proto h2` (h2c) or existing QUIC/TLS frontend", + ("haproxy", H2T): "`server ... ssl verify none alpn h2 proto h2`", + ("haproxy", H3): "`server ... quic4@127.0.0.1: ssl verify none alpn h3` (3.2 USE_QUIC)", + ("envoy", H2C): "TCP listener codec HTTP2, no TLS; cluster `http2_protocol_options` without TLS", + ("envoy", H2T): "cluster `explicit_http_config.http2_protocol_options` + UpstreamTlsContext", + ("envoy", H3): "cluster QuicUpstreamTransport + `http3_protocol_options` (upstream H3 is alpha)", +} + +IMPOSSIBLE_REASON = { + "h2-up": "Not possible (no H2 upstream)", + "h3-up": "Not possible (no H3 upstream)", +} + +SPECIAL_ABSENT: Tuple = () + + +def _origin_kind(origin: str) -> str: + if origin == H3: + return "h3" + if origin in (H2C, H2T): + return "h2" + return "h1" + + +def status(product: str, client: str, origin: str) -> str: + if (product, client, origin) in PRESENT_ARMS: + return "present" + if (product, client, origin) in ABSENT_ARMS: + return "absent" + return "impossible" + + +def arm_name(product: str, client: str, origin: str) -> Optional[str]: + if (product, client, origin) in PRESENT_ARMS: + return PRESENT_ARMS[(product, client, origin)] + if (product, client, origin) in ABSENT_ARMS: + return ABSENT_ARMS[(product, client, origin)] + return None + + +def impossible_reason(product: str, client: str, origin: str) -> Optional[str]: + if status(product, client, origin) != "impossible": + return None + if product != "nginx": + return "Not possible" + kind = _origin_kind(origin) + if kind == "h3": + return IMPOSSIBLE_REASON["h3-up"] + if kind == "h2": + return IMPOSSIBLE_REASON["h2-up"] + return "Not possible" + + +def how(product: str, client: str, origin: str) -> Optional[str]: + if status(product, client, origin) != "absent": + return None + if product == "nginx": + return ABSENT_HOW[("nginx", H2C)] + origin_kind = _origin_kind(origin) + if origin_kind == "h1": + if client == H2C: + return ABSENT_HOW[(product, H2C)] + return "existing H1 cluster + inbound protocol from client cell" + if origin_kind == "h2": + return ABSENT_HOW[(product, H2C if origin == H2C else H2T)] + return ABSENT_HOW[(product, H3)] + + +def absent_rows() -> List[Dict[str, str]]: + rows = [] + for product in PRODUCTS: + for client in CLIENTS: + for origin in ORIGINS: + if status(product, client, origin) != "absent": + continue + rows.append( + { + "product": product, + "client": client, + "origin": origin, + "arm": ABSENT_ARMS[(product, client, origin)], + "how": how(product, client, origin) or "", + "suite": "compare-product", + } + ) + for spec in SPECIAL_ABSENT: + for product in ("haproxy", "envoy"): + rows.append( + { + "product": product, + "client": H2T, + "origin": H2T, + "arm": spec[product], + "how": spec["note"], + "suite": spec["suite"], + } + ) + return rows + + +def counts() -> Dict[str, Dict[str, int]]: + out: Dict[str, Dict[str, int]] = {} + for product in PRODUCTS: + c = {"present": 0, "absent": 0, "impossible": 0} + for client in CLIENTS: + for origin in ORIGINS: + c[status(product, client, origin)] += 1 + out[product] = c + return out + + +def as_json() -> dict: + wires = [] + for client in CLIENTS: + for origin in ORIGINS: + cell = {"client": client, "origin": origin} + for product in PRODUCTS: + st = status(product, client, origin) + cell[product] = { + "status": st, + "arm": arm_name(product, client, origin), + "reason": impossible_reason(product, client, origin), + "how": how(product, client, origin), + } + wires.append(cell) + return { + "suite": "compare-product 5×5 reverse (Linux/macOS peers)", + "windows": "HAProxy and Envoy are OS-impossible on Windows; nginx H3 inbound is OS-impossible (no QUIC).", + "counts": counts(), + "wires": wires, + "special": list(SPECIAL_ABSENT), + "absent": absent_rows(), + } + + +def print_gaps() -> None: + rows = absent_rows() + print(f"{len(rows)} product-possible / harness-absent arms\n") + print(f"{'suite':<16} {'product':<8} {'client':<16} {'origin':<16} arm") + print("-" * 110) + for r in rows: + print(f"{r['suite']:<16} {r['product']:<8} {r['client']:<16} {r['origin']:<16} {r['arm']}") + print() + c = counts() + for product in PRODUCTS: + n = c[product] + print( + f"{product}: present {n['present']} absent {n['absent']} " + f"impossible {n['impossible']} (of 25)" + ) + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0]) + p.add_argument("--json", action="store_true") + p.add_argument("--counts", action="store_true") + args = p.parse_args() + if args.json: + print(json.dumps(as_json(), indent=2)) + return + if args.counts: + print(json.dumps(counts(), indent=2)) + return + print_gaps() + + +if __name__ == "__main__": + main() diff --git a/tools/RpsLoadProbe/publish-rps-peers.ps1 b/tools/RpsLoadProbe/publish-rps-peers.ps1 new file mode 100644 index 000000000..8da06c677 --- /dev/null +++ b/tools/RpsLoadProbe/publish-rps-peers.ps1 @@ -0,0 +1,115 @@ +# Publish product + heavier peer numbers into wiki/Performance.md and regenerate practical charts. +# Requires completed GHA CSV downloads under tools/RpsLoadProbe/results/gha-dl//. +# +# Wiki-grade (repeats=3, warmup 2s / measure 8s, c=8,16,32,64): +# compare-product (3 OS × shards), compare-saturation, compare-bodies, compare-post, +# compare-lossy, compare-tls-cost, compare-arch, compare-grpc +# +# Download artifacts into gha-dl//rps-csv-{os}[-shard-*]/ then: +# pwsh tools/RpsLoadProbe/publish-rps-peers.ps1 -ProductRunIds 111,222,... -PasteHeavier + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string[]] $ProductRunIds, + # Comma-separated or repeated; Win+Linux (+shards) for each heavier mode. + [string[]] $SaturationRunIds = @(), + [string[]] $BodiesRunIds = @(), + [string[]] $PostRunIds = @(), + [string[]] $LossyRunIds = @(), + [string[]] $TlsRunIds = @(), + [string[]] $ArchRunIds = @(), + [string[]] $GrpcRunIds = @(), + [switch] $PasteHeavier, + [string] $HeadSha = '9a2b3a1e', + [string] $GhaDlRoot = 'tools/RpsLoadProbe/results/gha-dl' +) + +$ErrorActionPreference = 'Stop' + +function Expand-Ids([string[]] $ids) { + $out = @() + foreach ($raw in $ids) { + if (-not $raw) { continue } + foreach ($p in ($raw -split ',')) { + $t = $p.Trim() + if ($t) { $out += $t } + } + } + return $out +} + +function Resolve-Py { + foreach ($c in @('py', 'python', 'python3')) { + $cmd = Get-Command $c -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + } + throw 'Python not found (tried py, python, python3)' +} + +$ProductRunIds = Expand-Ids $ProductRunIds +$SaturationRunIds = Expand-Ids $SaturationRunIds +$BodiesRunIds = Expand-Ids $BodiesRunIds +$PostRunIds = Expand-Ids $PostRunIds +$LossyRunIds = Expand-Ids $LossyRunIds +$TlsRunIds = Expand-Ids $TlsRunIds +$ArchRunIds = Expand-Ids $ArchRunIds +$GrpcRunIds = Expand-Ids $GrpcRunIds + +$primary = $ProductRunIds[0] +$root = Join-Path $GhaDlRoot $primary +if (-not (Test-Path $root)) { + throw "Missing product CSV root: $root" +} + +$py = Resolve-Py +Write-Host "Product CSV runs: $($ProductRunIds -join ', ')" -ForegroundColor Cyan +Write-Host "Python: $py" -ForegroundColor DarkGray + +$runIdsCsv = ($ProductRunIds -join ',') +pwsh tools/RpsLoadProbe/paste-compare-product-wiki.ps1 ` + -RunIds $runIdsCsv ` + -ResultsRoot $GhaDlRoot ` + -HeadSha $HeadSha ` + -PrimaryRunId $primary ` + -OutFile tools/RpsLoadProbe/results/wiki-paste-out.txt + +pwsh tools/RpsLoadProbe/apply-wiki-paste.ps1 ` + -PasteFile tools/RpsLoadProbe/results/wiki-paste-out.txt ` + -HeadSha $HeadSha ` + -PrimaryRunId $primary + +if ($PasteHeavier -or $SaturationRunIds.Count -or $BodiesRunIds.Count) { + $env:PYTHONIOENCODING = 'utf-8' + $heavierArgs = @() + if ($SaturationRunIds.Count) { $heavierArgs += @('--saturation', ($SaturationRunIds -join ',')) } + if ($BodiesRunIds.Count) { $heavierArgs += @('--bodies', ($BodiesRunIds -join ',')) } + if ($PostRunIds.Count) { $heavierArgs += @('--post', ($PostRunIds -join ',')) } + if ($LossyRunIds.Count) { $heavierArgs += @('--lossy', ($LossyRunIds -join ',')) } + if ($TlsRunIds.Count) { $heavierArgs += @('--tls', ($TlsRunIds -join ',')) } + if ($ArchRunIds.Count) { $heavierArgs += @('--arch', ($ArchRunIds -join ',')) } + & $py tools/RpsLoadProbe/paste-heavier-wiki.py @heavierArgs +} + +& $py -m pip install -q -r tools/RpsLoadProbe/requirements-charts.txt + +$practicalArgs = @('--out-dir', 'wiki/images', '--title-suffix', "@ $HeadSha") +foreach ($id in $ProductRunIds) { + $practicalArgs += @('--results-root', (Join-Path $GhaDlRoot $id)) +} +foreach ($id in $BodiesRunIds) { + $practicalArgs += @('--bodies-root', (Join-Path $GhaDlRoot $id)) +} +foreach ($id in $PostRunIds) { + $practicalArgs += @('--post-root', (Join-Path $GhaDlRoot $id)) +} +foreach ($id in $ArchRunIds) { + $practicalArgs += @('--arch-root', (Join-Path $GhaDlRoot $id)) +} +foreach ($id in $GrpcRunIds) { + $practicalArgs += @('--grpc-root', (Join-Path $GhaDlRoot $id)) +} + +& $py tools/RpsLoadProbe/render-practical-charts.py @practicalArgs + +Write-Host 'Done. Review wiki/Performance.md and wiki/images/rps-practical-*.png before commit.' -ForegroundColor Green diff --git a/tools/RpsLoadProbe/render-practical-charts.py b/tools/RpsLoadProbe/render-practical-charts.py new file mode 100644 index 000000000..f93511a3b --- /dev/null +++ b/tools/RpsLoadProbe/render-practical-charts.py @@ -0,0 +1,1005 @@ +#!/usr/bin/env python3 +"""Render practical reverse-proxy RPS grouped bar charts (TWP / YARP / nginx / HAProxy / Envoy). + +Two PNGs per OS: + - Tiny: eight industry reverse wires (tiny keep-alive GET ~56 B) plus WebSocket / gRPC + (10 clusters). Filename rps-practical-{os}.png. + - Heavier (64 KB): typical reverse body/POST clusters. Filename + rps-practical-heavier-{os}.png. Skipped when every cluster is empty (e.g. macOS wiki). + +Example (CSV): + python3 tools/RpsLoadProbe/render-practical-charts.py \\ + --results-root tools/RpsLoadProbe/results/gha-dl/ \\ + --bodies-root tools/RpsLoadProbe/results/gha-dl/ \\ + --post-root tools/RpsLoadProbe/results/gha-dl/ \\ + --arch-root tools/RpsLoadProbe/results/gha-dl/ \\ + --grpc-root tools/RpsLoadProbe/results/gha-dl/ \\ + --out-dir wiki/images \\ + --title-suffix '@ ' + +Wiki fallback (current Performance.md reverse + heavier + gRPC tables): + python3 tools/RpsLoadProbe/render-practical-charts.py \\ + --from-wiki wiki/Performance.md --out-dir wiki/images --title-suffix '@ ' +""" + +from __future__ import annotations + +import argparse +import csv +import re +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Tuple, Union + +# Practical industry reverse wires (short labels → CSV arm names). +# Order: typical TLS-in→H1-out, H2 same-protocol, H3 terminate, then H3→h2c. +# nginx H2/H3 origin stays None (stock nginx has no H2/H3 upstream). +PRACTICAL_ARMS: List[Tuple[str, str, str, Optional[str], Optional[str], Optional[str]]] = [ + # label, twp, yarp, nginx, haproxy, envoy (None = product-impossible) + ( + "H1 TLS→H1c", + "twp-reverse-http1-tls", + "yarp-reverse-http1-tls", + "nginx-reverse-http1-tls", + "haproxy-reverse-http1-tls", + "envoy-reverse-http1-tls", + ), + ( + "H1 TLS→H1 TLS", + "twp-reverse-http1-mitm", + "yarp-reverse-http1-tls-to-https", + "nginx-reverse-http1-tls-to-https", + "haproxy-reverse-http1-tls-to-https", + "envoy-reverse-http1-tls-to-https", + ), + ( + "H2 TLS→H1c", + "twp-reverse-http2-cleartext", + "yarp-reverse-http2", + "nginx-reverse-http2", + "haproxy-reverse-http2", + "envoy-reverse-http2", + ), + ( + "H2 TLS→H1 TLS", + "twp-reverse-http2-to-https-http1", + "yarp-reverse-http2-to-https-http1", + "nginx-reverse-http2-to-https-http1", + "haproxy-reverse-http2-to-https-http1", + "envoy-reverse-http2-to-https-http1", + ), + ( + "H2 TLS→h2c", + "twp-reverse-http2-to-h2c", + "yarp-reverse-http2-to-h2c", + None, + "haproxy-reverse-http2-to-h2c", + "envoy-reverse-http2-to-h2c", + ), + ( + "H2 TLS→H2 TLS", + "twp-reverse-http2", + "yarp-reverse-http2-to-https", + None, + "haproxy-reverse-http2-to-https", + "envoy-reverse-http2-to-https", + ), + ( + "H3→H1c", + "twp-reverse-http3-cleartext", + "yarp-reverse-http3-cleartext", + "nginx-reverse-http3-cleartext", + "haproxy-reverse-http3-cleartext", + "envoy-reverse-http3-cleartext", + ), + ( + "H3→h2c", + "twp-reverse-http3-to-h2c", + "yarp-reverse-http3-to-h2c", + None, + "haproxy-reverse-http3-to-h2c", + "envoy-reverse-http3-to-h2c", + ), +] + +COLORS = { + "Titanium": "#0B6E4F", + "YARP": "#C45C26", + "nginx": "#2F5D8C", + "HAProxy": "#8B4513", + "Envoy": "#6B5B95", +} + +PRODUCTS = ("Titanium", "YARP", "nginx", "HAProxy", "Envoy") + +# Workload clusters on the tiny chart (protocol mix, not body size). +INDUSTRY_WORKLOADS: List[Tuple[str, Dict[str, Optional[str]]]] = [ + ( + "WebSocket", + { + "Titanium": "twp-reverse-http1-tls-duplex-ws", + "YARP": "yarp-reverse-http1-tls-duplex-ws", + "nginx": "nginx-reverse-http1-tls-duplex-ws", + "HAProxy": "haproxy-reverse-http1-tls-duplex-ws", + "Envoy": "envoy-reverse-http1-tls-duplex-ws", + }, + ), + ( + "gRPC unary", + { + "Titanium": "twp-reverse-http2-grpc-unary", + "YARP": "yarp-reverse-http2-grpc-unary", + "nginx": "nginx-reverse-http2-grpc-unary", + "HAProxy": "haproxy-reverse-http2-grpc-unary", + "Envoy": "envoy-reverse-http2-grpc-unary", + }, + ), +] + +# Heavier practical chart: 64 KB GET/POST on typical reverse wires (+ 256 KB H1). +# label, twp, yarp, nginx, haproxy, envoy (None = product-impossible). +PRACTICAL_HEAVIER_ARMS: List[Tuple[str, str, str, Optional[str], Optional[str], Optional[str]]] = [ + ( + "GET 64 KB · H1 TLS→H1c", + "twp-reverse-http1-tls-body64k", + "yarp-reverse-http1-tls-body64k", + "nginx-reverse-http1-tls-body64k", + "haproxy-reverse-http1-tls-body64k", + "envoy-reverse-http1-tls-body64k", + ), + ( + "GET 64 KB · H2 TLS→H1c", + "twp-reverse-http2-cleartext-body64k", + "yarp-reverse-http2-body64k", + "nginx-reverse-http2-body64k", + "haproxy-reverse-http2-body64k", + "envoy-reverse-http2-body64k", + ), + ( + "GET 64 KB · H3→H1c", + "twp-reverse-http3-cleartext-body64k", + "yarp-reverse-http3-cleartext-body64k", + "nginx-reverse-http3-cleartext-body64k", + "haproxy-reverse-http3-cleartext-body64k", + "envoy-reverse-http3-cleartext-body64k", + ), + ( + "GET 64 KB · H2 TLS→H2 TLS", + "twp-reverse-http2-to-https-body64k", + "yarp-reverse-http2-to-https-body64k", + None, + "haproxy-reverse-http2-to-https-body64k", + "envoy-reverse-http2-to-https-body64k", + ), + ( + "POST 64 KB · H1 TLS→H1c", + "twp-reverse-http1-tls-post64k", + "yarp-reverse-http1-tls-post64k", + "nginx-reverse-http1-tls-post64k", + "haproxy-reverse-http1-tls-post64k", + "envoy-reverse-http1-tls-post64k", + ), + ( + "GET 256 KB · H1 TLS→H1c", + "twp-reverse-http1-tls-body256k", + "yarp-reverse-http1-tls-body256k", + "nginx-reverse-http1-tls-body256k", + "haproxy-reverse-http1-tls-body256k", + "envoy-reverse-http1-tls-body256k", + ), +] + +WIRE_COUNT = len(PRACTICAL_ARMS) +HEAVIER_COUNT = len(PRACTICAL_HEAVIER_ARMS) + +OS_SPECS = ( + ("linux", "Linux", ("ubuntu-latest",)), + ("windows", "Windows", ("windows-latest",)), + ("macos", "macOS", ("macos-15-intel", "macos-latest")), +) + +TINY_FOOTER = ( + "Wires: tiny keep-alive GET (~56 B) · Workloads: WebSocket / gRPC (RPC/s) · " + "GitHub Actions 4-core / 16 GiB (macOS 14 GiB)" +) + +HEAVIER_FOOTER = ( + "64 KB GET/POST on typical reverse wires (H1/H2/H3 terminate, H2 origin, " + "256 KB H1) · GitHub Actions 4-core / 16 GiB (macOS 14 GiB)" +) + +# Practical wire label → (client, origin) cells in wiki Performance.md reverse tables. +WIKI_WIRE_CELLS: Dict[str, Tuple[str, str]] = { + "H1 TLS→H1c": ("HTTP/1 · TLS", "HTTP/1 · plain"), + "H1 TLS→H1 TLS": ("HTTP/1 · TLS", "HTTP/1 · TLS"), + "H2 TLS→H1c": ("HTTP/2 · TLS", "HTTP/1 · plain"), + "H2 TLS→H1 TLS": ("HTTP/2 · TLS", "HTTP/1 · TLS"), + "H2 TLS→h2c": ("HTTP/2 · TLS", "HTTP/2 · plain"), + "H2 TLS→H2 TLS": ("HTTP/2 · TLS", "HTTP/2 · TLS"), + "H3→H1c": ("HTTP/3 · QUIC", "HTTP/1 · plain"), + "H3→h2c": ("HTTP/3 · QUIC", "HTTP/2 · plain"), +} + +# Heavier chart wiki cells: (body_or_None_for_post, client, origin) → cluster index. +WIKI_HEAVIER_CELLS: Dict[Tuple[Optional[str], str, str], int] = { + ("64 KiB", "HTTP/1 · TLS", "HTTP/1 · plain"): 0, + ("64 KiB", "HTTP/2 · TLS", "HTTP/1 · plain"): 1, + ("64 KiB", "HTTP/3 · QUIC", "HTTP/1 · plain"): 2, + ("64 KiB", "HTTP/2 · TLS", "HTTP/2 · TLS"): 3, + (None, "HTTP/1 · TLS", "HTTP/1 · plain"): 4, # POST table (no Body col) + ("256 KiB", "HTTP/1 · TLS", "HTTP/1 · plain"): 5, +} + +HEADING_TO_OS = { + "Windows — Titanium vs nginx vs YARP": "windows", + "Linux — Titanium vs nginx vs YARP": "linux", + "macOS — Titanium vs nginx vs YARP": "macos", + "Windows — Titanium vs nginx vs HAProxy vs Envoy vs YARP": "windows", + "Linux — Titanium vs nginx vs HAProxy vs Envoy vs YARP": "linux", + "macOS — Titanium vs nginx vs HAProxy vs Envoy vs YARP": "macos", +} + +CELL_RE = re.compile(r"\*{0,2}(\d[\d,]*)") + + +def parse_rps_cell(cell: str) -> Optional[float]: + t = cell.strip() + if "Not possible" in t or "Not measured" in t or t in ("", "—", "-"): + return None + m = CELL_RE.search(t.replace(",", "")) + if not m: + return None + v = float(m.group(1).replace(",", "")) + if v == 0: + return None + return v + + +def _product_row_from_cols(cols: Sequence[str], *, offset: int = 0) -> Dict[str, Optional[float]]: + """Parse TWP/nginx/HAProxy/Envoy/YARP sustain columns (offset skips Scenario).""" + # cols: [Client, Origin, TWP, TWP peak, nginx, nginx peak, HAProxy, ..., YARP, YARP peak] + i = offset + return { + "Titanium": parse_rps_cell(cols[i + 2]), + "nginx": parse_rps_cell(cols[i + 4]), + "HAProxy": parse_rps_cell(cols[i + 6]), + "Envoy": parse_rps_cell(cols[i + 8]), + "YARP": parse_rps_cell(cols[i + 10]), + } + + +def parse_wiki_practical(md: str) -> Dict[str, Dict[str, List[Optional[float]]]]: + """Build per-OS product series aligned with PRACTICAL_ARMS + INDUSTRY_WORKLOADS.""" + empty = {p: [None] * (WIRE_COUNT + len(INDUSTRY_WORKLOADS)) for p in PRODUCTS} + out: Dict[str, Dict[str, List[Optional[float]]]] = { + "windows": {p: list(empty[p]) for p in PRODUCTS}, + "linux": {p: list(empty[p]) for p in PRODUCTS}, + "macos": {p: list(empty[p]) for p in PRODUCTS}, + } + + wire_index = {label: i for i, (label, *_rest) in enumerate(PRACTICAL_ARMS)} + ws_idx = WIRE_COUNT + 0 + grpc_idx = WIRE_COUNT + 1 + + # --- reverse 5×5 → practical wires --- + current_os: Optional[str] = None + in_reverse = False + in_table = False + for line in md.splitlines(): + if line.startswith("## "): + title = line[3:].strip() + current_os = HEADING_TO_OS.get(title) + in_reverse = False + in_table = False + continue + if current_os is None: + continue + if line.startswith("### Reverse"): + in_reverse = True + in_table = False + continue + if line.startswith("### ") and not line.startswith("### Reverse"): + in_reverse = False + in_table = False + continue + if not in_reverse: + continue + if line.startswith("| Client | Origin |"): + in_table = True + continue + if in_table and line.startswith("|---"): + continue + if in_table and line.startswith("|"): + cols = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cols) < 12: + continue + client, origin = cols[0], cols[1] + vals = _product_row_from_cols(cols) + for label, cell in WIKI_WIRE_CELLS.items(): + if (client, origin) != cell: + continue + idx = wire_index[label] + for product in PRODUCTS: + out[current_os][product][idx] = vals[product] + elif in_table and not line.startswith("|"): + in_table = False + + # --- WebSocket from architecture-sensitive --- + arch_os: Optional[str] = None + in_arch_table = False + for line in md.splitlines(): + if line.startswith("#### Windows"): + arch_os = "windows" + in_arch_table = False + continue + if line.startswith("#### Linux"): + arch_os = "linux" + in_arch_table = False + continue + if line.startswith("#### ") and arch_os is not None: + arch_os = None + in_arch_table = False + continue + if arch_os is None: + continue + if line.startswith("| Scenario | Client | Origin |"): + in_arch_table = True + continue + if in_arch_table and line.startswith("|---"): + continue + if in_arch_table and line.startswith("|"): + cols = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cols) < 13: + continue + if "WebSocket" in cols[0]: + vals = _product_row_from_cols(cols, offset=1) + for product in PRODUCTS: + out[arch_os][product][ws_idx] = vals[product] + elif in_arch_table and not line.startswith("|"): + in_arch_table = False + + # --- Unary gRPC --- + in_grpc = False + in_grpc_table = False + grpc_os_map = {"Windows": "windows", "Linux": "linux", "macOS": "macos"} + for line in md.splitlines(): + if line.startswith("## Unary gRPC"): + in_grpc = True + in_grpc_table = False + continue + if in_grpc and line.startswith("## ") and not line.startswith("## Unary gRPC"): + break + if not in_grpc: + continue + if line.startswith("| OS |"): + in_grpc_table = True + continue + if in_grpc_table and line.startswith("|---"): + continue + if in_grpc_table and line.startswith("|"): + cols = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cols) < 6: + continue + key = grpc_os_map.get(cols[0]) + if not key: + continue + out[key]["Titanium"][grpc_idx] = parse_rps_cell(cols[1]) + out[key]["YARP"][grpc_idx] = parse_rps_cell(cols[2]) + out[key]["nginx"][grpc_idx] = parse_rps_cell(cols[3]) + out[key]["HAProxy"][grpc_idx] = parse_rps_cell(cols[4]) + out[key]["Envoy"][grpc_idx] = parse_rps_cell(cols[5]) + elif in_grpc_table and not line.startswith("|"): + in_grpc_table = False + + return out + + +def parse_wiki_heavier(md: str) -> Dict[str, Dict[str, List[Optional[float]]]]: + """Build per-OS product series aligned with PRACTICAL_HEAVIER_ARMS from wiki tables.""" + empty = {p: [None] * HEAVIER_COUNT for p in PRODUCTS} + out: Dict[str, Dict[str, List[Optional[float]]]] = { + "windows": {p: list(empty[p]) for p in PRODUCTS}, + "linux": {p: list(empty[p]) for p in PRODUCTS}, + "macos": {p: list(empty[p]) for p in PRODUCTS}, + } + + # --- heavier reverse GET --- + body_os: Optional[str] = None + in_body_table = False + for line in md.splitlines(): + if line.startswith("### Windows — heavier reverse GET"): + body_os = "windows" + in_body_table = False + continue + if line.startswith("### Linux — heavier reverse GET"): + body_os = "linux" + in_body_table = False + continue + if line.startswith("### ") and body_os is not None and "heavier reverse GET" not in line: + body_os = None + in_body_table = False + continue + if body_os is None: + continue + if line.startswith("| Body | Client | Origin |") or line.startswith("| Size | Client | Origin |"): + in_body_table = True + continue + if in_body_table and line.startswith("|---"): + continue + if in_body_table and line.startswith("|"): + cols = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cols) < 13: + continue + key = (cols[0], cols[1], cols[2]) + idx = WIKI_HEAVIER_CELLS.get(key) + if idx is None: + continue + vals = _product_row_from_cols(cols, offset=1) + for product in PRODUCTS: + out[body_os][product][idx] = vals[product] + elif in_body_table and not line.startswith("|"): + in_body_table = False + + # --- POST 64 KiB (H1 TLS→H1c only for practical heavier chart) --- + post_os: Optional[str] = None + in_post_table = False + for line in md.splitlines(): + if line.startswith("### Windows — POST"): + post_os = "windows" + in_post_table = False + continue + if line.startswith("### Linux — POST"): + post_os = "linux" + in_post_table = False + continue + if line.startswith("### ") and post_os is not None and "POST" not in line: + post_os = None + in_post_table = False + continue + if post_os is None: + continue + if line.startswith("| Client | Origin |"): + in_post_table = True + continue + if in_post_table and line.startswith("|---"): + continue + if in_post_table and line.startswith("|"): + cols = [c.strip() for c in line.strip().strip("|").split("|")] + if len(cols) < 12: + continue + key = (None, cols[0], cols[1]) + idx = WIKI_HEAVIER_CELLS.get(key) + if idx is None: + continue + vals = _product_row_from_cols(cols) + for product in PRODUCTS: + out[post_os][product][idx] = vals[product] + elif in_post_table and not line.startswith("|"): + in_post_table = False + + return out + + +def series_has_any(series: Dict[str, List[Optional[float]]]) -> bool: + """True if any product has a positive sustain value.""" + for product in PRODUCTS: + for v in series.get(product) or []: + if v is not None and float(v) > 0: + return True + return False + + +def median(vals: Sequence[float]) -> Optional[float]: + if not vals: + return None + s = sorted(vals) + return s[(len(s) - 1) // 2] + + +def plot_packed_product_bars( + ax, + series: Dict[str, List[Optional[float]]], + x, + *, + products: Sequence[str] = PRODUCTS, + colors: Optional[Dict[str, str]] = None, + bar_width: float = 0.14, + legend_once: bool = True, + add_legend_labels: bool = True, +) -> float: + """Draw per-cluster packed bars; omit None and <=0 so no empty slots remain. + + Surviving peers keep a constant bar width and are packed edge-to-edge, then + centered on the cluster. Missing 0 / n/a slots collapse; remaining bars do + not grow to fill the five-product group. + """ + palette = colors or COLORS + labeled: set = set() + ymax = 1.0 + for i, xi in enumerate(x): + present: List[Tuple[str, float]] = [] + for product in products: + vals = series.get(product) + if not vals or i >= len(vals): + continue + v = vals[i] + if v is None: + continue + h = float(v) + if h <= 0: + continue + present.append((product, h)) + n = len(present) + if n == 0: + continue + start = float(xi) - (n - 1) * bar_width / 2.0 + for j, (product, h) in enumerate(present): + label = None + if add_legend_labels: + if legend_once: + if product not in labeled: + label = product + labeled.add(product) + else: + label = product + ax.bar( + start + j * bar_width, + h, + bar_width, + label=label, + color=palette[product], + edgecolor="white", + linewidth=0.4, + zorder=3, + ) + ymax = max(ymax, h) + return ymax + + +def arm_sustain_c64(csv_path: Path, arm: str) -> Optional[float]: + """Sustain RPS @ c=64. Median of SLO-passing repeats; 0 if all c=64 steps miss SLO.""" + rows = [r for r in csv.DictReader(csv_path.open(newline="")) if r.get("arm") == arm] + if not rows: + return None + # Group into ramp chunks of 4 concurrency steps when present; else last c=64 per pass. + steps = 4 + sustains: List[float] = [] + saw_c64 = False + i = 0 + while i + steps <= len(rows): + chunk = rows[i : i + steps] + c64_ok = [r for r in chunk if r.get("concurrency") == "64" and r.get("meets_slo") == "1"] + if c64_ok: + sustains.append(float(c64_ok[-1]["rps"])) + saw_c64 = True + else: + c64_any = [r for r in chunk if r.get("concurrency") == "64"] + if c64_any: + saw_c64 = True + i += steps + if sustains: + return median(sustains) + # Misaligned repeats (e.g. a pass missing c=64) — still use any SLO-pass c=64 rows. + ok_any = [ + float(r["rps"]) + for r in rows + if r.get("concurrency") == "64" and r.get("meets_slo") == "1" + ] + if ok_any: + return median(ok_any) + if not saw_c64: + return None + return 0.0 + + +def parse_path_list(values: Optional[Sequence[Union[Path, str]]]) -> List[Path]: + """Expand repeated flags and comma-separated paths.""" + out: List[Path] = [] + for v in values or []: + for part in str(v).split(","): + part = part.strip() + if part: + out.append(Path(part)) + return out + + +def find_csv(results_root: Path, os_keys: Iterable[str]) -> Optional[Path]: + for key in os_keys: + for pattern in ( + f"rps-csv-{key}/*.csv", + f"rps-csv-{key}-*/*.csv", + f"{key}/*.csv", + f"**/*{key}*/**/*.csv", + f"**/*{key}*.csv", + ): + hits = sorted(results_root.glob(pattern)) + if hits: + return hits[0] + return None + + +def find_csvs(results_roots: Sequence[Path], os_keys: Iterable[str]) -> List[Path]: + seen: set = set() + out: List[Path] = [] + for root in results_roots: + found = find_csv(root, os_keys) + if found and found not in seen: + seen.add(found) + out.append(found) + return out + + +def gha_dl_sibling_roots(results_root: Path) -> List[Path]: + """Other gha-dl/ folders beside a primary run (workload CSV defaults).""" + run_dir = results_root + if not run_dir.is_dir(): + run_dir = results_root.parent + gha_dl = run_dir.parent + if gha_dl.name != "gha-dl": + return [run_dir] + return sorted(p for p in gha_dl.iterdir() if p.is_dir()) + + +def arm_sustain_union(csv_paths: Sequence[Path], arm: Optional[str]) -> Optional[float]: + """Best sustain across CSVs (overlay peer-fix runs over older 0-RPS product rows).""" + if arm is None: + return None + best: Optional[float] = None + for path in csv_paths: + val = arm_sustain_c64(path, arm) + if val is None: + continue + if best is None or val > best: + best = val + return best + + +def grpc_arm_union(csv_paths: Sequence[Path], prefix: str, fallback: Optional[str]) -> Optional[str]: + """Resolve a *-grpc-* arm; prefer -reverse-http2-grpc-unary when present.""" + preferred = fallback or f"{prefix}-reverse-http2-grpc-unary" + for path in csv_paths: + arms = {r.get("arm") for r in csv.DictReader(path.open(newline="")) if r.get("arm")} + if preferred in arms: + return preferred + matches = sorted(a for a in arms if a.startswith(f"{prefix}-") and "-grpc-" in a) + if matches: + return matches[0] + return fallback + + +def collect_series(csv_paths: Sequence[Path]) -> Dict[str, List[Optional[float]]]: + """Return product → list of RPS aligned with PRACTICAL_ARMS (None = missing).""" + out: Dict[str, List[Optional[float]]] = {p: [] for p in PRODUCTS} + for _label, twp, yarp, nginx, haproxy, envoy in PRACTICAL_ARMS: + out["Titanium"].append(arm_sustain_union(csv_paths, twp)) + out["YARP"].append(arm_sustain_union(csv_paths, yarp)) + for product, arm in (("nginx", nginx), ("HAProxy", haproxy), ("Envoy", envoy)): + if arm is None: + out[product].append(None) + else: + out[product].append(arm_sustain_union(csv_paths, arm)) + return out + + +def collect_industry_series( + arch_csvs: Sequence[Path], + grpc_csvs: Sequence[Path], +) -> Dict[str, List[Optional[float]]]: + """WebSocket / gRPC @ c=64 from mode-specific CSV unions.""" + sources = { + "WebSocket": arch_csvs, + "gRPC unary": grpc_csvs, + } + prefix_map = { + "Titanium": "twp", + "YARP": "yarp", + "nginx": "nginx", + "HAProxy": "haproxy", + "Envoy": "envoy", + } + out: Dict[str, List[Optional[float]]] = {p: [] for p in PRODUCTS} + for label, arms in INDUSTRY_WORKLOADS: + csv_paths = sources[label] + for product in PRODUCTS: + arm = arms.get(product) + if label == "gRPC unary": + arm = grpc_arm_union(csv_paths, prefix_map[product], arm) + out[product].append(arm_sustain_union(csv_paths, arm)) + return out + + +def collect_heavier_series( + bodies_csvs: Sequence[Path], + post_csvs: Sequence[Path], +) -> Dict[str, List[Optional[float]]]: + """Return product → list of RPS aligned with PRACTICAL_HEAVIER_ARMS.""" + out: Dict[str, List[Optional[float]]] = {p: [] for p in PRODUCTS} + for label, twp, yarp, nginx, haproxy, envoy in PRACTICAL_HEAVIER_ARMS: + csv_paths = post_csvs if "POST" in label else bodies_csvs + out["Titanium"].append(arm_sustain_union(csv_paths, twp)) + out["YARP"].append(arm_sustain_union(csv_paths, yarp)) + for product, arm in (("nginx", nginx), ("HAProxy", haproxy), ("Envoy", envoy)): + if arm is None: + out[product].append(None) + else: + out[product].append(arm_sustain_union(csv_paths, arm)) + return out + + +def merge_series( + wire: Dict[str, List[Optional[float]]], + workload: Dict[str, List[Optional[float]]], +) -> Dict[str, List[Optional[float]]]: + return {p: list(wire[p]) + list(workload[p]) for p in PRODUCTS} + + +def render_chart( + series: Dict[str, List[Optional[float]]], + os_title: str, + out_path: Path, + title_suffix: str, + labels: Optional[Sequence[str]] = None, + footer: Optional[str] = None, + workload_start: Optional[int] = None, + title_template: str = "Reverse proxy RPS — {os}", +) -> None: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import numpy as np + + labels = list(labels) if labels is not None else [a[0] for a in PRACTICAL_ARMS] + x = np.arange(len(labels), dtype=float) + + fig_w = 16.0 if len(labels) >= 10 else 14.5 + fig, ax = plt.subplots(figsize=(fig_w, 5.8), dpi=140) + ymax = plot_packed_product_bars(ax, series, x) + + if workload_start is not None and 0 < workload_start < len(labels): + ax.axvline( + workload_start - 0.5, + color="#888888", + linestyle="--", + linewidth=1.0, + zorder=2, + alpha=0.7, + ) + + ax.set_ylabel("Sustain RPS @ concurrency 64", fontsize=13) + title = title_template.format(os=os_title) + if title_suffix: + title = f"{title} {title_suffix}" + ax.set_title(title, fontsize=15) + ax.set_xticks(x) + ax.set_xticklabels(labels, rotation=18, ha="right", fontsize=12) + ax.tick_params(axis="y", labelsize=12) + ax.set_ylim(0, ymax * 1.12) + ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _: f"{int(v):,}")) + ax.grid(axis="y", linestyle=":", alpha=0.45, zorder=0) + ax.legend( + loc="upper right", + framealpha=0.92, + ncols=5, + fontsize=13, + handlelength=1.8, + handleheight=1.4, + borderpad=0.6, + labelspacing=0.45, + columnspacing=1.4, + handletextpad=0.5, + ) + ax.set_axisbelow(True) + fig.text( + 0.01, + 0.01, + footer or TINY_FOOTER, + fontsize=12, + color="#444444", + ) + fig.tight_layout(rect=(0, 0.06, 1, 1)) + out_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(out_path, bbox_inches="tight") + plt.close(fig) + + +def _print_series(labels: Sequence[str], series: Dict[str, List[Optional[float]]]) -> None: + for i, label in enumerate(labels): + parts = [] + for product in PRODUCTS: + v = series[product][i] if i < len(series[product]) else None + parts.append("n/a" if v is None else f"{v:.0f}") + print( + f" {label}: TWP={parts[0]} YARP={parts[1]} nginx={parts[2]} " + f"HAProxy={parts[3]} Envoy={parts[4]}" + ) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--results-root", + action="append", + default=[], + help="gha-dl/ folder(s) for compare-product; comma-separated or repeat flag", + ) + ap.add_argument( + "--from-wiki", + type=Path, + help="Render from wiki/Performance.md reverse + heavier + gRPC tables (no CSV)", + ) + ap.add_argument("--csv-linux", type=Path) + ap.add_argument("--csv-windows", type=Path) + ap.add_argument("--csv-macos", type=Path) + ap.add_argument("--out-dir", type=Path, default=Path("wiki/images")) + ap.add_argument("--title-suffix", default="", help="e.g. '@ af6feb9c'") + ap.add_argument( + "--os", + action="append", + choices=("linux", "windows", "macos"), + help="Subset of OS charts (default: all with a CSV)", + ) + ap.add_argument( + "--bodies-root", + action="append", + default=[], + help="compare-bodies run root(s) for practical heavier GET clusters", + ) + ap.add_argument( + "--post-root", + action="append", + default=[], + help="compare-post run root(s) for practical heavier POST cluster", + ) + ap.add_argument( + "--arch-root", + action="append", + default=[], + help="compare-arch run root(s) for WebSocket cluster", + ) + ap.add_argument( + "--grpc-root", + action="append", + default=[], + help="compare-grpc run root(s) for gRPC unary cluster", + ) + args = ap.parse_args() + + suffix = args.title_suffix.strip() + written: List[Path] = [] + tiny_labels = [a[0] for a in PRACTICAL_ARMS] + [w[0] for w in INDUSTRY_WORKLOADS] + heavier_labels = [a[0] for a in PRACTICAL_HEAVIER_ARMS] + heavier_title = "Reverse proxy RPS — {os} 64 KB" + + if args.from_wiki: + md = args.from_wiki.read_text(encoding="utf-8") + by_os = parse_wiki_practical(md) + by_os_heavier = parse_wiki_heavier(md) + wanted = set(args.os) if args.os else {"linux", "windows", "macos"} + for key, title, _folder_keys in OS_SPECS: + if key not in wanted: + continue + series = by_os[key] + out = args.out_dir / f"rps-practical-{key}.png" + render_chart( + series, + title, + out, + suffix, + labels=tiny_labels, + footer=TINY_FOOTER, + workload_start=WIRE_COUNT, + ) + written.append(out) + print(f"{title} (tiny) <- wiki {args.from_wiki}") + _print_series(tiny_labels, series) + print(f" wrote {out}") + + heavier = by_os_heavier[key] + if not series_has_any(heavier): + print(f"{title} (64 KB) <- wiki: skip (no data)") + continue + out_h = args.out_dir / f"rps-practical-heavier-{key}.png" + render_chart( + heavier, + title, + out_h, + suffix, + labels=heavier_labels, + footer=HEAVIER_FOOTER, + title_template=heavier_title, + ) + written.append(out_h) + print(f"{title} (64 KB) <- wiki {args.from_wiki}") + _print_series(heavier_labels, heavier) + print(f" wrote {out_h}") + if not written: + print("No charts written.", file=__import__("sys").stderr) + return 2 + return 0 + + results_roots = parse_path_list(args.results_root) + csv_lists_by_os: Dict[str, List[Path]] = {} + if args.csv_linux: + csv_lists_by_os["linux"] = [args.csv_linux] + if args.csv_windows: + csv_lists_by_os["windows"] = [args.csv_windows] + if args.csv_macos: + csv_lists_by_os["macos"] = [args.csv_macos] + if results_roots: + for key, _title, folder_keys in OS_SPECS: + if key in csv_lists_by_os: + continue + found = find_csvs(results_roots, folder_keys) + if found: + csv_lists_by_os[key] = found + + if not csv_lists_by_os: + ap.error( + "Provide --from-wiki, --results-root, and/or --csv-linux/--csv-windows/--csv-macos" + ) + + sibling_roots = gha_dl_sibling_roots(results_roots[0]) if results_roots else [] + bodies_roots = parse_path_list(args.bodies_root) or results_roots or sibling_roots + post_roots = parse_path_list(args.post_root) or results_roots or sibling_roots + arch_roots = parse_path_list(args.arch_root) or results_roots or sibling_roots + grpc_roots = parse_path_list(args.grpc_root) or results_roots or sibling_roots + + wanted = set(args.os) if args.os else set(csv_lists_by_os) + + for key, title, folder_keys in OS_SPECS: + if key not in wanted or key not in csv_lists_by_os: + continue + paths = csv_lists_by_os[key] + bodies_csvs = find_csvs(bodies_roots, folder_keys) + post_csvs = find_csvs(post_roots, folder_keys) + arch_csvs = find_csvs(arch_roots, folder_keys) + grpc_csvs = find_csvs(grpc_roots, folder_keys) + wire = collect_series(paths) + workload = collect_industry_series(arch_csvs, grpc_csvs) + series = merge_series(wire, workload) + out = args.out_dir / f"rps-practical-{key}.png" + render_chart( + series, + title, + out, + suffix, + labels=tiny_labels, + footer=TINY_FOOTER, + workload_start=WIRE_COUNT, + ) + written.append(out) + src = ", ".join(str(p) for p in paths) + print(f"{title} (tiny) <- product: {src}") + print(f" workloads <- arch={len(arch_csvs)} grpc={len(grpc_csvs)} CSV(s)") + _print_series(tiny_labels, series) + print(f" wrote {out}") + + heavier = collect_heavier_series(bodies_csvs, post_csvs) + if not series_has_any(heavier): + print(f"{title} (64 KB) <- skip (no bodies/post data)") + continue + out_h = args.out_dir / f"rps-practical-heavier-{key}.png" + render_chart( + heavier, + title, + out_h, + suffix, + labels=heavier_labels, + footer=HEAVIER_FOOTER, + title_template=heavier_title, + ) + written.append(out_h) + print( + f"{title} (64 KB) <- bodies={len(bodies_csvs)} post={len(post_csvs)} CSV(s)" + ) + _print_series(heavier_labels, heavier) + print(f" wrote {out_h}") + + if not written: + print("No charts written (no matching CSVs).", file=__import__("sys").stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/RpsLoadProbe/requirements-charts.txt b/tools/RpsLoadProbe/requirements-charts.txt new file mode 100644 index 000000000..fc622160f --- /dev/null +++ b/tools/RpsLoadProbe/requirements-charts.txt @@ -0,0 +1 @@ +matplotlib>=3.8,<4 diff --git a/tools/RpsLoadProbe/run-rps.ps1 b/tools/RpsLoadProbe/run-rps.ps1 index 2803ad0c7..cf47a0ed2 100644 --- a/tools/RpsLoadProbe/run-rps.ps1 +++ b/tools/RpsLoadProbe/run-rps.ps1 @@ -10,19 +10,41 @@ param( [ValidateSet( 'compare', 'compare-http2', 'compare-tls', 'compare-terminate', 'compare-same', 'compare-bridges', - 'compare-http3-cleartext', 'compare-mitm', 'compare-matrix', 'compare-product', 'compare-product-smoke', 'compare-spot', 'compare-ceiling', - 'compare-bodies', 'compare-post', 'compare-lossy', 'compare-tls-cost', 'compare-arch', 'compare-saturation', + 'compare-http3-cleartext', 'compare-nginx-https', 'compare-haproxy-smoke', 'compare-envoy-smoke', 'compare-mitm', 'compare-matrix', 'compare-product', 'compare-product-smoke', 'compare-spot', 'compare-ceiling', + 'compare-bodies', 'compare-post', 'compare-lossy', 'compare-tls-cost', 'compare-arch', 'compare-grpc', 'compare-saturation', 'compare-editions', 'compare-cross-version', 'origin-direct', 'explicit-pool-sweep', - 'reverse-http1', 'bare-reverse-http1', 'nginx-reverse-http1', 'yarp-reverse-http1', - 'reverse-http1-tls', 'bare-reverse-http1-tls', 'nginx-reverse-http1-tls', 'yarp-reverse-http1-tls', + 'reverse-http1', 'bare-reverse-http1', 'nginx-reverse-http1', 'haproxy-reverse-http1', 'envoy-reverse-http1', 'yarp-reverse-http1', + 'reverse-http1-tls', 'bare-reverse-http1-tls', 'nginx-reverse-http1-tls', 'haproxy-reverse-http1-tls', 'envoy-reverse-http1-tls', 'yarp-reverse-http1-tls', 'reverse-http1-to-https', 'yarp-reverse-http1-to-https', + 'nginx-reverse-http1-to-https', 'nginx-reverse-http1-tls-to-https', + 'haproxy-reverse-http1-to-https', 'haproxy-reverse-http1-tls-to-https', + 'envoy-reverse-http1-to-https', 'envoy-reverse-http1-tls-to-https', 'https-mitm', 'http-mitm', 'reverse-http1-mitm', 'mitm-http2-to-http1', 'mitm-http3-to-http1', 'reverse-http2', 'reverse-http2-cleartext', 'reverse-http2-to-h2c', 'yarp-reverse-http2-to-h2c', 'reverse-h2c', 'yarp-reverse-h2c', 'reverse-h2c-to-h2c', 'yarp-reverse-h2c-to-h2c', 'reverse-h2c-to-h1', 'yarp-reverse-h2c-to-h1', 'reverse-h2c-to-https', 'yarp-reverse-h2c-to-https', 'reverse-h2c-to-h3', 'yarp-reverse-h2c-to-h3', - 'nginx-reverse-http2', 'nginx-reverse-http3-cleartext', 'yarp-reverse-http2', 'yarp-reverse-http2-to-https', + 'nginx-reverse-http2', 'nginx-reverse-http2-to-https-http1', 'nginx-reverse-http3-cleartext', + 'nginx-reverse-http3-to-https-http1', + 'haproxy-reverse-http2', 'haproxy-reverse-http2-to-https-http1', 'haproxy-reverse-http3-cleartext', + 'haproxy-reverse-http3-to-https-http1', + 'envoy-reverse-http2', 'envoy-reverse-http2-to-https-http1', 'envoy-reverse-http3-cleartext', + 'envoy-reverse-http3-to-https-http1', + 'nginx-reverse-h2c-to-h1', 'nginx-reverse-h2c-to-https', + 'haproxy-reverse-http1-plain-to-h2c', 'haproxy-reverse-http1-plain-to-http2', 'haproxy-reverse-http1-plain-to-http3', + 'haproxy-reverse-http1-to-h2c', 'haproxy-reverse-http11-to-http2', 'haproxy-reverse-http1-to-http3', + 'haproxy-reverse-h2c-to-h1', 'haproxy-reverse-h2c-to-https', 'haproxy-reverse-h2c-to-h2c', + 'haproxy-reverse-h2c', 'haproxy-reverse-h2c-to-h3', + 'haproxy-reverse-http2-to-h2c', 'haproxy-reverse-http2-to-https', 'haproxy-reverse-http2-to-http3', + 'haproxy-reverse-http3-to-h2c', 'haproxy-reverse-http3-to-http2', 'haproxy-reverse-http3-to-http3', + 'envoy-reverse-http1-plain-to-h2c', 'envoy-reverse-http1-plain-to-http2', 'envoy-reverse-http1-plain-to-http3', + 'envoy-reverse-http1-to-h2c', 'envoy-reverse-http11-to-http2', 'envoy-reverse-http1-to-http3', + 'envoy-reverse-h2c-to-h1', 'envoy-reverse-h2c-to-https', 'envoy-reverse-h2c-to-h2c', + 'envoy-reverse-h2c', 'envoy-reverse-h2c-to-h3', + 'envoy-reverse-http2-to-h2c', 'envoy-reverse-http2-to-https', 'envoy-reverse-http2-to-http3', + 'envoy-reverse-http3-to-h2c', 'envoy-reverse-http3-to-http2', 'envoy-reverse-http3-to-http3', + 'yarp-reverse-http2', 'yarp-reverse-http2-to-https', 'yarp-reverse-http2-to-https-http1', 'yarp-reverse-http1-tls-to-https', 'yarp-reverse-http3-to-https-http1', 'reverse-http3', 'reverse-http3-cleartext', 'yarp-reverse-http3-cleartext', 'reverse-http11-to-http2', 'yarp-reverse-http11-to-http2', @@ -45,10 +67,13 @@ param( [string] $Mode = 'compare', [string] $NginxPath, + [string] $HaproxyPath, + [string] $EnvoyPath, [string] $Concurrency = '8,16,24,32,48,64,128,256,512', [int] $WarmupSec = 5, [int] $DurationSec = 20, [int] $Repeats = 1, + [string] $ArmShard = 'all', [string] $ResultsDir, [ValidateSet('GET', 'POST')] [string] $Method = 'GET', @@ -74,7 +99,7 @@ if (-not $ResultsDir) { Write-Host '' Write-Host 'RpsLoadProbe — close browsers / heavy apps before a publishable run.' -ForegroundColor Yellow -Write-Host "Mode=$Mode concurrency=$Concurrency warmup=${WarmupSec}s duration=${DurationSec}s repeats=$Repeats" -ForegroundColor Cyan +Write-Host "Mode=$Mode concurrency=$Concurrency warmup=${WarmupSec}s duration=${DurationSec}s repeats=$Repeats arm-shard=$ArmShard" -ForegroundColor Cyan Write-Host '' if (-not $SkipBuild) { @@ -130,6 +155,7 @@ $probeArgs = $probePrefix + @( '--warmup-sec', $WarmupSec, '--duration-sec', $DurationSec, '--repeats', $Repeats, + '--arm-shard', $ArmShard, '--results-dir', $ResultsDir, '--method', $Method, '--delay-ms', $DelayMs, @@ -147,6 +173,12 @@ if ($NoKeepAlive) { if ($NginxPath) { $probeArgs += @('--nginx-path', $NginxPath) } +if ($HaproxyPath) { + $probeArgs += @('--haproxy-path', $HaproxyPath) +} +if ($EnvoyPath) { + $probeArgs += @('--envoy-path', $EnvoyPath) +} if ($NoStopOnSloFail) { $probeArgs += '--no-stop-on-slo-fail' } diff --git a/tools/RpsLoadProbe/run-spot-matrix.ps1 b/tools/RpsLoadProbe/run-spot-matrix.ps1 index 0a0ec9716..704ec20c9 100644 --- a/tools/RpsLoadProbe/run-spot-matrix.ps1 +++ b/tools/RpsLoadProbe/run-spot-matrix.ps1 @@ -1,12 +1,12 @@ -# Spot gate: compare-spot @ c=64, Full÷Reverse >= 0.70 and reverse TWP÷YARP >= 0.95. +# Spot gate: compare-spot @ c=64, Full÷Reverse >= 0.50 and reverse TWP÷YARP >= 0.70. # Skip TWP÷YARP when YARP did not SLO-pass (same policy as validate-compare-product-gates.ps1). [CmdletBinding()] param( [int] $Concurrency = 64, [int] $WarmupSec = 2, [int] $DurationSec = 8, - [double] $MitmRatioGate = 0.70, - [double] $ReverseYarpGate = 0.95, + [double] $MitmRatioGate = 0.50, + [double] $ReverseYarpGate = 0.70, [switch] $SkipBuild ) diff --git a/tools/RpsLoadProbe/validate-all-compare-product-arms.ps1 b/tools/RpsLoadProbe/validate-all-compare-product-arms.ps1 index 4c78bc9eb..a794dd5d9 100644 --- a/tools/RpsLoadProbe/validate-all-compare-product-arms.ps1 +++ b/tools/RpsLoadProbe/validate-all-compare-product-arms.ps1 @@ -1,10 +1,17 @@ # Full compare-product gate validation (all WIRES rows, Win+Lin+Mac, median of 3 GHA runs). param( [Parameter(Mandatory)] [string[]] $RunIds, - [double] $MitmGate = 0.70, - [double] $ReverseYarpGate = 0.95, + [double] $MitmLiteGate = 0.50, + [double] $MitmFullGate = 0.50, + # Backward-compatible alias: if set, applies to both Lite and Full. + [double] $MitmGate = -1, + [double] $ReverseYarpGate = 0.70, [string] $BaselineRunId = '32960766249' ) +if ($MitmGate -ge 0) { + $MitmLiteGate = $MitmGate + $MitmFullGate = $MitmGate +} $ErrorActionPreference = 'Stop' if ($RunIds.Count -eq 1 -and $RunIds[0] -match ',') { @@ -86,13 +93,13 @@ foreach ($os in @('windows-latest', 'ubuntu-latest', 'macos-15-intel')) { if ($null -ne $lite) { $lr = $lite / $rev - $ok = $lr -ge $MitmGate + $ok = $lr -ge $MitmLiteGate if (-not $ok) { $failed += "$os $($w.C)->$($w.O) Lite=$([math]::Round($lr,3))" } Write-Host ("MITM Lite {0}->{1}: {2:N3} {3}" -f $w.C, $w.O, $lr, $(if($ok){'OK'}else{'FAIL'})) } if ($null -ne $full) { $fr = $full / $rev - $ok = $fr -ge $MitmGate + $ok = $fr -ge $MitmFullGate if (-not $ok) { $failed += "$os $($w.C)->$($w.O) Full=$([math]::Round($fr,3))" } Write-Host ("MITM Full {0}->{1}: {2:N3} {3}" -f $w.C, $w.O, $fr, $(if($ok){'OK'}else{'FAIL'})) } diff --git a/tools/RpsLoadProbe/validate-arm-shards.ps1 b/tools/RpsLoadProbe/validate-arm-shards.ps1 new file mode 100644 index 000000000..6e2749faf --- /dev/null +++ b/tools/RpsLoadProbe/validate-arm-shards.ps1 @@ -0,0 +1,90 @@ +# Local asserts: comparison-group shards are exclusive, complete, and keep gate pairs atomic. +param( + [string] $Configuration = 'Release' +) + +$ErrorActionPreference = 'Stop' +$probeDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Push-Location $probeDir +try { + dotnet build -c $Configuration --nologo -v q | Out-Host + $dll = Join-Path $probeDir "bin\$Configuration\net10.0\RpsLoadProbe.dll" + if (-not (Test-Path $dll)) { throw "Build output missing: $dll" } + + function Get-Arms([string] $Mode, [string] $Shard = 'all') { + $args = @('run', '--no-build', '-c', $Configuration, '--', '--ramp', '--mode', $Mode, '--print-arms') + if ($Shard -ne 'all') { + $args += @('--arm-shard', $Shard) + } + & dotnet @args 2>$null | Where-Object { $_ -and $_ -notmatch '^\[' } + } + + Write-Host 'Validating compare-product 3-way comparison-group shards...' -ForegroundColor Cyan + $all = @(Get-Arms 'compare-product') + if ($all.Count -lt 10) { throw "Expected many product arms; got $($all.Count)" } + + $s1 = @(Get-Arms 'compare-product' '1/3') + $s2 = @(Get-Arms 'compare-product' '2/3') + $s3 = @(Get-Arms 'compare-product' '3/3') + $union = @($s1 + $s2 + $s3 | Select-Object -Unique) + if ($union.Count -ne $all.Count) { + throw "Union size $($union.Count) != all $($all.Count)" + } + $missing = $all | Where-Object { $union -notcontains $_ } + if ($missing) { throw "Missing from union: $($missing -join ', ')" } + + $inter12 = $s1 | Where-Object { $s2 -contains $_ } + $inter13 = $s1 | Where-Object { $s3 -contains $_ } + $inter23 = $s2 | Where-Object { $s3 -contains $_ } + if ($inter12 -or $inter13 -or $inter23) { + throw "Shard intersection not empty" + } + + # Gate pairs (Lite/Full/Reverse) and TWP+YARP must share a shard when both present. + $pairs = @( + @('twp-reverse-http1', 'twp-mitm-http1', 'twp-mitm-full-http1'), + @('twp-reverse-http2', 'twp-mitm-http2', 'twp-mitm-full-http2', 'yarp-reverse-http2-to-https'), + @('twp-reverse-http2-cleartext', 'yarp-reverse-http2', 'nginx-reverse-http2'), + @('twp-mitm-https-connect', 'twp-mitm-full-https-connect') + ) + $shards = @(@{ Name = '1/3'; Arms = $s1 }, @{ Name = '2/3'; Arms = $s2 }, @{ Name = '3/3'; Arms = $s3 }) + foreach ($pair in $pairs) { + $present = @($pair | Where-Object { $all -contains $_ }) + if ($present.Count -lt 2) { continue } + $homes = @() + foreach ($arm in $present) { + $shardHome = ($shards | Where-Object { $_.Arms -contains $arm } | Select-Object -First 1).Name + if (-not $shardHome) { throw "Arm $arm missing from shards" } + $homes += $shardHome + } + $distinct = $homes | Select-Object -Unique + if ($distinct.Count -ne 1) { + throw "Pair split across shards: $($present -join ', ') -> $($homes -join ', ')" + } + } + + Write-Host "OK: product $($all.Count) arms -> $($s1.Count)/$($s2.Count)/$($s3.Count) by comparison group" -ForegroundColor Green + + Write-Host 'Validating compare-product-smoke 2-way shards...' -ForegroundColor Cyan + $smokeAll = @(Get-Arms 'compare-product-smoke') + $sm1 = @(Get-Arms 'compare-product-smoke' '1/2') + $sm2 = @(Get-Arms 'compare-product-smoke' '2/2') + if ((@($sm1 + $sm2 | Select-Object -Unique).Count) -ne $smokeAll.Count) { + throw 'Smoke shard union incomplete' + } + Write-Host "OK: smoke $($smokeAll.Count) arms -> $($sm1.Count)/$($sm2.Count)" -ForegroundColor Green + + Write-Host 'Validating compare-grpc single group...' -ForegroundColor Cyan + $grpc = @(Get-Arms 'compare-grpc') + if ($grpc.Count -lt 2) { throw "Expected grpc arms; got $($grpc.Count)" } + $g1 = @(Get-Arms 'compare-grpc' '1/2') + $g2 = @(Get-Arms 'compare-grpc' '2/2') + # One comparison group → all arms on shard 1, none on shard 2 + if ($g1.Count -ne $grpc.Count -or $g2.Count -ne 0) { + throw "gRPC should be one group (shard1=$($g1.Count) shard2=$($g2.Count) all=$($grpc.Count))" + } + Write-Host "OK: grpc $($grpc.Count) arms stay on one shard" -ForegroundColor Green +} +finally { + Pop-Location +} diff --git a/tools/RpsLoadProbe/validate-compare-product-gates.ps1 b/tools/RpsLoadProbe/validate-compare-product-gates.ps1 index 22c0f5eba..9b8a81665 100644 --- a/tools/RpsLoadProbe/validate-compare-product-gates.ps1 +++ b/tools/RpsLoadProbe/validate-compare-product-gates.ps1 @@ -1,24 +1,24 @@ -# Validate compare-product medians: MITM Lite/Full >= 0.70, reverse TWP/YARP >= 0.95. +# Validate compare-product medians @ c=64: +# MITM Lite ÷ Reverse >= 0.50, Full ÷ Reverse >= 0.50 (all OS, all gated pairs) +# Reverse TWP ÷ YARP >= 0.70 (when YARP SLO-passes) +# No nginx gate — nginx is wiki/charts only. # When Repeats>1, each arm contributes multiple c=64 SLO-pass rows — use the median RPS. -# macos-15-intel CI passes lower floors for first Mac baselines (see PERF-GATES.md / workflow). param( [Parameter(Mandatory)] [string] $CsvPath, - [double] $MitmGate = 0.70, - # H3→H3 MITM (all OS): macos-15-intel first baseline ~0.693; keep written floor. - [double] $MitmHttp3Gate = 0.69, - # Defaults match MitmGate; Mac CI overrides to 0.65 (H3→H1 TLS Full smoke @ 2026da55). - [double] $MitmHttp3TlsFullGate = 0.70, - # Defaults match MitmGate; Mac CI overrides to 0.55 (H1 plain Full @ d0439556 = 0.564). - [double] $MitmHttp1PlainFullGate = 0.70, - [double] $ReverseYarpGate = 0.95, - # H3→H3 peer (all OS when YARP SLO-passes): Mac ~0.78×; Win often TWP ahead. - [double] $ReverseYarpHttp3Gate = 0.75, - # Defaults match ReverseYarpGate; Mac CI overrides to 0.55 (median 0.587 @ d0439556). - [double] $ReverseYarpHttp3ToHttp1Gate = 0.95, + [double] $MitmLiteGate = 0.50, + [double] $MitmFullGate = 0.50, + # Backward-compatible alias: if set, applies to both Lite and Full (overrides the pair above). + [double] $MitmGate = -1, + [double] $ReverseYarpGate = 0.70, [string] $BaselineCsvPath = "" ) $ErrorActionPreference = 'Stop' +if ($MitmGate -ge 0) { + $MitmLiteGate = $MitmGate + $MitmFullGate = $MitmGate +} + $rows = Import-Csv $CsvPath $byArm = @{} foreach ($row in $rows) { @@ -54,35 +54,41 @@ $mitmPairs = @( ) $failed = $false -Write-Host "MITM gates (Full/Lite >= $MitmGate x Reverse; H3->H3 >= $MitmHttp3Gate; H3->H1 TLS Full >= $MitmHttp3TlsFullGate; H1 plain Full >= $MitmHttp1PlainFullGate @ c=64 median)" -ForegroundColor Cyan +Write-Host "MITM gates (Lite >= $MitmLiteGate / Full >= $MitmFullGate x Reverse @ c=64 median; all OS)" -ForegroundColor Cyan foreach ($p in $mitmPairs) { foreach ($kind in @('Lite', 'Full')) { - $pairGate = if ($p.Label -eq 'H3->H3') { $MitmHttp3Gate } - elseif ($p.Label -eq 'H3->H1 TLS' -and $kind -eq 'Full') { $MitmHttp3TlsFullGate } - elseif ($p.Label -eq 'H1 plain' -and $kind -eq 'Full') { $MitmHttp1PlainFullGate } - else { $MitmGate } $num = $p.$kind $den = $p.Reverse + $gate = if ($kind -eq 'Lite') { $MitmLiteGate } else { $MitmFullGate } + # Sharded CSVs only contain a subset of arms — skip pairs not present in this artifact. + if (-not $sustain.ContainsKey($num) -and -not $sustain.ContainsKey($den)) { + Write-Host "SKIP $($p.Label) $kind : not in this shard/CSV" -ForegroundColor DarkYellow + continue + } if (-not $sustain.ContainsKey($num) -or -not $sustain.ContainsKey($den)) { - Write-Host "FAIL $($p.Label) $kind : missing data" -ForegroundColor Red + Write-Host "FAIL $($p.Label) $kind : missing data (partial pair in CSV)" -ForegroundColor Red $failed = $true continue } $ratio = $sustain[$num] / $sustain[$den] - $ok = $ratio -ge $pairGate + $ok = $ratio -ge $gate $color = if ($ok) { 'Green' } else { 'Red' } - Write-Host ("{0} {1} = {2:N3} (gate {3:N2})" -f $p.Label, $kind, $ratio, $pairGate) -ForegroundColor $color + Write-Host ("{0} {1} = {2:N3} (gate {3:N2})" -f $p.Label, $kind, $ratio, $gate) -ForegroundColor $color if (-not $ok) { $failed = $true } } } Write-Host "" -Write-Host "Reverse TWP/YARP gates (H3->H1 >= $ReverseYarpHttp3ToHttp1Gate; H3->H3 >= $ReverseYarpHttp3Gate @ c=64 median)" -ForegroundColor Cyan +Write-Host "Reverse TWP/YARP gates (>= $ReverseYarpGate @ c=64 median; skip when YARP SLO-fails)" -ForegroundColor Cyan $revPairs = @( - @{ Label = 'H3->H1'; Twp = 'twp-reverse-http3-to-https-http1'; Yarp = 'yarp-reverse-http3-to-https-http1'; Gate = $ReverseYarpHttp3ToHttp1Gate }, - @{ Label = 'H3->H3'; Twp = 'twp-reverse-http3'; Yarp = 'yarp-reverse-http3-to-http3'; Gate = $ReverseYarpHttp3Gate } + @{ Label = 'H3->H1'; Twp = 'twp-reverse-http3-to-https-http1'; Yarp = 'yarp-reverse-http3-to-https-http1' }, + @{ Label = 'H3->H3'; Twp = 'twp-reverse-http3'; Yarp = 'yarp-reverse-http3-to-http3' } ) foreach ($p in $revPairs) { + if (-not $sustain.ContainsKey($p.Twp) -and -not $sustain.ContainsKey($p.Yarp)) { + Write-Host "SKIP $($p.Label) : not in this shard/CSV" -ForegroundColor DarkYellow + continue + } if (-not $sustain.ContainsKey($p.Twp)) { Write-Host "FAIL $($p.Label) : missing TWP data" -ForegroundColor Red $failed = $true @@ -94,10 +100,9 @@ foreach ($p in $revPairs) { continue } $ratio = $sustain[$p.Twp] / $sustain[$p.Yarp] - $gate = [double]$p.Gate - $ok = $ratio -ge $gate + $ok = $ratio -ge $ReverseYarpGate $color = if ($ok) { 'Green' } else { 'Red' } - Write-Host ("{0} TWP/YARP = {1:N3} (gate {2:N2})" -f $p.Label, $ratio, $gate) -ForegroundColor $color + Write-Host ("{0} TWP/YARP = {1:N3} (gate {2:N2})" -f $p.Label, $ratio, $ReverseYarpGate) -ForegroundColor $color if (-not $ok) { $failed = $true } } diff --git a/tools/packaging/CHOCOLATEY_FOLLOWUP.md b/tools/packaging/CHOCOLATEY_FOLLOWUP.md new file mode 100644 index 000000000..9d04330d1 --- /dev/null +++ b/tools/packaging/CHOCOLATEY_FOLLOWUP.md @@ -0,0 +1,52 @@ +# Chocolatey follow-up + +Community packages: + +- `titanium-cli` — win-x64 zip (`titanium` / `twp`) +- `titanium-inspector` — win-x64 MSI + +Stubs: [`chocolatey/`](chocolatey/). Bump from a release tag: + +```powershell +pwsh ./tools/packaging/chocolatey/bump-packages.ps1 -Tag v7.0.5 +``` + +## Secret + +| Secret | Purpose | +| --- | --- | +| `CHOCOLATEY_API_KEY` | API key for `https://push.chocolatey.org/` | + +```shell +gh secret set CHOCOLATEY_API_KEY --repo justcoding121/titanium-web-proxy +``` + +Do not paste the key into git, docs, or workflow YAML. CI passes `--api-key` to `choco push` only. + +## Publish + +**Automatic on merge to `beta` / `stable`:** + +1. [`dotnetcore.yml`](../../.github/workflows/dotnetcore.yml) `cut-product-tag` creates `v…` / `v…-beta` and dispatches [`release.yml`](../../.github/workflows/release.yml). +2. `release.yml` builds/signs, creates the GitHub Release, then job `publish-chocolatey` bumps SHA256s and `choco push`es `titanium-cli` + `titanium-inspector` (stable and prerelease). + +No extra click after the packaging stubs are on that branch. GitHub Release is created even if Chocolatey push fails. + +**Manual** (existing tag only, e.g. first `v7.0.5` before the next beta/stable cut): workflow [`chocolatey-publish.yml`](../../.github/workflows/chocolatey-publish.yml) with input `release_tag`. + +First versions wait for chocolatey.org moderation. Users install with: + +```shell +choco install titanium-cli +choco install titanium-inspector +choco install titanium-cli --pre +choco install titanium-inspector --pre +``` + +## Do not + +- Use package id `titanium` (unrelated Titanium Studio already exists). +- Put prerelease text in the package id; use version `7.0.5-beta` and `--pre`. +- Embed zip/MSI in the nupkg. +- Push unsigned Windows assets. +- Post chocolatey.org discussion or moderation replies from CI. diff --git a/tools/packaging/GPG_SECRETS.md b/tools/packaging/GPG_SECRETS.md new file mode 100644 index 000000000..c91143b3b --- /dev/null +++ b/tools/packaging/GPG_SECRETS.md @@ -0,0 +1,18 @@ +# GPG release checksums + +`release.yml` always attaches `SHA256SUMS`. When the following secrets are set, it also attaches armored signatures: + +| Secret | Purpose | +| --- | --- | +| `GPG_PRIVATE_KEY` | ASCII-armored private key (or `gpg --export-secret-keys --armor`) | +| `GPG_PASSPHRASE` | Optional passphrase for the key | + +Generate once (example): + +```shell +gpg --batch --passphrase '' --quick-generate-key 'Titanium Releases ' default default 2y +gpg --export-secret-keys --armor 'Titanium Releases' | gh secret set GPG_PRIVATE_KEY +gpg --export --armor 'Titanium Releases' > titanium-releases.asc # publish this public key on the website/docs +``` + +Do not paste private keys into chat. diff --git a/tools/packaging/PACKAGING.md b/tools/packaging/PACKAGING.md index ad80cd574..2363a402f 100644 --- a/tools/packaging/PACKAGING.md +++ b/tools/packaging/PACKAGING.md @@ -2,12 +2,14 @@ ## RID matrix (7.0) -| Product | RIDs | Notes | -| --- | --- | --- | -| CLI | `win-x64`, `linux-x64`, `linux-arm64`, `linux-musl-x64`, `linux-musl-arm64`, `osx-x64`, `osx-arm64` | Self-contained zips | -| Inspector | Same RIDs | Zip for all; **MSI only** for `win-x64` | +| Product | RIDs | Primary formats | Also attached | +| --- | --- | --- | --- | +| CLI | `win-x64`, `linux-x64`, `linux-arm64`, `linux-musl-*`, `osx-*` | win zip; linux glibc AppImage+deb+rpm; musl zip; osx zip (+ Homebrew) | All RIDs keep a **zip** for `titanium update` | +| Inspector | Same RIDs | win **MSI**; osx **DMG**; linux glibc AppImage+deb+rpm; musl zip | Zip for all RIDs | -HTTP/3 natives are bundled into every Linux/macOS zip via [`bundle-http3-native.ps1`](bundle-http3-native.ps1) + [`http3-native.lock.json`](http3-native.lock.json). Windows uses OS MsQuic (Win11 / Server 2022+). +HTTP/3 natives are bundled into every Linux/macOS publish via [`bundle-http3-native.ps1`](bundle-http3-native.ps1) + [`http3-native.lock.json`](http3-native.lock.json). Windows uses OS MsQuic (Win11 / Server 2022+). + +Website [download](../../website/download.md) prefers MSI/DMG/AppImage/deb/rpm and hides redundant zips when those exist. ## Publish (local) @@ -33,15 +35,46 @@ pwsh ./tools/packaging/bundle-http3-native.ps1 -Rid $rid -PublishDir artifacts/i -Version 7.0.0 ``` -Uses WiX 5 (`dotnet tool` manifest under `tools/packaging/wix/`) plus **WixToolset.UI.wixext** / **Util** for: +Uses WiX 5 (`dotnet tool` manifest under `tools/packaging/wix/`) plus **WixToolset.UI.wixext** / **Util**. + +### Windows Authenticode + +Uses [Azure Artifact Signing](https://learn.microsoft.com/en-us/azure/artifact-signing/quickstart) in [`release.yml`](../../.github/workflows/release.yml) (`win-x64` CLI `titanium.exe`/`twp.exe`, `TitaniumInspector.exe`, and the MSI). Publisher is the validated individual identity (currently `CN=Jehonathan Thomas`). Leaf certificates are short-lived; timestamping is `http://timestamp.acs.microsoft.com`. + +### macOS codesign + notarize + DMG -- `WixUI_InstallDir` wizard (welcome → license → **install folder** → progress → **Finished**) -- `ARPPRODUCTICON` (Programs and Features icon from `app.ico`) -- Start Menu + Desktop shortcuts -- **Launch Titanium Inspector** checkbox on the exit dialog (first install and upgrades) -- `CloseApplication` for `TitaniumInspector.exe` so manual MSI upgrades can close a running instance +Scripts under [`osx/`](osx/): -Authenticode signing is stretch; unsigned MSI is fine for GitHub Releases / early winget. +| Script | Role | +| --- | --- | +| `build-app-bundle.sh` | `.app` from Inspector publish folder | +| `build-dmg.sh` | UDZO DMG with Applications symlink | +| `sign-and-notarize.sh` | Developer ID codesign + `notarytool` + staple | +| `TitaniumInspector.entitlements` / `TitaniumCli.entitlements` | Hardened runtime (JIT + network) | + +CI is **gated**: if `APPLE_CERTIFICATE_P12` (and related secrets) are unset, macOS jobs still build an **unsigned** DMG for artifact shape; notarize is skipped. Required GitHub secrets: `APPLE_DEVELOPER_ID`, `APPLE_CERTIFICATE_P12` (base64), `APPLE_CERTIFICATE_PASSWORD`, `NOTARY_KEY`, `NOTARY_KEY_ID`, `NOTARY_ISSUER`. + +### Linux AppImage / deb / rpm + +| Script | Role | +| --- | --- | +| [`linux/build-appimage.sh`](linux/build-appimage.sh) | AppImage for `cli` or `inspector` (glibc `linux-x64` / `linux-arm64`) | +| [`linux/build-deb-rpm.sh`](linux/build-deb-rpm.sh) | `.deb` + `.rpm` via `fpm` (deb fallback without fpm) | + +Release asset names match the website loader: `Titanium.Cli-{rid}.{AppImage,deb,rpm}`, `TitaniumInspector-{rid}.{AppImage,deb,rpm,dmg}`. + +### GPG checksums + +[`sign-checksums.sh`](sign-checksums.sh) writes `SHA256SUMS` (always) and `SHA256SUMS.asc` / `release-manifest.json.asc` when `GPG_PRIVATE_KEY` (+ optional `GPG_PASSPHRASE`) is set in the release job. + +### Homebrew (Mac CLI) + +Formula source of truth: [`homebrew/titanium.rb`](homebrew/titanium.rb). After a release, run [`homebrew/bump-formula-shas.sh`](homebrew/bump-formula-shas.sh) and push to the `justcoding121/homebrew-titanium` tap (`Formula/titanium.rb`). + +```shell +brew tap justcoding121/titanium +brew install titanium +``` ### Linux / macOS desktop helpers (Inspector zips) @@ -49,19 +82,30 @@ Release publish copies helpers into each Inspector zip: | RID | Helpers | | --- | --- | -| `linux-*` | `install.sh`, `uninstall.sh`, `TitaniumInspector.desktop.in`, `app.ico` | -| `osx-*` | `install-app.sh`, `uninstall-app.sh`, `app.ico` | +| `linux-*` | `install.sh`, `uninstall.sh`, `TitaniumInspector.desktop.in`, `app.ico`, `desktop-icons.sh`, `titanium-inspector*.png` | +| `osx-*` | `install-app.sh`, `uninstall-app.sh`, `app.ico`, `desktop-icons.sh`, `AppIcon.icns` | -**Linux:** extract the zip, then `./install.sh` (default prefix `~/.local`) to copy the app under `~/.local/share/TitaniumInspector`, add a desktop entry, and symlink `titanium-inspector` on `PATH`. Uninstall: `./uninstall.sh` or `~/.local/share/TitaniumInspector/uninstall.sh`. Portable use without install: run `./TitaniumInspector` from the extracted folder. +Prebuilt icons live in [`icons/`](icons/) (hicolor PNG sizes + macOS `AppIcon.icns`). Deb/rpm/AppImage/DMG packaging installs them into the OS icon locations. -**macOS:** extract the zip, then `./install-app.sh` to create `~/Applications/Titanium Inspector.app`. Uninstall: `./uninstall-app.sh`. Portable use: run `./TitaniumInspector` from the extracted folder. +**Linux:** extract the zip, then `./install.sh` (default prefix `~/.local`). **macOS:** extract, then `./install-app.sh` → `~/Applications/Titanium Inspector.app` (prefer DMG when published). Winget package IDs: - `justcoding121.TitaniumInspector` (prefer MSI when attached to the Release) - `justcoding121.TitaniumCli` (portable zip) -Manifest stubs live in `tools/packaging/winget/`. +Manifest stubs live in `tools/packaging/winget/`. Resubmit to `microsoft/winget-pkgs` only after the **first signed stable** with fresh SHA256s (do not resubmit unsigned `7.0.4`). + +### Chocolatey (Windows) + +Package ids: `titanium-cli` (zip), `titanium-inspector` (MSI). Stubs in [`chocolatey/`](chocolatey/). **Automatic:** merge to `beta` / `stable` → `cut-product-tag` → [`release.yml`](../../.github/workflows/release.yml) `publish-chocolatey` (bump SHA256s + `choco push` for stable and `-beta`). Re-push an existing tag with [`chocolatey-publish.yml`](../../.github/workflows/chocolatey-publish.yml). Secret name only: `CHOCOLATEY_API_KEY` — see [`CHOCOLATEY_FOLLOWUP.md`](CHOCOLATEY_FOLLOWUP.md). + +```shell +choco install titanium-cli +choco install titanium-inspector +choco install titanium-cli --pre +choco install titanium-inspector --pre +``` ## HTTP/3 native lock file @@ -83,13 +127,16 @@ Do **not** mix glibc `.so` into musl zips (or the reverse). Do **not** redistrib See [`.github/workflows/release.yml`](../../.github/workflows/release.yml): -- Matrix RID × runner (`ubuntu-latest` for win/linux/musl; `macos-latest` for osx). -- `setup-dotnet` with `cache: true`. -- Native download cache keyed on lock file hash. +- Matrix RID × runner (`windows-latest` for `win-x64` CLI/Inspector; `ubuntu-latest` for linux/musl; `macos-latest` for osx). +- Azure Artifact Signing on Windows; optional Apple notarize when secrets exist; optional GPG on checksums. - Smoke: `linux-x64` on host + `linux-musl-x64` inside `alpine:3.24` — `titanium http3-deps status` + assert natives present. Native bundling runs on **release** only (not every PR). +### Local macOS Debug / `dotnet run` + +`copy-http3-natives-osx.sh` (Inspector + CLI `CopyMacHttp3Natives` target) copies Homebrew MsQuic + OpenSSL beside `$(TargetDir)` with `@loader_path` rewrites. Framework-dependent hosts still need that directory on `DYLD_FALLBACK_LIBRARY_PATH` because `System.Net.Quic` loads MsQuic by leaf name only. Inspector/CLI call `Http3NativeBootstrap.EnsureAppLocalMsQuicVisible` at startup (and regenerate gitignored `Properties/launchSettings.json`) so a normal Debug launch enables HTTP/3 after `brew install libmsquic openssl@3`. Self-contained RID publishes do not need the re-exec path. + ## Quarterly lock-file bump (CVE ownership) Bundling OpenSSL/MsQuic means **this repo owns CVE patching** for those versions in distributed zips. @@ -98,19 +145,3 @@ Bundling OpenSSL/MsQuic means **this repo owns CVE patching** for those versions 2. Recompute SHA256 for each URL. 3. Run a release (or local publish + smoke) for `linux-x64` and `linux-musl-x64`. 4. Ship a new GitHub Release so operators pick up patched natives. - -Target cadence: **at least quarterly**, and immediately for critical OpenSSL/MsQuic CVEs. - -## Fallback for edge hosts - -```bash -titanium http3-deps status -titanium http3-deps install # apt / dnf / zypper / apk / brew -``` - -Not run automatically by MSI/winget. NuGet library consumers stay docs-only (system MsQuic). - -## Operator docs - -- Website: `/docs/http3`, `/docs/install`, download page RID table -- Wiki: `HTTP-3.md` packaging sections diff --git a/tools/packaging/WINGET_FOLLOWUP.md b/tools/packaging/WINGET_FOLLOWUP.md new file mode 100644 index 000000000..5a28709ad --- /dev/null +++ b/tools/packaging/WINGET_FOLLOWUP.md @@ -0,0 +1,21 @@ +# Winget follow-up + +Status (2026-09-02): Signed stable [`v7.0.5`](https://github.com/justcoding121/titanium-web-proxy/releases/tag/v7.0.5) cut with Authenticode MSI + AppImages + `SHA256SUMS.asc`. + +## Submitted winget PRs (v7.0.5) + +- CLI: https://github.com/microsoft/winget-pkgs/pull/428410 — validation green (`Azure-Pipeline-Passed`, `Validation-Completed`); awaiting community moderator approval. +- Inspector (Authenticode MSI): https://github.com/microsoft/winget-pkgs/pull/428421 — same (supersedes #428411; fixed `LicenseUrl`). + +CLA: `@microsoft-github-policy-service agree` already recorded; `license/cla` success. + +## Do not + +- Resubmit unsigned `7.0.4` or any beta tag to `microsoft/winget-pkgs`. +- Open duplicate PRs while the above are still open. + +## After merge + +1. Verify `winget search Titanium` / `winget show justcoding121.TitaniumCli` and Inspector. +2. For later stables: refresh SHA256s in [`winget/`](winget/) from release `SHA256SUMS`, then open version-bump PRs (not new-package). +3. Note Authenticode publisher **Jehonathan Thomas** when relevant. diff --git a/tools/packaging/bundle-http3-native.ps1 b/tools/packaging/bundle-http3-native.ps1 index 414a0937d..b8fe002bb 100644 --- a/tools/packaging/bundle-http3-native.ps1 +++ b/tools/packaging/bundle-http3-native.ps1 @@ -256,10 +256,15 @@ function Ensure-SonameLinks([string] $dir) { function Set-LinuxRpath([string] $dir) { $patchelf = Ensure-Patchelf - Get-ChildItem -Path $dir -File -Filter "*.so*" | ForEach-Object { - $soFile = $_ + # Do NOT use Filter "*.so*" — PowerShell/Win32 wildcards match ".Sockets" inside + # System.Net.Sockets.dll and similar managed assemblies. + $soFiles = @( + Get-ChildItem -Path $dir -File -Filter "*.so" + Get-ChildItem -Path $dir -File | Where-Object { $_.Name -match '\.so\.\d' } + ) | Sort-Object -Property FullName -Unique + foreach ($soFile in $soFiles) { # Skip pure symlinks - if ($soFile.Attributes -band [IO.FileAttributes]::ReparsePoint) { return } + if ($soFile.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } try { Invoke-Native $patchelf @("--set-rpath", "`$ORIGIN", $soFile.FullName) } @@ -268,6 +273,9 @@ function Set-LinuxRpath([string] $dir) { Write-Info "patchelf skipped $($soFile.Name): $_" } } + # Caught native failures leave $LASTEXITCODE non-zero; clear so the GHA pwsh step + # does not fail the job after a soft-skip. + $global:LASTEXITCODE = 0 } function Assert-RequiredFiles([string[]] $globs) { @@ -443,3 +451,5 @@ switch ($ridEntry.mode) { } Write-Info "done" +# Ensure GitHub Actions pwsh step succeeds even if a soft-skipped native tool left LASTEXITCODE set. +$global:LASTEXITCODE = 0 diff --git a/tools/packaging/chocolatey/bump-packages.ps1 b/tools/packaging/chocolatey/bump-packages.ps1 new file mode 100644 index 000000000..cd47b1683 --- /dev/null +++ b/tools/packaging/chocolatey/bump-packages.ps1 @@ -0,0 +1,107 @@ +#Requires -Version 5.1 +param( + [Parameter(Mandatory = $true)] + [string]$Tag, + + [string]$Repo = 'justcoding121/titanium-web-proxy', + + [string]$Sha256SumsPath +) + +$ErrorActionPreference = 'Stop' + +if ($Tag -notmatch '^v') { + $Tag = "v$Tag" +} +$Version = $Tag.TrimStart('v') +$Root = Split-Path -Parent $MyInvocation.MyCommand.Path + +function Get-Sha256Map { + param([string[]]$Lines) + $map = @{} + foreach ($line in $Lines) { + $t = $line.Trim() + if ($t -eq '' -or $t.StartsWith('#')) { continue } + if ($t -match '^([A-Fa-f0-9]{64})\s+\*?(.+)$') { + $map[$Matches[2].Trim()] = $Matches[1].ToUpperInvariant() + } + } + return $map +} + +if ($Sha256SumsPath) { + $sumsText = Get-Content -LiteralPath $Sha256SumsPath -Raw -Encoding utf8 +} else { + $url = "https://github.com/$Repo/releases/download/$Tag/SHA256SUMS" + Write-Host "Fetching $url" + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("twp-SHA256SUMS-$Tag.txt") + Invoke-WebRequest -Uri $url -OutFile $tmp -UseBasicParsing + $sumsText = Get-Content -LiteralPath $tmp -Raw -Encoding utf8 +} + +$sha = Get-Sha256Map -Lines ($sumsText -split "`r?`n") +$cliZip = 'Titanium.Cli-win-x64.zip' +$msi = 'TitaniumInspector-win-x64.msi' +foreach ($name in @($cliZip, $msi)) { + if (-not $sha.ContainsKey($name)) { + throw "SHA256SUMS missing entry for $name (tag $Tag)" + } +} + +$cliUrl = "https://github.com/$Repo/releases/download/$Tag/$cliZip" +$msiUrl = "https://github.com/$Repo/releases/download/$Tag/$msi" +$releaseNotes = "https://github.com/$Repo/releases/tag/$Tag" + +function Update-Nuspec { + param( + [string]$Path, + [string]$Version, + [string]$ReleaseNotes + ) + [xml]$xml = Get-Content -LiteralPath $Path -Raw + $ns = New-Object System.Xml.XmlNamespaceManager($xml.NameTable) + $ns.AddNamespace('n', 'http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd') + $meta = $xml.SelectSingleNode('//n:metadata', $ns) + if (-not $meta) { throw "metadata missing in $Path" } + $meta.SelectSingleNode('n:version', $ns).InnerText = $Version + $rn = $meta.SelectSingleNode('n:releaseNotes', $ns) + if ($rn) { $rn.InnerText = $ReleaseNotes } + $xml.Save($Path) +} + +function Update-InstallScript { + param( + [string]$Path, + [string]$UrlProperty, + [string]$Url, + [string]$ChecksumProperty, + [string]$Checksum + ) + $text = Get-Content -LiteralPath $Path -Raw + $text = [regex]::Replace( + $text, + "(?m)(\s*$UrlProperty\s*=\s*)'[^']*'", + "`$1'$Url'" + ) + $text = [regex]::Replace( + $text, + "(?m)(\s*$ChecksumProperty\s*=\s*)'[^']*'", + "`$1'$Checksum'" + ) + if (-not $text.EndsWith("`n")) { $text += "`n" } + Set-Content -LiteralPath $Path -Value $text -NoNewline -Encoding utf8NoBOM +} + +$cliNuspec = Join-Path $Root 'titanium-cli\titanium-cli.nuspec' +$cliInstall = Join-Path $Root 'titanium-cli\tools\chocolateyInstall.ps1' +$insNuspec = Join-Path $Root 'titanium-inspector\titanium-inspector.nuspec' +$insInstall = Join-Path $Root 'titanium-inspector\tools\chocolateyInstall.ps1' + +Update-Nuspec -Path $cliNuspec -Version $Version -ReleaseNotes $releaseNotes +Update-Nuspec -Path $insNuspec -Version $Version -ReleaseNotes $releaseNotes +Update-InstallScript -Path $cliInstall -UrlProperty 'url64bit' -Url $cliUrl -ChecksumProperty 'checksum64' -Checksum $sha[$cliZip] +Update-InstallScript -Path $insInstall -UrlProperty 'url64bit' -Url $msiUrl -ChecksumProperty 'checksum64' -Checksum $sha[$msi] + +Write-Host "Updated chocolatey stubs for $Tag ($Version)" +Write-Host " $cliZip = $($sha[$cliZip])" +Write-Host " $msi = $($sha[$msi])" diff --git a/tools/packaging/chocolatey/titanium-cli/titanium-cli.nuspec b/tools/packaging/chocolatey/titanium-cli/titanium-cli.nuspec new file mode 100644 index 000000000..370ee3d84 --- /dev/null +++ b/tools/packaging/chocolatey/titanium-cli/titanium-cli.nuspec @@ -0,0 +1,24 @@ + + + + titanium-cli + 7.0.5 + Titanium CLI + justcoding121 + justcoding121 + Copyright (c) Jehonathan Thomas + https://github.com/justcoding121/titanium-web-proxy + https://github.com/justcoding121/titanium-web-proxy/blob/develop/LICENSE + false + https://github.com/justcoding121/titanium-web-proxy + https://titaniumproxy.com/docs/cli + https://github.com/justcoding121/titanium-web-proxy/issues + titanium proxy cli http https mitm + Titanium Web Proxy command-line daemon (titanium / twp). + Titanium Web Proxy CLI. Self-contained win-x64 zip from GitHub Releases (titanium / twp). + https://github.com/justcoding121/titanium-web-proxy/releases/tag/v7.0.5 + + + + + \ No newline at end of file diff --git a/tools/packaging/chocolatey/titanium-cli/tools/chocolateyInstall.ps1 b/tools/packaging/chocolatey/titanium-cli/tools/chocolateyInstall.ps1 new file mode 100644 index 000000000..42235343e --- /dev/null +++ b/tools/packaging/chocolatey/titanium-cli/tools/chocolateyInstall.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference = 'Stop' + +$toolsDir = "$(Split-Path -Parent $MyInvocation.MyCommand.Definition)" + +$packageArgs = @{ + packageName = $env:ChocolateyPackageName + unzipLocation = $toolsDir + url64bit = 'https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.5/Titanium.Cli-win-x64.zip' + checksum64 = '8C6D09C7293C9A3D205C64FB00D946A10AFA3379F072FA83A87EEFC4FE9425E2' + checksumType64 = 'sha256' +} + +Install-ChocolateyZipPackage @packageArgs + +Get-ChildItem -Path $toolsDir -Recurse -Filter '*.exe' | ForEach-Object { + if ($_.Name -notin @('titanium.exe', 'twp.exe')) { + New-Item -Path "$($_.FullName).ignore" -ItemType File -Force | Out-Null + } +} diff --git a/tools/packaging/chocolatey/titanium-inspector/titanium-inspector.nuspec b/tools/packaging/chocolatey/titanium-inspector/titanium-inspector.nuspec new file mode 100644 index 000000000..9320b9bc6 --- /dev/null +++ b/tools/packaging/chocolatey/titanium-inspector/titanium-inspector.nuspec @@ -0,0 +1,24 @@ + + + + titanium-inspector + 7.0.5 + Titanium Inspector + justcoding121 + justcoding121 + Copyright (c) Jehonathan Thomas + https://github.com/justcoding121/titanium-web-proxy + https://github.com/justcoding121/titanium-web-proxy/blob/develop/licenses/PolyForm-Noncommercial-1.0.0.txt + true + https://github.com/justcoding121/titanium-web-proxy + https://titaniumproxy.com/docs/inspector + https://github.com/justcoding121/titanium-web-proxy/issues + titanium proxy inspector mitm http https debugger + Desktop HTTP(S) traffic debugger for Titanium Web Proxy. + Titanium Inspector MITM debugger. Installs the signed win-x64 MSI from GitHub Releases. PolyForm Noncommercial 1.0.0. + https://github.com/justcoding121/titanium-web-proxy/releases/tag/v7.0.5 + + + + + \ No newline at end of file diff --git a/tools/packaging/chocolatey/titanium-inspector/tools/chocolateyInstall.ps1 b/tools/packaging/chocolatey/titanium-inspector/tools/chocolateyInstall.ps1 new file mode 100644 index 000000000..278dad026 --- /dev/null +++ b/tools/packaging/chocolatey/titanium-inspector/tools/chocolateyInstall.ps1 @@ -0,0 +1,14 @@ +$ErrorActionPreference = 'Stop' + +$packageArgs = @{ + packageName = $env:ChocolateyPackageName + fileType = 'msi' + url64bit = 'https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.5/TitaniumInspector-win-x64.msi' + checksum64 = '15179C71A50B7724F55F421025086E0DA798B6A18EC9004FE64F22E04002124E' + checksumType64 = 'sha256' + silentArgs = '/qn /norestart' + validExitCodes = @(0, 3010, 1641) + softwareName = 'Titanium Inspector*' +} + +Install-ChocolateyPackage @packageArgs diff --git a/tools/packaging/chocolatey/titanium-inspector/tools/chocolateyUninstall.ps1 b/tools/packaging/chocolatey/titanium-inspector/tools/chocolateyUninstall.ps1 new file mode 100644 index 000000000..93a250f68 --- /dev/null +++ b/tools/packaging/chocolatey/titanium-inspector/tools/chocolateyUninstall.ps1 @@ -0,0 +1,25 @@ +$ErrorActionPreference = 'Stop' + +$packageArgs = @{ + packageName = $env:ChocolateyPackageName + softwareName = 'Titanium Inspector*' + fileType = 'msi' + silentArgs = '/qn /norestart' + validExitCodes = @(0, 3010, 1605, 1614, 1641) +} + +[array]$keys = Get-UninstallRegistryKey -SoftwareName $packageArgs['softwareName'] +if ($keys.Count -eq 1) { + $packageArgs['file'] = "$($keys[0].UninstallString)" + if ($keys[0].UninstallString -match '\{[0-9A-Fa-f-]+\}') { + $packageArgs['silentArgs'] = "$($Matches[0]) $($packageArgs['silentArgs'])" + $packageArgs['file'] = '' + } + Uninstall-ChocolateyPackage @packageArgs +} elseif ($keys.Count -eq 0) { + Write-Warning "$($packageArgs['packageName']) has already been uninstalled by other means." +} else { + Write-Warning "$($keys.Count) matches found for $($packageArgs['packageName'])!" + Write-Warning 'Please alert the package maintainer so the following keys can be checked:' + $keys | ForEach-Object { Write-Warning "- $($_.DisplayName): $($_.UninstallString)" } +} diff --git a/tools/packaging/copy-http3-natives-osx.sh b/tools/packaging/copy-http3-natives-osx.sh new file mode 100755 index 000000000..ba83bb7ee --- /dev/null +++ b/tools/packaging/copy-http3-natives-osx.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Copy Homebrew MsQuic + OpenSSL into an output folder with @loader_path so +# local Debug/Release builds can load them (Release RID zips use bundle-http3-native.ps1). +# +# Note: framework-dependent (typical `dotnet build` / `dotnet run`) hosts still need the +# app directory on DYLD_FALLBACK_LIBRARY_PATH — System.Net.Quic only dlopen()s by leaf name +# on macOS and does not search AppContext.BaseDirectory. Inspector/CLI call +# Http3NativeBootstrap (or use generated launchSettings) for that. +set -euo pipefail + +OUT_DIR="${1:-}" +if [[ -z "$OUT_DIR" || ! -d "$OUT_DIR" ]]; then + echo "[http3-copy] usage: $0 " >&2 + exit 2 +fi + +OUT_DIR="$(cd "$OUT_DIR" && pwd)" + +resolve_brew_prefix() { + if [[ -n "${HOMEBREW_PREFIX:-}" && -d "${HOMEBREW_PREFIX}/opt/libmsquic/lib" ]]; then + echo "$HOMEBREW_PREFIX" + return + fi + for p in "${HOME}/.homebrew" /opt/homebrew /usr/local; do + if [[ -d "$p/opt/libmsquic/lib" ]]; then + echo "$p" + return + fi + done + if command -v brew >/dev/null 2>&1; then + brew --prefix + return + fi + return 1 +} + +PREFIX="$(resolve_brew_prefix || true)" +if [[ -z "${PREFIX}" ]]; then + echo "[http3-copy] skip: libmsquic not found (brew install libmsquic openssl@3)" + exit 0 +fi + +MSQ_LIB="$PREFIX/opt/libmsquic/lib" +SSL_LIB="$PREFIX/opt/openssl@3/lib" +if [[ ! -d "$MSQ_LIB" ]]; then + echo "[http3-copy] skip: missing $MSQ_LIB" + exit 0 +fi +if [[ ! -d "$SSL_LIB" ]]; then + echo "[http3-copy] skip: missing $SSL_LIB (brew install openssl@3)" + exit 0 +fi + +MSQ_SRC="" +for cand in libmsquic.2.6.1.dylib libmsquic.2.dylib libmsquic.dylib; do + if [[ -e "$MSQ_LIB/$cand" ]]; then + MSQ_SRC="$MSQ_LIB/$cand" + break + fi +done + +if [[ -z "$MSQ_SRC" ]]; then + echo "[http3-copy] skip: no libmsquic*.dylib under $MSQ_LIB" + exit 0 +fi + +# -L follows Homebrew versioned symlinks so install_name_tool edits a real dylib. +cp -fL "$MSQ_SRC" "$OUT_DIR/libmsquic.2.6.1.dylib" +cp -f "$OUT_DIR/libmsquic.2.6.1.dylib" "$OUT_DIR/libmsquic.2.dylib" +cp -f "$OUT_DIR/libmsquic.2.6.1.dylib" "$OUT_DIR/libmsquic.dylib" +cp -fL "$SSL_LIB/libssl.3.dylib" "$OUT_DIR/libssl.3.dylib" +cp -fL "$SSL_LIB/libcrypto.3.dylib" "$OUT_DIR/libcrypto.3.dylib" + +rewrite_id() { + local f="$1" + install_name_tool -id "@loader_path/$(basename "$f")" "$f" 2>/dev/null || true +} + +for f in libmsquic.2.6.1.dylib libmsquic.2.dylib libmsquic.dylib libssl.3.dylib libcrypto.3.dylib; do + rewrite_id "$OUT_DIR/$f" +done + +# Match Release bundler: retarget libmsquic / libssl / libcrypto deps to @loader_path. +retarget_http3_deps() { + local f="$1" + local dep leaf + while IFS= read -r dep; do + [[ -z "$dep" ]] && continue + leaf="$(basename "$dep")" + case "$leaf" in + libmsquic*|libssl*|libcrypto*) + if [[ -f "$OUT_DIR/$leaf" || "$leaf" == libmsquic* || "$leaf" == libssl* || "$leaf" == libcrypto* ]]; then + # Prefer the stable names we copied when the dep leaf is a versioned symlink name. + if [[ ! -f "$OUT_DIR/$leaf" ]]; then + case "$leaf" in + libmsquic*) leaf="libmsquic.dylib" ;; + libssl*) leaf="libssl.3.dylib" ;; + libcrypto*) leaf="libcrypto.3.dylib" ;; + esac + fi + if [[ -f "$OUT_DIR/$leaf" && "$dep" != "@loader_path/$leaf" ]]; then + install_name_tool -change "$dep" "@loader_path/$leaf" "$f" 2>/dev/null || true + fi + fi + ;; + esac + done < <(otool -L "$f" | awk 'NR>1 {print $1}') +} + +for f in libmsquic.2.6.1.dylib libmsquic.2.dylib libmsquic.dylib libssl.3.dylib libcrypto.3.dylib; do + retarget_http3_deps "$OUT_DIR/$f" +done + +if command -v codesign >/dev/null 2>&1; then + codesign --force -s - \ + "$OUT_DIR"/libmsquic.2.6.1.dylib \ + "$OUT_DIR"/libmsquic.2.dylib \ + "$OUT_DIR"/libmsquic.dylib \ + "$OUT_DIR"/libssl.3.dylib \ + "$OUT_DIR"/libcrypto.3.dylib >/dev/null 2>&1 || true +fi + +echo "[http3-copy] bundled MsQuic + OpenSSL into $OUT_DIR (from $PREFIX)" diff --git a/tools/packaging/desktop-icons.sh b/tools/packaging/desktop-icons.sh new file mode 100755 index 000000000..aefeb3238 --- /dev/null +++ b/tools/packaging/desktop-icons.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# Shared desktop icon helpers for Inspector packaging (Linux hicolor + macOS icns). +# Sourced by install/build scripts — not meant to be run standalone. +# +# Prebuilt assets live in tools/packaging/icons/ (committed). Fallbacks: +# payload app.ico / ImageMagick convert / sips+iconutil. + +# Capture this file's directory at source time (bash or zsh). +if [[ -n "${BASH_SOURCE[0]:-}" ]]; then + _DESKTOP_ICONS_PACKAGING_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +elif [[ -n "${ZSH_VERSION:-}" ]]; then + # zsh: %x is this file when sourced/executed + # shellcheck disable=SC2296 + _DESKTOP_ICONS_PACKAGING_DIR="$(cd "$(dirname "${(%):-%x}")" && pwd)" +else + _DESKTOP_ICONS_PACKAGING_DIR="$(cd "$(dirname "$0")" && pwd)" +fi + +_desktop_icons_dir() { + if [[ -d "${_DESKTOP_ICONS_PACKAGING_DIR}/icons" ]]; then + printf '%s' "${_DESKTOP_ICONS_PACKAGING_DIR}/icons" + else + # Release zips ship PNGs/icns next to desktop-icons.sh + printf '%s' "${_DESKTOP_ICONS_PACKAGING_DIR}" + fi +} + +# Resolve a source .ico from common payload / repo locations. +# Args: optional extra candidate paths… +_desktop_icons_find_ico() { + local c repo_ico + repo_ico="$(cd "${_DESKTOP_ICONS_PACKAGING_DIR}/../.." && pwd)/src/Titanium.Inspector/Assets/app.ico" + for c in "$@" \ + "${PAYLOAD_DIR:-}/app.ico" \ + "${SCRIPT_DIR:-}/app.ico" \ + "${repo_ico}"; do + if [[ -n "${c}" && -f "${c}" ]]; then + printf '%s' "${c}" + return 0 + fi + done + return 1 +} + +# Install freedesktop hicolor icons named .png at standard sizes. +# Args: [extra ico candidates…] +# Example hicolor_root: "$STAGE/usr/share/icons/hicolor" +install_hicolor_icons() { + local hicolor_root="${1:?hicolor root}" + local icon_id="${2:?icon id}" + shift 2 + local icons_dir size dest src ico + icons_dir="$(_desktop_icons_dir)" + + for size in 16 32 48 128 256 512; do + mkdir -p "${hicolor_root}/${size}x${size}/apps" + dest="${hicolor_root}/${size}x${size}/apps/${icon_id}.png" + src="${icons_dir}/${icon_id}-${size}.png" + if [[ ! -f "${src}" ]]; then + src="${icons_dir}/${icon_id}.png" + fi + if [[ ! -f "${src}" ]]; then + src="${icons_dir}/titanium-inspector-${size}.png" + fi + if [[ ! -f "${src}" ]]; then + src="${icons_dir}/titanium-inspector.png" + fi + if [[ -f "${src}" ]]; then + cp -f "${src}" "${dest}" + continue + fi + # Last resort: convert from .ico when ImageMagick is available. + if ico="$(_desktop_icons_find_ico "$@")" && command -v convert >/dev/null 2>&1; then + convert "${ico}" -thumbnail "${size}x${size}" "${dest}" 2>/dev/null || true + fi + done + + # Ensure at least 256x256 exists (desktop entries / AppImage expect it). + dest="${hicolor_root}/256x256/apps/${icon_id}.png" + if [[ ! -f "${dest}" ]]; then + mkdir -p "$(dirname "${dest}")" + if [[ -f "${icons_dir}/titanium-inspector.png" ]]; then + cp -f "${icons_dir}/titanium-inspector.png" "${dest}" + elif ico="$(_desktop_icons_find_ico "$@")" && command -v convert >/dev/null 2>&1; then + convert "${ico}" -thumbnail 256x256 "${dest}" 2>/dev/null || true + fi + fi + + [[ -f "${dest}" ]] +} + +# Remove hicolor icons installed for . +# Args: +remove_hicolor_icons() { + local hicolor_root="${1:?hicolor root}" + local icon_id="${2:?icon id}" + local size + for size in 16 32 48 128 256 512; do + rm -f "${hicolor_root}/${size}x${size}/apps/${icon_id}.png" + done + rm -f "${hicolor_root}/256x256/apps/${icon_id}.ico" +} + +# Copy AppIcon.icns into Resources. Prefers committed icns; else builds from .ico. +# Args: [extra ico candidates…] +# Prints the CFBundleIconFile value (without .icns) on success; empty on failure. +install_macos_app_icon() { + local resources_dir="${1:?Resources dir}" + shift + local icons_dir ico iconset tmp_png size double + icons_dir="$(_desktop_icons_dir)" + mkdir -p "${resources_dir}" + + if [[ -f "${icons_dir}/AppIcon.icns" ]]; then + cp -f "${icons_dir}/AppIcon.icns" "${resources_dir}/AppIcon.icns" + printf 'AppIcon' + return 0 + fi + + # Payload may already carry a prebuilt icns (release zip). + if [[ -f "${PAYLOAD_DIR:-}/AppIcon.icns" ]]; then + cp -f "${PAYLOAD_DIR}/AppIcon.icns" "${resources_dir}/AppIcon.icns" + printf 'AppIcon' + return 0 + fi + if [[ -f "${SCRIPT_DIR:-}/AppIcon.icns" ]]; then + cp -f "${SCRIPT_DIR}/AppIcon.icns" "${resources_dir}/AppIcon.icns" + printf 'AppIcon' + return 0 + fi + + if ! ico="$(_desktop_icons_find_ico "$@")"; then + return 1 + fi + if ! command -v sips >/dev/null 2>&1 || ! command -v iconutil >/dev/null 2>&1; then + return 1 + fi + + iconset="$(mktemp -d)/AppIcon.iconset" + mkdir -p "${iconset}" + tmp_png="$(mktemp).png" + if ! sips -s format png "${ico}" --out "${tmp_png}" >/dev/null 2>&1; then + rm -rf "$(dirname "${iconset}")" "${tmp_png}" 2>/dev/null || true + return 1 + fi + for size in 16 32 128 256 512; do + sips -z "${size}" "${size}" "${tmp_png}" --out "${iconset}/icon_${size}x${size}.png" >/dev/null + double=$((size * 2)) + sips -z "${double}" "${double}" "${tmp_png}" --out "${iconset}/icon_${size}x${size}@2x.png" >/dev/null + done + if iconutil -c icns "${iconset}" -o "${resources_dir}/AppIcon.icns" 2>/dev/null; then + rm -rf "$(dirname "${iconset}")" "${tmp_png}" 2>/dev/null || true + printf 'AppIcon' + return 0 + fi + rm -rf "$(dirname "${iconset}")" "${tmp_png}" 2>/dev/null || true + return 1 +} diff --git a/tools/packaging/homebrew/bump-formula-shas.sh b/tools/packaging/homebrew/bump-formula-shas.sh new file mode 100644 index 000000000..7f5f5dcc9 --- /dev/null +++ b/tools/packaging/homebrew/bump-formula-shas.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Print Homebrew formula snippet with SHA256s from a release-manifest or zip paths. +# Usage: ./bump-formula-shas.sh +set -euo pipefail +VERSION="${1:?version}" +ARM_ZIP="${2:?osx-arm64 zip}" +X64_ZIP="${3:?osx-x64 zip}" +ARM_SHA="$(shasum -a 256 "${ARM_ZIP}" | awk '{print $1}')" +X64_SHA="$(shasum -a 256 "${X64_ZIP}" | awk '{print $1}')" +ROOT="$(cd "$(dirname "$0")" && pwd)" +sed -e "s/version \".*\"/version \"${VERSION}\"/" \ + -e "s/REPLACE_OSX_ARM64_SHA256/${ARM_SHA}/" \ + -e "s/REPLACE_OSX_X64_SHA256/${X64_SHA}/" \ + "${ROOT}/titanium.rb" diff --git a/tools/packaging/homebrew/titanium.rb b/tools/packaging/homebrew/titanium.rb new file mode 100644 index 000000000..766a13963 --- /dev/null +++ b/tools/packaging/homebrew/titanium.rb @@ -0,0 +1,38 @@ +# Homebrew formula template for Titanium CLI (custom tap). +# After each release, bump url + sha256 for the active macOS arch zip. +# Tap usage: +# brew tap justcoding121/titanium +# brew install titanium +# +# Place this file in a homebrew-titanium tap repo as Formula/titanium.rb +# (kept here as the source of truth for CI bump scripts). + +class Titanium < Formula + desc "Titanium Web Proxy CLI (MITM / reverse proxy)" + homepage "https://github.com/justcoding121/titanium-web-proxy" + version "7.0.5" + license "MIT" + + on_macos do + on_arm do + url "https://github.com/justcoding121/titanium-web-proxy/releases/download/v#{version}/Titanium.Cli-osx-arm64.zip" + sha256 "0eebb0a3cff372b004496cd44830edfb546670c42fd18d7abf79b56b15ce30e8" + end + on_intel do + url "https://github.com/justcoding121/titanium-web-proxy/releases/download/v#{version}/Titanium.Cli-osx-x64.zip" + sha256 "786f97316afedcd82b3c32e66970984f0f084b52a6a4b2a66b1320834f3d5d07" + end + end + + def install + # Keep publish layout so @loader_path / $ORIGIN natives resolve next to the binary. + libexec.install Dir["*"] + bin.install_symlink libexec/"titanium" + bin.install_symlink libexec/"twp" if (libexec/"twp").exist? + end + + test do + # Assembly versions are numeric (e.g. 7.0.5.0); formula may be 7.0.5-beta. + assert_match version.to_s.split("-").first, shell_output("#{bin}/titanium version") + end +end diff --git a/tools/packaging/icons/AppIcon.icns b/tools/packaging/icons/AppIcon.icns new file mode 100644 index 000000000..40bb9eeb2 Binary files /dev/null and b/tools/packaging/icons/AppIcon.icns differ diff --git a/tools/packaging/icons/titanium-inspector-128.png b/tools/packaging/icons/titanium-inspector-128.png new file mode 100644 index 000000000..05c3ebca3 Binary files /dev/null and b/tools/packaging/icons/titanium-inspector-128.png differ diff --git a/tools/packaging/icons/titanium-inspector-16.png b/tools/packaging/icons/titanium-inspector-16.png new file mode 100644 index 000000000..8e184252d Binary files /dev/null and b/tools/packaging/icons/titanium-inspector-16.png differ diff --git a/tools/packaging/icons/titanium-inspector-256.png b/tools/packaging/icons/titanium-inspector-256.png new file mode 100644 index 000000000..85145893d Binary files /dev/null and b/tools/packaging/icons/titanium-inspector-256.png differ diff --git a/tools/packaging/icons/titanium-inspector-32.png b/tools/packaging/icons/titanium-inspector-32.png new file mode 100644 index 000000000..8fbe17da6 Binary files /dev/null and b/tools/packaging/icons/titanium-inspector-32.png differ diff --git a/tools/packaging/icons/titanium-inspector-48.png b/tools/packaging/icons/titanium-inspector-48.png new file mode 100644 index 000000000..b9fb45f8b Binary files /dev/null and b/tools/packaging/icons/titanium-inspector-48.png differ diff --git a/tools/packaging/icons/titanium-inspector-512.png b/tools/packaging/icons/titanium-inspector-512.png new file mode 100644 index 000000000..e84846724 Binary files /dev/null and b/tools/packaging/icons/titanium-inspector-512.png differ diff --git a/tools/packaging/icons/titanium-inspector.png b/tools/packaging/icons/titanium-inspector.png new file mode 100644 index 000000000..85145893d Binary files /dev/null and b/tools/packaging/icons/titanium-inspector.png differ diff --git a/tools/packaging/linux/build-appimage.sh b/tools/packaging/linux/build-appimage.sh new file mode 100644 index 000000000..1be523880 --- /dev/null +++ b/tools/packaging/linux/build-appimage.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Wrap a self-contained publish directory as an AppImage (glibc linux-x64 / linux-arm64). +# Runs on x86_64 GHA runners for both target arches: download host appimagetool, set ARCH=target. +set -euo pipefail + +PRODUCT="${1:?cli|inspector}" +PAYLOAD_DIR="${2:?payload dir}" +OUT_APPIMAGE="${3:?output .AppImage path}" +VERSION="${4:-0.0.0}" +ARCH_HINT="${5:-x86_64}" # target arch: x86_64 | aarch64 + +# Resolve output path BEFORE any cd into temp dirs (relative paths must stay in caller CWD). +CALLER_PWD="$(pwd)" +if [[ "${OUT_APPIMAGE}" != /* ]]; then + OUT_ABS="${CALLER_PWD}/${OUT_APPIMAGE}" +else + OUT_ABS="${OUT_APPIMAGE}" +fi +OUT_DIR="$(dirname "${OUT_ABS}")" +OUT_LEAF="$(basename "${OUT_ABS}")" +mkdir -p "${OUT_DIR}" + +PAYLOAD_DIR="$(cd "${PAYLOAD_DIR}" && pwd)" +ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" +WORK="$(mktemp -d)" +trap 'rm -rf "${WORK}"' EXIT + +case "${PRODUCT}" in + cli) + EXE_NAME="titanium" + DESKTOP_NAME="Titanium CLI" + ICON_ID="titanium-cli" + ;; + inspector) + EXE_NAME="TitaniumInspector" + DESKTOP_NAME="Titanium Inspector" + ICON_ID="titanium-inspector" + ;; + *) + echo "error: product must be cli or inspector" >&2 + exit 1 + ;; +esac + +if [[ ! -f "${PAYLOAD_DIR}/${EXE_NAME}" && ! -f "${PAYLOAD_DIR}/${EXE_NAME}.exe" ]]; then + echo "error: ${EXE_NAME} missing under ${PAYLOAD_DIR}" >&2 + exit 1 +fi + +APPDIR="${WORK}/AppDir" +mkdir -p "${APPDIR}/usr/bin" "${APPDIR}/usr/share/applications" "${APPDIR}/usr/share/icons/hicolor/256x256/apps" + +rsync -a "${PAYLOAD_DIR}/" "${APPDIR}/usr/bin/" +chmod +x "${APPDIR}/usr/bin/${EXE_NAME}" 2>/dev/null || true + +cat > "${APPDIR}/AppRun" < "${APPDIR}/usr/share/applications/${ICON_ID}.desktop" </dev/null 2>&1; then + convert -size 256x256 xc:'#2563eb' -fill white -gravity center \ + -pointsize 48 -annotate 0 'T' "${ICON_PNG}" + else + # Minimal valid 1x1 PNG + printf '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82' \ + > "${ICON_PNG}" + fi +fi +cp -f "${ICON_PNG}" "${APPDIR}/${ICON_ID}.png" +cp -f "${ICON_PNG}" "${APPDIR}/.DirIcon" + +HOST_ARCH="$(uname -m)" +case "${HOST_ARCH}" in + x86_64|amd64) TOOL_ARCH=x86_64 ;; + aarch64|arm64) TOOL_ARCH=aarch64 ;; + *) TOOL_ARCH=x86_64 ;; +esac + +TOOL_URL="https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${TOOL_ARCH}.AppImage" +TOOL_IMG="${WORK}/appimagetool.AppImage" +echo "Fetching appimagetool (${TOOL_ARCH} host) for target ARCH=${ARCH_HINT}" +echo "Output: ${OUT_ABS}" +curl -fsSL "${TOOL_URL}" -o "${TOOL_IMG}" +chmod +x "${TOOL_IMG}" + +cd "${WORK}" +export APPIMAGE_EXTRACT_AND_RUN=1 +"${TOOL_IMG}" --appimage-extract >/dev/null +if [[ -x "${WORK}/squashfs-root/AppRun" ]]; then + TOOL="${WORK}/squashfs-root/AppRun" +elif [[ -x "${WORK}/squashfs-root/usr/bin/appimagetool" ]]; then + TOOL="${WORK}/squashfs-root/usr/bin/appimagetool" +else + echo "error: failed to extract appimagetool" >&2 + find "${WORK}/squashfs-root" -maxdepth 3 -type f 2>/dev/null | head >&2 || true + exit 1 +fi + +export ARCH="${ARCH_HINT}" +export VERSION +cd "${WORK}" +"${TOOL}" "${APPDIR}" "${OUT_ABS}" + +test -f "${OUT_ABS}" +chmod +x "${OUT_ABS}" +echo "Built ${OUT_ABS} ($(du -h "${OUT_ABS}" | awk '{print $1}'))" diff --git a/tools/packaging/linux/build-deb-rpm.sh b/tools/packaging/linux/build-deb-rpm.sh new file mode 100644 index 000000000..dcfe420f1 --- /dev/null +++ b/tools/packaging/linux/build-deb-rpm.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Build .deb and .rpm from a self-contained publish folder (glibc linux-x64 / linux-arm64). +# Uses fpm when available; falls back to a minimal dpkg-deb for .deb only. +set -euo pipefail + +PRODUCT="${1:?cli|inspector}" +PAYLOAD_DIR="${2:?payload dir}" +OUT_DIR="${3:?output directory}" +VERSION="${4:-0.0.0}" +RID="${5:-linux-x64}" # linux-x64 | linux-arm64 + +PAYLOAD_DIR="$(cd "${PAYLOAD_DIR}" && pwd)" +OUT_DIR="$(mkdir -p "${OUT_DIR}" && cd "${OUT_DIR}" && pwd)" +VERSION="${VERSION%%-*}" + +case "${RID}" in + *arm64*) DEB_ARCH=arm64; RPM_ARCH=aarch64 ;; + *) DEB_ARCH=amd64; RPM_ARCH=x86_64 ;; +esac + +case "${PRODUCT}" in + cli) + NAME="titanium-cli" + EXE="titanium" + DESC="Titanium Web Proxy CLI" + ;; + inspector) + NAME="titanium-inspector" + EXE="TitaniumInspector" + DESC="Titanium Inspector MITM debugger" + ;; + *) + echo "error: product must be cli or inspector" >&2 + exit 1 + ;; +esac + +if [[ ! -f "${PAYLOAD_DIR}/${EXE}" ]]; then + echo "error: ${EXE} missing under ${PAYLOAD_DIR}" >&2 + exit 1 +fi + +STAGE="$(mktemp -d)" +trap 'rm -rf "${STAGE}"' EXIT +PREFIX="${STAGE}/opt/${NAME}" +mkdir -p "${PREFIX}" "${STAGE}/usr/bin" +rsync -a "${PAYLOAD_DIR}/" "${PREFIX}/" +chmod +x "${PREFIX}/${EXE}" 2>/dev/null || true +# PATH symlink +ln -sf "/opt/${NAME}/${EXE}" "${STAGE}/usr/bin/${EXE}" +if [[ "${PRODUCT}" == "cli" && -f "${PREFIX}/twp" ]]; then + ln -sf "/opt/${NAME}/twp" "${STAGE}/usr/bin/twp" +fi + +if [[ "${PRODUCT}" == "inspector" ]]; then + mkdir -p "${STAGE}/usr/share/applications" "${STAGE}/usr/share/icons/hicolor" + # shellcheck source=desktop-icons.sh + source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/desktop-icons.sh" + if ! install_hicolor_icons "${STAGE}/usr/share/icons/hicolor" "titanium-inspector" \ + "${PAYLOAD_DIR}/app.ico"; then + echo "error: failed to install titanium-inspector hicolor icons" >&2 + exit 1 + fi + DESKTOP_IN="${PAYLOAD_DIR}/TitaniumInspector.desktop.in" + if [[ -f "${DESKTOP_IN}" ]]; then + sed -e "s|@EXEC@|/opt/${NAME}/${EXE}|g" -e "s|@ICON@|titanium-inspector|g" \ + "${DESKTOP_IN}" > "${STAGE}/usr/share/applications/titanium-inspector.desktop" + fi +fi + +DEB_OUT="${OUT_DIR}/${NAME}_${VERSION}_${DEB_ARCH}.deb" +RPM_OUT="${OUT_DIR}/${NAME}-${VERSION}-1.${RPM_ARCH}.rpm" + +if command -v fpm >/dev/null 2>&1; then + fpm -s dir -t deb -n "${NAME}" -v "${VERSION}" -a "${DEB_ARCH}" \ + --description "${DESC}" --license "PolyForm-Noncommercial-1.0.0" \ + --url "https://github.com/justcoding121/titanium-web-proxy" \ + -C "${STAGE}" -p "${DEB_OUT}" \ + opt usr + fpm -s dir -t rpm -n "${NAME}" -v "${VERSION}" -a "${RPM_ARCH}" \ + --description "${DESC}" --license "PolyForm-Noncommercial-1.0.0" \ + --url "https://github.com/justcoding121/titanium-web-proxy" \ + -C "${STAGE}" -p "${RPM_OUT}" \ + opt usr +else + # Minimal .deb without fpm + DEB_ROOT="$(mktemp -d)" + mkdir -p "${DEB_ROOT}/DEBIAN" + cp -a "${STAGE}/." "${DEB_ROOT}/" + cat > "${DEB_ROOT}/DEBIAN/control" < +Description: ${DESC} +Depends: libnuma1 +EOF + dpkg-deb --build "${DEB_ROOT}" "${DEB_OUT}" + rm -rf "${DEB_ROOT}" + echo "warn: fpm not installed — skipped .rpm (${RPM_OUT})" >&2 +fi + +# Rename to release convention used by download.data.ts +case "${PRODUCT}" in + cli) + [[ -f "${DEB_OUT}" ]] && cp -f "${DEB_OUT}" "${OUT_DIR}/Titanium.Cli-${RID}.deb" + [[ -f "${RPM_OUT}" ]] && cp -f "${RPM_OUT}" "${OUT_DIR}/Titanium.Cli-${RID}.rpm" + ;; + inspector) + [[ -f "${DEB_OUT}" ]] && cp -f "${DEB_OUT}" "${OUT_DIR}/TitaniumInspector-${RID}.deb" + [[ -f "${RPM_OUT}" ]] && cp -f "${RPM_OUT}" "${OUT_DIR}/TitaniumInspector-${RID}.rpm" + ;; +esac + +echo "Built packages under ${OUT_DIR}" +ls -la "${OUT_DIR}"/*."${DEB_ARCH}".deb "${OUT_DIR}"/*."${RPM_ARCH}".rpm \ + "${OUT_DIR}/Titanium."*-"${RID}".deb "${OUT_DIR}/TitaniumInspector-${RID}".* \ + "${OUT_DIR}/Titanium.Cli-${RID}".* 2>/dev/null || true diff --git a/tools/packaging/linux/install.sh b/tools/packaging/linux/install.sh old mode 100644 new mode 100755 index 2dcfbbdcf..6121c19b2 --- a/tools/packaging/linux/install.sh +++ b/tools/packaging/linux/install.sh @@ -7,9 +7,10 @@ PREFIX="${PREFIX:-${HOME}/.local}" APP_DIR="${PREFIX}/share/TitaniumInspector" BIN_DIR="${PREFIX}/bin" APPS_DIR="${PREFIX}/share/applications" -ICONS_DIR="${PREFIX}/share/icons/hicolor/256x256/apps" +HICOLOR_ROOT="${PREFIX}/share/icons/hicolor" DESKTOP_SRC="${SCRIPT_DIR}/TitaniumInspector.desktop.in" EXE_NAME="TitaniumInspector" +ICON_ID="titanium-inspector" if [[ ! -x "${SCRIPT_DIR}/${EXE_NAME}" && ! -f "${SCRIPT_DIR}/${EXE_NAME}" ]]; then echo "error: ${EXE_NAME} not found next to install.sh (extract the full zip first)" >&2 @@ -21,7 +22,7 @@ if [[ ! -f "${DESKTOP_SRC}" ]]; then exit 1 fi -mkdir -p "${APP_DIR}" "${BIN_DIR}" "${APPS_DIR}" "${ICONS_DIR}" +mkdir -p "${APP_DIR}" "${BIN_DIR}" "${APPS_DIR}" "${HICOLOR_ROOT}" echo "Installing payload to ${APP_DIR} ..." # Refresh install dir; keep a clean copy of the zip contents (including helpers). @@ -30,40 +31,67 @@ mkdir -p "${APP_DIR}" cp -a "${SCRIPT_DIR}/." "${APP_DIR}/" chmod +x "${APP_DIR}/${EXE_NAME}" "${APP_DIR}/install.sh" "${APP_DIR}/uninstall.sh" 2>/dev/null || true -ICON_SRC="" -for candidate in "${APP_DIR}/app.ico" "${APP_DIR}/Assets/app.ico" "${SCRIPT_DIR}/app.ico"; do - if [[ -f "${candidate}" ]]; then - ICON_SRC="${candidate}" - break +# Prefer shared helper when present (repo checkout or zip that ships desktop-icons.sh). +ICON_OK=0 +if [[ -f "${SCRIPT_DIR}/desktop-icons.sh" ]]; then + # shellcheck source=../desktop-icons.sh + source "${SCRIPT_DIR}/desktop-icons.sh" + PAYLOAD_DIR="${APP_DIR}" SCRIPT_DIR="${SCRIPT_DIR}" \ + install_hicolor_icons "${HICOLOR_ROOT}" "${ICON_ID}" "${APP_DIR}/app.ico" \ + && ICON_OK=1 || true +elif [[ -f "${SCRIPT_DIR}/../desktop-icons.sh" ]]; then + # shellcheck source=../desktop-icons.sh + source "${SCRIPT_DIR}/../desktop-icons.sh" + PAYLOAD_DIR="${APP_DIR}" SCRIPT_DIR="${SCRIPT_DIR}" \ + install_hicolor_icons "${HICOLOR_ROOT}" "${ICON_ID}" "${APP_DIR}/app.ico" \ + && ICON_OK=1 || true +fi + +if [[ "${ICON_OK}" -ne 1 ]]; then + # Zip-local prebuilt PNGs (release) or ImageMagick fallback. + for size in 16 32 48 128 256 512; do + mkdir -p "${HICOLOR_ROOT}/${size}x${size}/apps" + dest="${HICOLOR_ROOT}/${size}x${size}/apps/${ICON_ID}.png" + if [[ -f "${SCRIPT_DIR}/${ICON_ID}-${size}.png" ]]; then + cp -f "${SCRIPT_DIR}/${ICON_ID}-${size}.png" "${dest}" + elif [[ -f "${SCRIPT_DIR}/${ICON_ID}.png" ]]; then + cp -f "${SCRIPT_DIR}/${ICON_ID}.png" "${dest}" + elif [[ -f "${APP_DIR}/app.ico" ]] && command -v convert >/dev/null 2>&1; then + convert "${APP_DIR}/app.ico" -thumbnail "${size}x${size}" "${dest}" 2>/dev/null || true + fi + done + if [[ ! -f "${HICOLOR_ROOT}/256x256/apps/${ICON_ID}.png" && -f "${APP_DIR}/app.ico" ]]; then + mkdir -p "${HICOLOR_ROOT}/256x256/apps" + if command -v convert >/dev/null 2>&1; then + convert "${APP_DIR}/app.ico" -thumbnail 256x256 \ + "${HICOLOR_ROOT}/256x256/apps/${ICON_ID}.png" 2>/dev/null || true + else + cp -f "${APP_DIR}/app.ico" "${HICOLOR_ROOT}/256x256/apps/${ICON_ID}.ico" + fi fi -done +fi -ICON_DEST="${ICONS_DIR}/titanium-inspector.png" -if [[ -n "${ICON_SRC}" ]]; then - if command -v convert >/dev/null 2>&1; then - convert "${ICON_SRC}" -thumbnail 256x256 "${ICON_DEST}" || cp -f "${ICON_SRC}" "${ICONS_DIR}/titanium-inspector.ico" +ICON_LINE="${ICON_ID}" +if [[ ! -f "${HICOLOR_ROOT}/256x256/apps/${ICON_ID}.png" ]]; then + if [[ -f "${HICOLOR_ROOT}/256x256/apps/${ICON_ID}.ico" ]]; then + ICON_LINE="${HICOLOR_ROOT}/256x256/apps/${ICON_ID}.ico" else - # Many desktops accept .ico; also keep a copy named .png path fallback via Icon= absolute .ico - cp -f "${ICON_SRC}" "${ICONS_DIR}/titanium-inspector.ico" - ICON_DEST="${ICONS_DIR}/titanium-inspector.ico" + ICON_LINE="" fi -else - ICON_DEST="" fi ln -sfn "${APP_DIR}/${EXE_NAME}" "${BIN_DIR}/titanium-inspector" DESKTOP_OUT="${APPS_DIR}/TitaniumInspector.desktop" EXEC_LINE="${APP_DIR}/${EXE_NAME}" -ICON_LINE="${ICON_DEST}" sed -e "s|@EXEC@|${EXEC_LINE}|g" -e "s|@ICON@|${ICON_LINE}|g" "${DESKTOP_SRC}" > "${DESKTOP_OUT}" chmod 644 "${DESKTOP_OUT}" if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database "${APPS_DIR}" >/dev/null 2>&1 || true fi -if command -v gtk-update-icon-cache >/dev/null 2>&1 && [[ -d "${PREFIX}/share/icons/hicolor" ]]; then - gtk-update-icon-cache -f -t "${PREFIX}/share/icons/hicolor" >/dev/null 2>&1 || true +if command -v gtk-update-icon-cache >/dev/null 2>&1 && [[ -d "${HICOLOR_ROOT}" ]]; then + gtk-update-icon-cache -f -t "${HICOLOR_ROOT}" >/dev/null 2>&1 || true fi echo "Installed Titanium Inspector." diff --git a/tools/packaging/linux/uninstall.sh b/tools/packaging/linux/uninstall.sh old mode 100644 new mode 100755 index 359a6d1af..ab82afbd3 --- a/tools/packaging/linux/uninstall.sh +++ b/tools/packaging/linux/uninstall.sh @@ -6,16 +6,23 @@ PREFIX="${PREFIX:-${HOME}/.local}" APP_DIR="${PREFIX}/share/TitaniumInspector" BIN_DIR="${PREFIX}/bin" APPS_DIR="${PREFIX}/share/applications" -ICONS_DIR="${PREFIX}/share/icons/hicolor/256x256/apps" +HICOLOR_ROOT="${PREFIX}/share/icons/hicolor" +ICON_ID="titanium-inspector" echo "Removing Titanium Inspector from ${PREFIX} ..." rm -f "${BIN_DIR}/titanium-inspector" rm -f "${APPS_DIR}/TitaniumInspector.desktop" -rm -f "${ICONS_DIR}/titanium-inspector.png" "${ICONS_DIR}/titanium-inspector.ico" +for size in 16 32 48 128 256 512; do + rm -f "${HICOLOR_ROOT}/${size}x${size}/apps/${ICON_ID}.png" +done +rm -f "${HICOLOR_ROOT}/256x256/apps/${ICON_ID}.ico" rm -rf "${APP_DIR}" if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database "${APPS_DIR}" >/dev/null 2>&1 || true fi +if command -v gtk-update-icon-cache >/dev/null 2>&1 && [[ -d "${HICOLOR_ROOT}" ]]; then + gtk-update-icon-cache -f -t "${HICOLOR_ROOT}" >/dev/null 2>&1 || true +fi echo "Uninstalled Titanium Inspector." diff --git a/tools/packaging/osx/APPLE_SECRETS.md b/tools/packaging/osx/APPLE_SECRETS.md new file mode 100644 index 000000000..f0fc30c3e --- /dev/null +++ b/tools/packaging/osx/APPLE_SECRETS.md @@ -0,0 +1,28 @@ +# Apple Developer secrets (macOS Gatekeeper) + +Agent work (scripts + gated `release.yml`) is ready. **Notarization stays no-op until you finish this checklist** and put secrets in GitHub — do **not** paste `.p12` / `.p8` / private keys into chat. + +## A. Developer ID Application certificate + +1. [Certificates list](https://developer.apple.com/account/resources/certificates/list) → **+** → **Developer ID Application**. +2. Create a CSR locally (Keychain on Mac, or `openssl req -new -newkey rsa:2048 -nodes -keyout developer_id.key -out developer_id.csr` on Windows). +3. Upload CSR → download `.cer` → export password-protected `.p12` (needs the private key from step 2). +4. Note identity string: `Developer ID Application: Your Name (TEAMID)`. + +## B. App Store Connect API key (notarization) + +1. [App Store Connect](https://appstoreconnect.apple.com) → Users and Access → Integrations → App Store Connect API. +2. Create a key; download `.p8` once; copy **Issuer ID** and **Key ID**. + +## C. GitHub secrets + +| Secret | Value | +| --- | --- | +| `APPLE_DEVELOPER_ID` | `Developer ID Application: … (TEAMID)` | +| `APPLE_CERTIFICATE_P12` | base64 of the `.p12` | +| `APPLE_CERTIFICATE_PASSWORD` | p12 password | +| `NOTARY_KEY` | `.p8` contents | +| `NOTARY_KEY_ID` | Key ID | +| `NOTARY_ISSUER` | Issuer ID | + +When done, tell the agent: **“Apple secrets are in GitHub”** so a beta release can verify Gatekeeper. diff --git a/tools/packaging/osx/TitaniumCli.entitlements b/tools/packaging/osx/TitaniumCli.entitlements new file mode 100644 index 000000000..3778313d3 --- /dev/null +++ b/tools/packaging/osx/TitaniumCli.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.network.client + + com.apple.security.network.server + + + diff --git a/tools/packaging/osx/TitaniumInspector.entitlements b/tools/packaging/osx/TitaniumInspector.entitlements new file mode 100644 index 000000000..852069792 --- /dev/null +++ b/tools/packaging/osx/TitaniumInspector.entitlements @@ -0,0 +1,18 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + com.apple.security.network.client + + com.apple.security.network.server + + + diff --git a/tools/packaging/osx/build-app-bundle.sh b/tools/packaging/osx/build-app-bundle.sh new file mode 100644 index 000000000..466d73c84 --- /dev/null +++ b/tools/packaging/osx/build-app-bundle.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Build Titanium Inspector.app from a published self-contained folder (CI). +set -euo pipefail + +PAYLOAD_DIR="${1:?payload dir}" +APP_PATH="${2:?output .app path}" +VERSION="${3:-7.0.0}" +EXE_NAME="TitaniumInspector" + +PAYLOAD_DIR="$(cd "$PAYLOAD_DIR" && pwd)" +if [[ ! -f "${PAYLOAD_DIR}/${EXE_NAME}" ]]; then + echo "error: ${EXE_NAME} missing under ${PAYLOAD_DIR}" >&2 + exit 1 +fi + +rm -rf "${APP_PATH}" +mkdir -p "${APP_PATH}/Contents/MacOS" "${APP_PATH}/Contents/Resources" + +# Copy publish output into MacOS (exclude helper scripts). +rsync -a --exclude 'install-app.sh' --exclude 'uninstall-app.sh' \ + "${PAYLOAD_DIR}/" "${APP_PATH}/Contents/MacOS/" 2>/dev/null \ + || { + cp -R "${PAYLOAD_DIR}/." "${APP_PATH}/Contents/MacOS/" + rm -f "${APP_PATH}/Contents/MacOS/install-app.sh" "${APP_PATH}/Contents/MacOS/uninstall-app.sh" + } + +chmod +x "${APP_PATH}/Contents/MacOS/${EXE_NAME}" + +# shellcheck source=../desktop-icons.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/desktop-icons.sh" +ICON_KEY="$(PAYLOAD_DIR="${PAYLOAD_DIR}" install_macos_app_icon \ + "${APP_PATH}/Contents/Resources" "${PAYLOAD_DIR}/app.ico" || true)" + +cat > "${APP_PATH}/Contents/Info.plist" < + + + + CFBundleExecutable + ${EXE_NAME} + CFBundleIdentifier + io.github.justcoding121.TitaniumInspector + CFBundleName + Titanium Inspector + CFBundleDisplayName + Titanium Inspector + CFBundlePackageType + APPL + CFBundleShortVersionString + ${VERSION} + CFBundleVersion + ${VERSION} + LSMinimumSystemVersion + 12.0 + NSHighResolutionCapable + +$([ -n "${ICON_KEY}" ] && printf '\tCFBundleIconFile\n\t%s\n' "${ICON_KEY}") + + +EOF + +echo "Built ${APP_PATH}" diff --git a/tools/packaging/osx/build-dmg.sh b/tools/packaging/osx/build-dmg.sh new file mode 100644 index 000000000..427bc4b20 --- /dev/null +++ b/tools/packaging/osx/build-dmg.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Create a simple drag-to-Applications DMG from a .app bundle. +set -euo pipefail + +APP_PATH="${1:?path to .app}" +DMG_PATH="${2:?output .dmg path}" +VOLUME_NAME="${3:-Titanium Inspector}" + +APP_PATH="$(cd "$(dirname "${APP_PATH}")" && pwd)/$(basename "${APP_PATH}")" +STAGE="$(mktemp -d)" +trap 'rm -rf "${STAGE}"' EXIT + +cp -R "${APP_PATH}" "${STAGE}/" +ln -s /Applications "${STAGE}/Applications" + +# UDZO compressed read-only image +hdiutil create -volname "${VOLUME_NAME}" -srcfolder "${STAGE}" -ov -format UDZO "${DMG_PATH}" +echo "Built ${DMG_PATH}" diff --git a/tools/packaging/osx/install-app.sh b/tools/packaging/osx/install-app.sh index b624538fa..08205195e 100644 --- a/tools/packaging/osx/install-app.sh +++ b/tools/packaging/osx/install-app.sh @@ -27,21 +27,20 @@ rsync -a --exclude 'install-app.sh' --exclude 'uninstall-app.sh' \ chmod +x "${APP_PATH}/Contents/MacOS/${EXE_NAME}" -# Best-effort icon: convert .ico → .icns when iconutil/sips tooling exists. -ICON_SRC="" -for candidate in "${SCRIPT_DIR}/app.ico" "${APP_PATH}/Contents/MacOS/app.ico"; do - if [[ -f "${candidate}" ]]; then - ICON_SRC="${candidate}" - break - fi -done - ICON_KEY="" -if [[ -n "${ICON_SRC}" ]] && command -v sips >/dev/null 2>&1 && command -v iconutil >/dev/null 2>&1; then +if [[ -f "${SCRIPT_DIR}/desktop-icons.sh" ]]; then + # shellcheck source=../desktop-icons.sh + source "${SCRIPT_DIR}/desktop-icons.sh" + ICON_KEY="$(PAYLOAD_DIR="${SCRIPT_DIR}" SCRIPT_DIR="${SCRIPT_DIR}" \ + install_macos_app_icon "${APP_PATH}/Contents/Resources" \ + "${SCRIPT_DIR}/app.ico" || true)" +elif [[ -f "${SCRIPT_DIR}/AppIcon.icns" ]]; then + cp -f "${SCRIPT_DIR}/AppIcon.icns" "${APP_PATH}/Contents/Resources/AppIcon.icns" + ICON_KEY="AppIcon" +elif [[ -f "${SCRIPT_DIR}/app.ico" ]] && command -v sips >/dev/null 2>&1 && command -v iconutil >/dev/null 2>&1; then ICONSET="$(mktemp -d)/AppIcon.iconset" mkdir -p "${ICONSET}" - # Produce a large PNG then downscale for iconset sizes. - sips -s format png "${ICON_SRC}" --out /tmp/titanium-inspector-icon.png >/dev/null 2>&1 || true + sips -s format png "${SCRIPT_DIR}/app.ico" --out /tmp/titanium-inspector-icon.png >/dev/null 2>&1 || true if [[ -f /tmp/titanium-inspector-icon.png ]]; then for size in 16 32 128 256 512; do sips -z "${size}" "${size}" /tmp/titanium-inspector-icon.png --out "${ICONSET}/icon_${size}x${size}.png" >/dev/null diff --git a/tools/packaging/osx/sign-and-notarize.sh b/tools/packaging/osx/sign-and-notarize.sh new file mode 100644 index 000000000..1dae01976 --- /dev/null +++ b/tools/packaging/osx/sign-and-notarize.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Codesign + notarize + staple a macOS artifact (.app, .dmg, or .zip). +# Requires env: APPLE_DEVELOPER_ID, APPLE_CERTIFICATE_P12 (base64), APPLE_CERTIFICATE_PASSWORD, +# NOTARY_KEY, NOTARY_KEY_ID, NOTARY_ISSUER +set -euo pipefail + +TARGET="${1:?path to .app / .dmg / .zip}" +ENTITLEMENTS="${2:-}" + +if [[ -z "${APPLE_CERTIFICATE_P12:-}" || -z "${APPLE_DEVELOPER_ID:-}" ]]; then + echo "skip: Apple signing secrets not configured" + exit 0 +fi + +KEYCHAIN="twp-signing.keychain-db" +KEYCHAIN_PW="$(openssl rand -base64 24)" +CERT_PATH="$(mktemp).p12" +trap 'rm -f "${CERT_PATH}"; security delete-keychain "${KEYCHAIN}" 2>/dev/null || true' EXIT + +echo "${APPLE_CERTIFICATE_P12}" | base64 --decode > "${CERT_PATH}" +security create-keychain -p "${KEYCHAIN_PW}" "${KEYCHAIN}" +security set-keychain-settings -lut 21600 "${KEYCHAIN}" +security unlock-keychain -p "${KEYCHAIN_PW}" "${KEYCHAIN}" +security import "${CERT_PATH}" -P "${APPLE_CERTIFICATE_PASSWORD}" -A -t cert -f pkcs12 -k "${KEYCHAIN}" +security list-keychain -d user -s "${KEYCHAIN}" $(security list-keychain -d user | tr -d '"') +security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${KEYCHAIN_PW}" "${KEYCHAIN}" + +SIGN_ARGS=(--force --options runtime --timestamp --sign "${APPLE_DEVELOPER_ID}") +if [[ -n "${ENTITLEMENTS}" && -f "${ENTITLEMENTS}" ]]; then + SIGN_ARGS+=(--entitlements "${ENTITLEMENTS}") +fi + +if [[ -d "${TARGET}" && "${TARGET}" == *.app ]]; then + # Sign nested dylibs/frameworks first, then the app. + find "${TARGET}/Contents" -type f \( -name '*.dylib' -o -name '*.so' -o -perm +111 \) 2>/dev/null \ + | while read -r f; do + file -b "${f}" 2>/dev/null | grep -qiE 'Mach-O|library|executable' || continue + codesign "${SIGN_ARGS[@]}" "${f}" || true + done + codesign "${SIGN_ARGS[@]}" --deep "${TARGET}" + codesign --verify --verbose=2 "${TARGET}" +elif [[ -f "${TARGET}" ]]; then + codesign "${SIGN_ARGS[@]}" "${TARGET}" + codesign --verify --verbose=2 "${TARGET}" || true +else + echo "error: unsupported target ${TARGET}" >&2 + exit 1 +fi + +# notarytool accepts only zip / pkg / dmg (or a zipped .app). Raw Mach-O binaries are codesigned only. +if [[ -n "${NOTARY_KEY:-}" && -n "${NOTARY_KEY_ID:-}" && -n "${NOTARY_ISSUER:-}" ]]; then + KEY_FILE="$(mktemp).p8" + printf '%s\n' "${NOTARY_KEY}" > "${KEY_FILE}" + SUBMIT="" + if [[ -d "${TARGET}" && "${TARGET}" == *.app ]]; then + SUBMIT="$(mktemp -d)/notarize.zip" + ditto -c -k --keepParent "${TARGET}" "${SUBMIT}" + elif [[ -f "${TARGET}" && ( "${TARGET}" == *.zip || "${TARGET}" == *.dmg || "${TARGET}" == *.pkg ) ]]; then + SUBMIT="${TARGET}" + else + echo "codesign-only (not a notarizable container): ${TARGET}" + fi + if [[ -n "${SUBMIT}" ]]; then + xcrun notarytool submit "${SUBMIT}" --wait \ + --key "${KEY_FILE}" --key-id "${NOTARY_KEY_ID}" --issuer "${NOTARY_ISSUER}" + if [[ "${TARGET}" == *.dmg || "${TARGET}" == *.app || -d "${TARGET}" ]]; then + xcrun stapler staple "${TARGET}" || true + fi + fi + rm -f "${KEY_FILE}" +fi + +echo "Signed ${TARGET}" diff --git a/tools/packaging/sign-checksums.sh b/tools/packaging/sign-checksums.sh new file mode 100644 index 000000000..42ec8b852 --- /dev/null +++ b/tools/packaging/sign-checksums.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# GPG-sign SHA256SUMS (and optional release-manifest.json) when GPG_PRIVATE_KEY is set. +set -euo pipefail + +DIST_DIR="${1:?dist directory}" +cd "${DIST_DIR}" + +# Build checksums for all release artifacts +: > SHA256SUMS +for f in *; do + [[ -f "$f" ]] || continue + [[ "$f" == SHA256SUMS* ]] && continue + sha256sum "$f" >> SHA256SUMS +done + +if [[ -z "${GPG_PRIVATE_KEY:-}" ]]; then + echo "warn: GPG_PRIVATE_KEY not set — wrote SHA256SUMS without signature" + exit 0 +fi + +GNUPGHOME="$(mktemp -d)" +export GNUPGHOME +trap 'rm -rf "${GNUPGHOME}"' EXIT +chmod 700 "${GNUPGHOME}" + +printf '%s\n' "${GPG_PRIVATE_KEY}" | gpg --batch --import +# Optional passphrase via GPG_PASSPHRASE +ARGS=(--batch --yes --detach-sign --armor) +if [[ -n "${GPG_PASSPHRASE:-}" ]]; then + ARGS+=(--pinentry-mode loopback --passphrase "${GPG_PASSPHRASE}") +fi +gpg "${ARGS[@]}" -o SHA256SUMS.asc SHA256SUMS +if [[ -f release-manifest.json ]]; then + gpg "${ARGS[@]}" -o release-manifest.json.asc release-manifest.json +fi +echo "Wrote SHA256SUMS and signatures" diff --git a/tools/packaging/update-feed.schema.json b/tools/packaging/update-feed.schema.json index 1eb789481..5f6d27214 100644 --- a/tools/packaging/update-feed.schema.json +++ b/tools/packaging/update-feed.schema.json @@ -16,6 +16,7 @@ "plus": { "type": "object", "properties": { + "version": { "type": "string" }, "requiredAbstractions": { "type": "string" }, "asset": { "$ref": "#/$defs/asset" } } diff --git a/tools/packaging/winget/TitaniumCli.yaml b/tools/packaging/winget/TitaniumCli.yaml index 265a201a3..234b89241 100644 --- a/tools/packaging/winget/TitaniumCli.yaml +++ b/tools/packaging/winget/TitaniumCli.yaml @@ -1,6 +1,8 @@ # justcoding121.TitaniumCli +# Submit a PR to microsoft/winget-pkgs after a *signed* stable GitHub Release. +# Authenticode publisher: Jehonathan Thomas. PackageIdentifier: justcoding121.TitaniumCli -PackageVersion: 7.0.0 +PackageVersion: 7.0.5 PackageLocale: en-US Publisher: justcoding121 PackageName: Titanium CLI @@ -13,9 +15,9 @@ Installers: NestedInstallerFiles: - RelativeFilePath: titanium.exe PortableCommandAlias: titanium - - RelativeFilePath: titanium.exe + - RelativeFilePath: twp.exe PortableCommandAlias: twp - InstallerUrl: https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.0/Titanium.Cli-win-x64.zip - InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000 + InstallerUrl: https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.5/Titanium.Cli-win-x64.zip + InstallerSha256: 8C6D09C7293C9A3D205C64FB00D946A10AFA3379F072FA83A87EEFC4FE9425E2 ManifestType: singleton ManifestVersion: 1.6.0 diff --git a/tools/packaging/winget/TitaniumInspector.yaml b/tools/packaging/winget/TitaniumInspector.yaml index aa7ed4c3c..2eda1aada 100644 --- a/tools/packaging/winget/TitaniumInspector.yaml +++ b/tools/packaging/winget/TitaniumInspector.yaml @@ -1,22 +1,24 @@ # justcoding121.TitaniumInspector -# Submit a PR to https://github.com/microsoft/winget-pkgs after a GitHub Release with MSI. +# Submit a PR to https://github.com/microsoft/winget-pkgs after a *signed* GitHub Release with MSI. +# Authenticode publisher: Jehonathan Thomas. PackageIdentifier: justcoding121.TitaniumInspector -PackageVersion: 7.0.0 +PackageVersion: 7.0.5 PackageLocale: en-US Publisher: justcoding121 PublisherUrl: https://github.com/justcoding121/titanium-web-proxy PackageName: Titanium Inspector License: PolyForm Noncommercial 1.0.0 +LicenseUrl: https://github.com/justcoding121/titanium-web-proxy/blob/develop/licenses/PolyForm-Noncommercial-1.0.0.txt ShortDescription: Desktop HTTP(S) traffic debugger for Titanium Web Proxy. Installers: - Architecture: x64 InstallerType: wix - InstallerUrl: https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.0/TitaniumInspector-win-x64.msi - InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000 + InstallerUrl: https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.5/TitaniumInspector-win-x64.msi + InstallerSha256: 15179C71A50B7724F55F421025086E0DA798B6A18EC9004FE64F22E04002124E - Architecture: x64 InstallerType: portable - InstallerUrl: https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.0/TitaniumInspector-win-x64.zip - InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000 + InstallerUrl: https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.5/TitaniumInspector-win-x64.zip + InstallerSha256: 2A5F29234E2DFA4B29552CFB42E0D84E0CBC556C4787072196FD6EB0756AC15B ManifestType: singleton ManifestVersion: 1.6.0 diff --git a/website/.vitepress/config.mts b/website/.vitepress/config.mts index f75f6f6f3..28467e0ad 100644 --- a/website/.vitepress/config.mts +++ b/website/.vitepress/config.mts @@ -77,7 +77,7 @@ export default defineConfig({ text: 'Edit this page on GitHub', }, footer: { - message: 'Core & CLI: MIT · Plus & Inspector: PolyForm Noncommercial', + message: 'Core, CLI, and website: MIT', copyright: 'Copyright © 2015–present Jehonathan Thomas', }, }, diff --git a/website/.vitepress/theme/custom.css b/website/.vitepress/theme/custom.css index 93297e04b..bcc07c0b3 100644 --- a/website/.vitepress/theme/custom.css +++ b/website/.vitepress/theme/custom.css @@ -47,6 +47,37 @@ color: var(--vp-c-brand-1); } +.rps-preview { + margin: 0.75rem 0 1.5rem; +} + +.rps-preview img { + display: block; + width: 100%; + height: auto; + border: 1px solid var(--vp-c-divider); + border-radius: 10px; +} + +.inspector-preview { + margin: 0.75rem 0 1.25rem; + max-width: 40rem; +} + +.inspector-preview img { + display: block; + width: 100%; + height: auto; + border: 1px solid var(--vp-c-divider); + border-radius: 10px; +} + +.inspector-preview figcaption { + margin-top: 0.45rem; + font-size: 0.85rem; + color: var(--vp-c-text-2); +} + .download-grid { display: grid; gap: 0.75rem; diff --git a/website/docs/cli.md b/website/docs/cli.md index befd44485..f8beb00c0 100644 --- a/website/docs/cli.md +++ b/website/docs/cli.md @@ -1,67 +1,187 @@ -# CLI (`titanium` / `twp`) +# CLI -Standalone reverse / edge proxy for any backend stack. MIT licensed. Self-contained binaries for Windows, Linux, and macOS — [Download](/download). +Standalone reverse / edge proxy for any backend stack. MIT licensed. For operators who want YAML (or a familiar reverse-proxy dialect) instead of embedding .NET. + +**Next:** [Download](/download) a self-contained binary → write a config → `titanium test` / `titanium run`. Alias: `twp`. + +## Quick start + +```yaml +schemaVersion: "7.1" +listeners: + - host: "127.0.0.1" + port: 8000 + decryptSsl: false + forwardHost: "127.0.0.1" + forwardPort: 8080 +``` + +```shell +titanium test -c twp.yaml +titanium run -c twp.yaml +``` + +Foreground run blocks until Ctrl+C (or SIGTERM). Exit `0` on clean stop; `1` on config/start errors. Opt-in NDJSON access logs via `server.accessLog` (see [Configuration](/docs/configuration)); bodies are never buffered for logging. + +Path-based routing and load balancing: [Configuration](/docs/configuration). ## Commands ```text -titanium run -c [-v|--verbose] +titanium run -c [-v|--verbose] [--service] titanium test -c +titanium service install|uninstall|start|stop|restart|status titanium version [--check] [--plus] [--channel beta] -titanium update [--plus] [--channel beta] +titanium update [--plus] [--remove-plus] [--channel beta] titanium http3-deps status|install ``` -`twp` is an alias for the same binary. +Nested help: `titanium --help` (and `titanium service install --help`, etc.). | Command | Purpose | |---------|---------| -| `run` | Start the proxy from YAML/JSON (or other dialects) | +| `run` | Start the proxy from a config file | | `test` | Validate config without serving traffic | +| `service` | Install / start / stop an OS service so the proxy survives reboot | | `version` | Print local version; `--check` compares to the update feed | -| `update` | Self-update the CLI from the release feed (download, verify SHA256, replace install); `--plus` updates the Plus DLL | +| `update` | Self-update the CLI (download, verify SHA256, replace install); `--plus` installs/updates Plus; `--remove-plus` deletes it | | `http3-deps` | Report Quic availability; optionally install system MsQuic on edge hosts | -Channels: `stable` (default) or `beta` via `--channel` or `TITANIUM_UPDATE_CHANNEL`. Messages always label the channel. `titanium update` does **not** use winget (so beta and non-Windows stay consistent); use winget only for the initial install on Windows stable. +### `run` -**Alpine / Kubernetes:** download the `linux-musl-*` RID zip (not `linux-x64`). See [HTTP/3](/docs/http3). +```text +titanium run -c [-v|--verbose] [--service] [--name ] +``` -## Minimal ForwardHost reverse +| Flag | Meaning | +|------|---------| +| `-c`, `--config` | Path to config (required) | +| `-v`, `--verbose` | Debug console logging | +| `--service` | Service-worker mode (used by `titanium service install`; no “Press Ctrl+C” prompt; SIGTERM / SCM stop) | +| `--name` | Windows SCM name when `--service` is set (default `titanium`) | -```yaml -schemaVersion: "7.0" -listeners: - - host: "127.0.0.1" - port: 8000 - decryptSsl: false - forwardHost: "127.0.0.1" - forwardPort: 8080 +### `test` + +```text +titanium test -c +``` + +Loads and validates the config without opening listeners. Exit `0` when OK; `1` when validation fails. + +### `service` {#service} + +Register the same `titanium run` binary with the OS so it starts at boot and restarts on failure (Windows Service, Linux systemd, or macOS launchd) — the same split nginx uses: one process, the OS supervises it. + +```text +titanium service install -c [--name titanium] [--user] [--no-start] +titanium service uninstall [--name titanium] [--user] +titanium service start|stop|restart|status [--name titanium] [--user] ``` +| Flag | Meaning | +|------|---------| +| `-c`, `--config` | Config path for **install** (validated; stored as an absolute path) | +| `--name` | Service / unit name (default `titanium`). On macOS the launchd label is `com.justcoding121.` unless the name already starts with `com.` | +| `--user` | Per-user systemd unit or LaunchAgent (no root). **Not supported on Windows.** Ports 80/443 usually fail without privileges. | +| `--no-start` | Install and enable, but do not start immediately | + +The unit runs: + +```text +titanium run -c --service +``` + +Working directory is the config file’s directory (so relative cert / static paths in YAML still resolve). Machine services require **Administrator** (Windows) or **root** (Linux/macOS). In an interactive terminal, Titanium asks the OS for permission (UAC on Windows, sudo on Linux/macOS). If you cancel the prompt, or the session is not interactive (CI / redirected IO), re-run from an elevated prompt — or use `--user` on Linux/macOS. + +#### Examples + ```shell -titanium test -c twp.yaml -titanium run -c twp.yaml +# Windows — UAC prompt if you are not already Administrator +titanium service install -c C:\proxy\twp.yaml +titanium service status +titanium service stop +titanium service start + +# Linux (systemd) — sudo prompt if you are not root +titanium service install -c /etc/titanium/twp.yaml +# Per-user (no elevation); for start-at-boot without login: +# loginctl enable-linger $USER +titanium service install -c ~/twp.yaml --user + +# macOS (LaunchDaemon) — sudo prompt if you are not root +titanium service install -c /usr/local/etc/titanium/twp.yaml +``` + +#### Logs + +| OS | Where to look | +|----|----------------| +| Windows | `%ProgramData%\Titanium\logs\titanium.log` when YAML has no file log (SCM has no console). Event Viewer for service start/stop. | +| Linux | `journalctl -u titanium` (system) or `journalctl --user -u titanium` (`--user`) | +| macOS | `/Library/Logs/Titanium/` (daemon) or `~/Library/Logs/Titanium/` (`--user`) | + +#### Status exit codes + +`titanium service status` exits `0` when the unit is installed (running or stopped), `1` when not installed. + +### `version` + +```text +titanium version [--check] [--plus] [--channel stable|beta] +``` + +Prints local CLI / Core / Abstractions / Configuration versions. With `--check`, compares to the update feed (`0` up to date, `2` update available, `1` feed error). `--plus` includes Plus. + +### `update` + +```text +titanium update [--plus] [--remove-plus] [--channel stable|beta] ``` -## Routes and clusters +Downloads the CLI zip (or Plus with `--plus`), verifies SHA256, and replaces the install. `--remove-plus` deletes Plus beside the CLI (no network; mutually exclusive with `--plus`). If an OS service is running, stop it first so the executable can be replaced: + +```shell +titanium service stop +titanium update +titanium service start +``` + +Channels: `stable` (default) or `beta` via `--channel` or `TITANIUM_UPDATE_CHANNEL` (other values are rejected). Messages always label the channel and print local → remote versions. `titanium update` upgrades when the feed is newer; same-semver beta switches are allowed; it does not reinstall when already current. `titanium update` uses the product download feed on every OS (not OS package managers). + +### `http3-deps` -For path-based routing and load balancing, see [Configuration](/docs/configuration). +```text +titanium http3-deps status|install +``` + +Reports Quic support and optionally installs system MsQuic. Prefer the matching download zip, which already bundles natives — see [HTTP/3](/docs/http3). ## Plus sidecar ```shell titanium update --plus +titanium update --remove-plus ``` -Enable in config (`plus.enabled: true` + control-plane shared secret). Details: [Plus](/docs/plus). +Enable in config (`plus.enabled: true` + control-plane shared secret); disable with `plus.enabled: false`. Use `--remove-plus` to delete Plus from disk. Details: [Plus](/docs/plus). + +## More -## Config dialects +### Config dialects | Extension | Dialect | |-----------|---------| -| `.yaml` / `.yml` / `.json` | Native `twp` schema 7.0 | +| `.yaml` / `.yml` / `.json` | Native `twp` schema 7.1 | | `.twp` | Compact site-file (`host / => http://origin`) | -| `.conf` | nginx-ish (`listen`, `server_name`, `location`, `proxy_pass`) | +| `.conf` | HTTP-server style (`listen`, `server_name`, `location`, `proxy_pass`) for familiar reverse-proxy configs | + +### Alpine / containers + +Download the `linux-musl-*` zip (not `linux-x64`) for Alpine or musl-based Kubernetes images. See [HTTP/3](/docs/http3). + +### Reload without restart (Unix) + +On Linux/macOS, **SIGHUP** reloads routes/clusters from the same config path without dropping the process or in-flight connections (listeners stay bound). ## See also diff --git a/website/docs/configuration.md b/website/docs/configuration.md index 9fbd7c13e..aca702a4b 100644 --- a/website/docs/configuration.md +++ b/website/docs/configuration.md @@ -1,6 +1,27 @@ # Configuration (`twp.yaml`) -Native schema version **7.1**. Root document maps to the configuration models in `Titanium.Web.Proxy.Configuration`. +Native schema version **7.1**. For CLI operators: start with a minimal reverse file, then add routes, certificates, or Plus as needed. + +**Next:** copy the minimal reverse below → `titanium test -c twp.yaml` → `titanium run -c twp.yaml`. + +## Minimal reverse + +```yaml +schemaVersion: "7.1" +listeners: + - host: "127.0.0.1" + port: 8000 + decryptSsl: false + forwardHost: "127.0.0.1" + forwardPort: 8080 +``` + +```shell +titanium test -c twp.yaml +titanium run -c twp.yaml +``` + +`forwardHost` / `forwardPort` is the classic single-origin reverse (no route table). For path-based routing and load balancing, add `routes` and `clusters` below. ## Top-level shape @@ -16,7 +37,7 @@ logging: null server: null ``` -Engine knobs live under `server:` (this document). Plus feature options stay under `plus:` / `plus.options` — Plus does **not** configure `ProxyServer`. +Engine knobs live under `server:` ([reference](#server-reference)). Plus feature options stay under `plus:` / `plus.options` — Plus does **not** configure the engine. ## Listeners @@ -24,7 +45,7 @@ Engine knobs live under `server:` (this document). Plus feature options stay und |-------|------|-------| | `host` | string | Default `0.0.0.0` | | `port` | int | Default `8000` | -| `decryptSsl` | bool | HTTPS MITM / TLS terminate | +| `decryptSsl` | bool | Terminate TLS / decrypt HTTPS (man-in-the-middle when used for MITM) | | `type` | string? | `explicit`, `transparent`, `socks`, or `quic` (null uses ForwardHost heuristics) | | `forwardHost` / `forwardPort` | string / int | Classic single-origin reverse (no route table) | | `enableHttp2` | bool? | `false` forces H1 globally; null inherits `server` / proxy default | @@ -65,7 +86,89 @@ Engine knobs live under `server:` (this document). Plus feature options stay und Match fields typically include host, path (`Exact` / `Prefix` / `Template`), method, headers, and query. Cluster algorithms include RoundRobin, Random, LeastRequests, and LeastTime; destinations support weight and sticky cookie/header. When any cluster uses `LeastTime`, the CLI automatically enables request timing capture so latency EWMA can drive selection. -## Server (`ProxyServer` knobs) +### Route transforms + +Optional `transforms` on a route rewrite the upstream request (and can stage response header changes). Empty/absent transforms keep the reverse fast path. Supported kinds: + +| Kind | Parameters | Effect | +|------|------------|--------| +| `PathRemovePrefix` | `prefix` | Strip a path prefix | +| `PathPrefix` | `prefix` | Prepend a path prefix | +| `QueryValueSet` | `name`, `value` | Set or replace a query parameter | +| `RequestHeaderSet` | `name`, `value` | Set a request header | +| `RequestHeaderRemove` | `name` | Remove a request header | +| `ResponseHeaderSet` | `name`, `value` | Set a response header after the origin responds | +| `ResponseHeaderRemove` | `name` | Remove a response header after the origin responds | + +```json +"transforms": [ + { "kind": "PathPrefix", "parameters": { "prefix": "/gw" } }, + { "kind": "QueryValueSet", "parameters": { "name": "env", "value": "lab" } }, + { "kind": "RequestHeaderSet", "parameters": { "name": "X-Edge", "value": "1" } } +] +``` + +## Static files + +```yaml +staticFiles: + root: "./www" + enableGzip: true + enableBrotli: false +``` + +## Certificates / ACME + +Automatic Certificate Management Environment (**ACME**) can obtain Let’s Encrypt (or other directory) certificates for public listeners: + +```yaml +certificates: + certificatePath: "./certs/fullchain.pem" + privateKeyPath: "./certs/privkey.pem" + acmeEmail: "ops@example.com" + acmeDomain: "app.example.com" + acmeDirectory: "https://acme-v02.api.letsencrypt.org/directory" +``` + +Use real paths and emails in your environment. Do not commit private keys. MITM engine knobs are under `server.certificateManager`; this section is for listener leaf PEM/PFX and ACME. + +## Logging + +```yaml +logging: + enabled: true + minimumLevel: "Error" + enableConsole: true + enableConsoleColors: true + enableFile: false + filePath: null + maxFileSizeBytes: null + maxRolledFiles: null + queueCapacity: null +``` + +## Plus + +```yaml +plus: + enabled: true + controlPlane: + host: "127.0.0.1" + port: 9080 + sharedSecret: "" + options: + cache.enable: "true" +``` + +Common `plus.options` keys (string values): see the table on [Plus](/docs/plus) (`discovery.*`, `security.*`, `waf.*`, `state.*`, `resilience.*`, `cache.enable`, `grpc.transcode.*`). Enabling `grpc.transcode.enabled` forces the HTTP session interception path — see [gRPC-JSON transcoding](/docs/grpc-json-transcoding). Engine settings belong in `server:`, not `plus.options`. + +## Validate + +```shell +titanium test -c twp.yaml +``` + +## Server reference Null nested objects and null properties leave the library or profile default. Apply order: `profile` first, then overlays. @@ -85,6 +188,9 @@ server: blockPrivateNetworkDestinations: false checkCertificateRevocation: NoCheck dnsServerEndPoint: "8.8.8.8:53" + accessLog: + path: "logs/access.ndjson" # omit or null = off (zero cost) + sampleRate: 1.0 # 0.0–1.0 timeouts: connectionTimeOutSeconds: 60 connectTimeOutSeconds: 20 @@ -148,6 +254,10 @@ server: upStreamEndPoint: null upStreamEndPointIPv4: null upStreamEndPointIPv6: null + decryptSkipHosts: [] # present ⇒ Replace tunnel-only list (omit key to keep Merge factory defaults) + decryptOnlyHosts: [] # when non-empty, only these hosts are decrypted (Replace with skip/only) + systemProxyBypassHosts: null # present ⇒ Replace OS bypass (omit ⇒ Merge identity defaults); SSO risk if removed + proxyLoopback: true # localhost via proxy when building SystemProxySettings from config certificateManager: certificateEngine: BouncyCastleFast leafCertificateKeyAlgorithm: EcdsaP256 @@ -161,76 +271,36 @@ server: rootCertificateIssuerName: null saveFakeCertificates: true disableWildCardCertificates: false + accessLog: null # opt-in NDJSON access log (see below) ``` Listener-level `enableHttp2: false` still forces HTTP/2 off after `server.enableHttp2`. Listener-level `enableHttp3: false` (or `server.enableHttp3: false`) disables HTTP/3. -HTTP interception is not a YAML knob: the CLI turns it on automatically when the config needs the session path (transforms, static files, or ACME). Request timing capture is not a YAML knob either: it is enabled automatically when any cluster uses `LeastTime`. - -### Code-only callbacks - -These cannot be set from YAML; wire them in library / embedder code: - -- `ProxyBasicAuthenticateFunc`, `ProxySchemeAuthenticateFunc` (and related realm/schemes on `ProxyServer`) -- `WinAuthCredentialsProvider` -- `GetCustomUpStreamProxyFunc`, `CustomUpStreamProxyFailureFunc` -- `ShouldInterceptHttp` -- `BufferPool`, `Logging.LoggerFactory`, custom `CertificateStorage` - -## Static files +HTTP interception is not a YAML knob: the CLI turns it on automatically when the config needs the session path (transforms, static files, ACME, access logs, or gRPC-JSON). Request timing capture is not a YAML knob either: it is enabled automatically when any cluster uses `LeastTime` (and when access logs are enabled). -```yaml -staticFiles: - root: "./www" - enableGzip: true - enableBrotli: false -``` +### Access log (`server.accessLog`) -## Certificates / ACME +Opt-in JSON Lines (NDJSON) access log written **after** each response. Bodies are never buffered for this feature. ```yaml -certificates: - certificatePath: "./certs/fullchain.pem" - privateKeyPath: "./certs/privkey.pem" - acmeEmail: "ops@example.com" - acmeDomain: "app.example.com" - acmeDirectory: "https://acme-v02.api.letsencrypt.org/directory" +server: + accessLog: + path: "./logs/access.ndjson" + sampleRate: 1.0 # 0.0–1.0; omit for 1.0 ``` -Use real paths and emails in your environment. Do not commit private keys. MITM engine knobs are under `server.certificateManager`; this section is for listener leaf PEM/PFX and ACME. - -## Logging - -```yaml -logging: - enabled: true - minimumLevel: "Error" - enableConsole: true - enableConsoleColors: true - enableFile: false - filePath: null - maxFileSizeBytes: null - maxRolledFiles: null - queueCapacity: null -``` +Each line includes `ts`, `method`, `url`, `host`, `status`, `durationMs`, and `clientIp`. Omit `accessLog` (or leave `path` empty) for zero cost on the hot path. -## Plus +### Graceful reload -```yaml -plus: - enabled: true - controlPlane: - host: "127.0.0.1" - port: 9080 - sharedSecret: "" - options: - cache.enable: "true" -``` +On Unix, send **SIGHUP** to a running `titanium run` process to reload routes and clusters from the same config file without stopping listeners or aborting in-flight requests. Windows service hosts should use a process restart or the Plus control-plane snapshot API instead. -Common `plus.options` keys (string values): discovery, security, WAF, rate-limit state (`state.mode=memory` or `state.redis`), resilience probes, and cache. See [Plus](/docs/plus). Engine settings belong in `server:`, not `plus.options`. +### Code-only callbacks -## Validate +These cannot be set from YAML; wire them in Library / C# code: -```shell -titanium test -c twp.yaml -``` +- `ProxyBasicAuthenticateFunc`, `ProxySchemeAuthenticateFunc` (and related realm/schemes on `ProxyServer`) +- `WinAuthCredentialsProvider` +- `GetCustomUpStreamProxyFunc`, `CustomUpStreamProxyFailureFunc` +- `ShouldInterceptHttp` +- `BufferPool`, `Logging.LoggerFactory`, custom `CertificateStorage` diff --git a/website/docs/editions.md b/website/docs/editions.md index 60cccfed1..7858d2792 100644 --- a/website/docs/editions.md +++ b/website/docs/editions.md @@ -1,18 +1,20 @@ # Editions & licenses -| Product | Role | License | How you get it | -|---------|------|---------|----------------| -| **Titanium.Cli** (`titanium` / `twp`) | Standalone reverse / edge daemon for any stack | MIT | [Download](/download) zips, winget | -| **Titanium Inspector** | Desktop MITM debugger | [PolyForm NC](https://github.com/justcoding121/titanium-web-proxy/blob/develop/licenses/PolyForm-Noncommercial-1.0.0.txt) | MSI / zip / winget | -| **Titanium.Plus** | Control plane, ops, observability, dashboard | PolyForm NC | `titanium update --plus` | -| **Titanium.Web.Proxy** | Optional embeddable library for .NET apps | MIT | NuGet | +**CLI**, core engine, and this website are **MIT**. **Inspector** and **Plus** are optional add-ons; license details are in [License](#license) below. -CLI and Plus target reverse-proxy / edge workloads (routing, load balancing, health, discovery) on Windows, Linux, and macOS. Inspector is the MITM debugging product. The Core library is for embedding the same engine in a .NET process. +| Product | Role | How you get it | +|---------|------|----------------| +| **CLI** (`titanium` / `twp`) | Standalone reverse / edge proxy for any stack | [Download](/download) (all OS); optional Homebrew on macOS | +| **Inspector** | Desktop man-in-the-middle (MITM) debugger — decrypt and inspect HTTPS traffic | [Download](/download) (Windows / macOS / Linux) | +| **Plus** | Control plane, ops, observability, dashboard, gRPC-JSON transcoding | After installing CLI: `titanium update --plus` | +| **Library** | Embed the same engine in a .NET app | NuGet (`Titanium.Web.Proxy`) | -## What PolyForm NC means +CLI and Plus target reverse-proxy / edge workloads (routing, load balancing, health, discovery) on Windows, Linux, and macOS. Inspector is the MITM debugging product. The Library is for embedding the same engine in a .NET process. -Plus and Inspector are **noncommercial** under PolyForm Noncommercial 1.0.0: personal, research, education, government, and charity use are allowed; **commercial use is not** without a separate agreement. They are not a paid SKU in the open repository. +## License -## Website & docs +The Library and CLI are [MIT](https://github.com/justcoding121/titanium-web-proxy/blob/develop/LICENSE). Inspector and Plus are [PolyForm Noncommercial 1.0.0](https://github.com/justcoding121/titanium-web-proxy/blob/develop/licenses/PolyForm-Noncommercial-1.0.0.txt). + +Plus and Inspector may be used for personal, research, education, government, and charity purposes. Commercial use of those two products needs a separate agreement. They are not a paid SKU in the open repository. Content under `/website` is MIT (see [`website/LICENSE`](https://github.com/justcoding121/titanium-web-proxy/blob/develop/website/LICENSE)). That does not relicense Plus or Inspector source. diff --git a/website/docs/getting-started.md b/website/docs/getting-started.md index 971c73127..9ae4e7195 100644 --- a/website/docs/getting-started.md +++ b/website/docs/getting-started.md @@ -1,82 +1,75 @@ # Getting started -Titanium Web Proxy is a lightweight, high-performance HTTP(S) proxy for general use: reverse / edge, MITM debugging, and ops. The CLI, Plus, and Inspector run on **Windows, Linux, and macOS**. A **.NET library** is available when you want to embed the engine in an app. +Titanium Web Proxy helps you do three things: + +1. **Inspect** HTTP/HTTPS traffic in a desktop app (Inspector) +2. **Run a reverse proxy** in front of your apps (CLI) +3. **Embed** the same engine in a .NET application (Library) + +Optional **Plus** adds a dashboard and ops features on top of the CLI. Everything runs on **Windows, Linux, and macOS**. ## Choose a path -| Goal | Start here | -|------|------------| -| Run a reverse / edge proxy from YAML | [Download CLI](/download) → [CLI](/docs/cli) → [Configuration](/docs/configuration) | -| Desktop traffic debugging | [Download Inspector](/download) → [Inspector](/docs/inspector) | -| Ops / control plane | [Plus](/docs/plus) (`titanium update --plus`; use `--channel beta` for prereleases) | -| Embed in a .NET app | [Library](/docs/library) + NuGet (`--prerelease` for beta) | +| I want to… | Start here | +|------------|------------| +| Debug browser or app traffic | [Download Inspector](/download) → [Inspector guide](/docs/inspector) | +| Put a proxy in front of my site or API | [Download CLI](/download) → create `twp.yaml` below → [CLI](/docs/cli) | +| Add dashboard / metrics / auth helpers | [Plus](/docs/plus) after installing the CLI | +| Use Titanium inside a .NET app | [Library](/docs/library) + [NuGet](https://www.nuget.org/packages/Titanium.Web.Proxy) | -## CLI reverse in 30 seconds +## Reverse proxy in a minute (CLI) -1. [Download](/download) the CLI zip for your OS and extract it. -2. Create `twp.yaml`: +1. [Download](/download) the CLI for your OS and extract it (or Homebrew on macOS — see [Install](/docs/install)). +2. Create `twp.yaml` that forwards to your backend (example: local port 8080): ```yaml -schemaVersion: "7.0" +schemaVersion: "7.1" listeners: - host: "127.0.0.1" port: 8000 + # false = do not decrypt HTTPS (plain reverse proxy) decryptSsl: false forwardHost: "127.0.0.1" forwardPort: 8080 ``` -3. Run: +3. Validate and run: ```shell titanium test -c twp.yaml titanium run -c twp.yaml ``` -## Library in 30 seconds (.NET) +`titanium run` stays in the foreground (Ctrl+C to stop). To start at boot: `titanium service install -c twp.yaml` — see [CLI — service](/docs/cli#service). More YAML: [Configuration](/docs/configuration). + +## Inspect traffic (Inspector) + +1. [Download](/download) and install Inspector. +2. Launch it — capturing and system proxy are on by default on the local machine. +3. Turn on **Decrypt HTTPS** when you need to see inside HTTPS (installs a local root certificate — only on machines you control). + +Details: [Inspector](/docs/inspector). + +## Embed in .NET (Library) ```shell dotnet add package Titanium.Web.Proxy -# beta / prerelease: +# Newer than stable: dotnet add package Titanium.Web.Proxy --prerelease ``` -```csharp -using System.Net; -using Microsoft.Extensions.Logging; -using Titanium.Web.Proxy; -using Titanium.Web.Proxy.EventArguments; -using Titanium.Web.Proxy.Models; - -using var proxyServer = new ProxyServer(); -proxyServer.Logging.MinimumLevel = LogLevel.Information; -proxyServer.BeforeRequest += OnRequest; - -var endPoint = new ExplicitProxyEndPoint(IPAddress.Loopback, 8000, decryptSsl: true); -proxyServer.AddEndPoint(endPoint); -proxyServer.CertificateManager.EnsureRootCertificate( - userTrustRootCertificate: true, - machineTrustRootCertificate: false); -proxyServer.Start(); - -Task OnRequest(object sender, SessionEventArgs e) -{ - proxyServer.Logger.LogInformation("{Url}", e.HttpClient.Request.Url); - return Task.CompletedTask; -} -``` - -Point your client at `127.0.0.1:8000`. Only trust a generated root CA on a machine you control. +Minimal sample and trust notes: [Library](/docs/library). Full API: [ProxyServer](/api/Titanium.Web.Proxy.ProxyServer.html){target="_blank" rel="noreferrer"}. -## Platforms +## Platforms at a glance -- **CLI, Plus, Core:** Windows, Linux, and macOS (self-contained CLI zips; no .NET SDK required to *run* the CLI). -- **Inspector:** Windows-first (MSI / portable zip). -- **Library:** .NET 10 (NuGet). +| Product | Platforms | +|---------|-----------| +| CLI / Plus | Windows, Linux, macOS (self-contained — no .NET SDK to *run*) | +| Inspector | Windows, macOS, Linux | +| Library | .NET 10 (NuGet) | ## Next - [Install](/docs/install) -- [Configuration (`twp.yaml`)](/docs/configuration) - [Editions & licenses](/docs/editions) -- [API reference](/api/Titanium.Web.Proxy.ProxyServer.html){target="_blank" rel="noreferrer"} +- [Performance](/docs/performance) diff --git a/website/docs/grpc-json-transcoding.md b/website/docs/grpc-json-transcoding.md new file mode 100644 index 000000000..773d4c234 --- /dev/null +++ b/website/docs/grpc-json-transcoding.md @@ -0,0 +1,118 @@ +# gRPC-JSON transcoding + +Call a gRPC service with ordinary HTTP and JSON — Plus maps REST/JSON to gRPC (and back) using `google.api.http` annotations in a compiled protobuf FileDescriptorSet. + +**Next:** enable Plus → point at a descriptor set → hit the annotated HTTP paths with `curl` or any HTTP client. + +## Requirements + +- Plus enabled (`plus.enabled: true`) with Plus installed +- A FileDescriptorSet (`.pb`) built with imports +- Fully-qualified service names listed in config +- HTTP/2 to the gRPC origin (enable HTTP/2 on the listener / server / cluster path) + +When disabled, Core does not call into the transcoder (null check only — no body buffering). + +Enabling the feature forces the HTTP session interception path for that process (same class of cost as route transforms or WAF). + +## Annotate services + +```protobuf +syntax = "proto3"; +package helloworld; +import "google/api/annotations.proto"; + +service Greeter { + rpc SayHello (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + get: "/v1/greeter/{name}" + }; + } + rpc CreateHello (HelloRequest) returns (HelloReply) { + option (google.api.http) = { + post: "/v1/greeter" + body: "*" + }; + } +} + +message HelloRequest { string name = 1; } +message HelloReply { string message = 1; } +``` + +## Build a descriptor set + +```shell +protoc -I${GOOGLEAPIS} -I. \ + --include_imports --include_source_info \ + --descriptor_set_out=api_descriptor.pb \ + your_service.proto +``` + +Include `google/api/annotations.proto` (and its imports) on the include path. + +## CLI config + +```yaml +plus: + enabled: true + controlPlane: + host: "127.0.0.1" + port: 9080 + sharedSecret: "" + options: + grpc.transcode.enabled: "true" + grpc.transcode.descriptorSet: "./protos/api_descriptor.pb" + grpc.transcode.services: "helloworld.Greeter" + grpc.transcode.convertGrpcStatus: "true" + grpc.transcode.ignoreUnknownQueryParameters: "true" + grpc.transcode.preserveProtoFieldNames: "false" + grpc.transcode.alwaysPrintPrimitiveFields: "false" + grpc.transcode.compression: "gzip" + +listeners: + - name: main + port: 8080 + enableHttp2: true + +routes: + - match: { pathPrefix: "/" } + cluster: greeter + +clusters: + - name: greeter + destinations: + - address: "https://127.0.0.1:50051" +``` + +Startup fails if the feature is enabled but the descriptor file is missing or `services` is empty. + +## Behavior + +| Client | Upstream | +|--------|----------| +| REST verb + path from `google.api.http` | `POST /package.Service/Method` | +| `application/json` (or empty GET body) | `application/grpc` length-prefixed protobuf | +| HTTP status from `grpc-status` | Trailers `grpc-status` / `grpc-message` | + +Unmatched REST requests pass through unchanged. Against a gRPC-only origin that usually fails — keep routes scoped to mapped prefixes. + +Supports **unary** and **multi-frame** responses (server streaming frames become a JSON array). Optional **gzip** compression via `grpc.transcode.compression` (`true` or `gzip`). + +### Example + +```shell +curl -s http://127.0.0.1:8080/v1/greeter/world +# {"message":"Hello world"} +``` + +## Inspector + +Transcoded sessions set `IsTranscoded` and support search `is:transcoded`. The inspect pane shows client REST/JSON faces and upstream gRPC path / framed payloads. + +## See also + +- [Plus](/docs/plus) +- [Configuration](/docs/configuration) +- [Inspector](/docs/inspector) +- [Performance](/docs/performance) diff --git a/website/docs/http3.md b/website/docs/http3.md index 9aa6c5a4e..7819cea81 100644 --- a/website/docs/http3.md +++ b/website/docs/http3.md @@ -1,20 +1,29 @@ # HTTP/3 (QUIC) -HTTP/3 is an **opt-in experimental** feature on `System.Net.Quic` / MsQuic. +HTTP/3 is an **opt-in experimental** feature. CLI and Inspector release zips already ship the Quic natives for common platforms — pick the zip that matches your OS, then enable it in config. -```csharp -#pragma warning disable TWP001 -proxyServer.EnableHttp3 = true; -#pragma warning restore TWP001 +**Next:** download the matching zip → set `enableHttp3: true` on a listener → `titanium http3-deps status` if Quic is unsupported. + +## Enable on the CLI + +```yaml +listeners: + - host: "0.0.0.0" + port: 443 + decryptSsl: true + enableHttp3: true ``` -## Prerequisites +```bash +titanium http3-deps status +titanium http3-deps install # only if status shows unsupported +``` -### CLI / Inspector (bundled) +## Pick the right download -Self-contained GitHub Release zips **ship MsQuic natives** for common RIDs. Pick the zip that matches your OS / libc / arch: +Self-contained GitHub Release zips **ship MsQuic natives** for common platforms: -| Environment | RID zip | +| Environment | Zip | | --- | --- | | Windows 11 / Server 2022+ | `win-x64` (OS MsQuic; no extra DLL) | | Ubuntu/Debian/RHEL-like (glibc) x64 | `linux-x64` | @@ -24,38 +33,38 @@ Self-contained GitHub Release zips **ship MsQuic natives** for common RIDs. Pick | macOS Intel | `osx-x64` | | macOS Apple Silicon | `osx-arm64` | -RID zips ship **MsQuic + OpenSSL only** (MIT / Apache-2.0). They do **not** redistribute LGPL/GPL host libs (`libnuma`, `lttng-ust`). On empty/distroless images install those with `http3-deps` (or your package manager) so the loader can resolve them: +Those zips ship **MsQuic + OpenSSL only** (MIT / Apache-2.0). They do **not** redistribute LGPL/GPL host libs (`libnuma`, `lttng-ust`). On empty/distroless images install those with `http3-deps` (or your package manager) so the loader can resolve them: | Host | Host packages | | --- | --- | | Ubuntu/Debian | `libnuma1` (pulled with `libmsquic` via `http3-deps install`) | | Alpine/musl | `numactl`, `lttng-ust` | -Check at runtime: - -```bash -titanium http3-deps status -``` - Edge OS / old glibc / missing host deps: `titanium http3-deps install` (apt / dnf / apk / brew; needs network + sudo). ### Alpine / Kubernetes Use the **`linux-musl-*`** zip inside Alpine images. A `linux-x64` (glibc) zip will not load MsQuic on musl. Also install `numactl` + `lttng-ust` (or run `titanium http3-deps install`). -### NuGet library hosts +## Library (for .NET library users) -Embedding `Titanium.Web.Proxy` in your own app does **not** bundle MsQuic: +Embedding the Library in your own app does **not** bundle MsQuic: - **Windows**: Windows 11 / Server 2022+ - **Linux**: install `libmsquic` (e.g. packages.microsoft.com / `apk add libmsquic`) - **macOS**: bundle `libmsquic` + OpenSSL with `@loader_path`, or `brew install libmsquic` -Confirm `System.Net.Quic.QuicListener.IsSupported == true` before enabling. +Confirm `System.Net.Quic.QuicListener.IsSupported == true` before enabling. HTTP/3 uses the experimental `TWP001` surface: + +```csharp +#pragma warning disable TWP001 +proxyServer.EnableHttp3 = true; +#pragma warning restore TWP001 +``` Inbound endpoint: `TransparentQuicProxyEndPoint` or `TransparentProxyEndPoint` with `EnableHttp3 = true`. -## Dual-listen reverse (TCP + UDP) +### Dual-listen reverse (TCP + UDP) Same IP:port speaks TLS H1/H2 over TCP and H3 over UDP, and can inject `Alt-Svc`: @@ -74,21 +83,6 @@ if (QuicListener.IsSupported) #pragma warning restore TWP001 ``` -## CLI - -```yaml -listeners: - - host: "0.0.0.0" - port: 443 - decryptSsl: true - enableHttp3: true -``` - -```bash -titanium http3-deps status -titanium http3-deps install # only if status shows unsupported -``` - ## Full guide Packaging, Alt-Svc, bridges, and gap list: [HTTP/3 wiki](https://github.com/justcoding121/titanium-web-proxy/wiki/HTTP-3). diff --git a/website/docs/inspector.md b/website/docs/inspector.md index d2f1af55d..091f3434e 100644 --- a/website/docs/inspector.md +++ b/website/docs/inspector.md @@ -1,73 +1,52 @@ # Inspector -Desktop MITM debugger (Avalonia). Licensed under [PolyForm Noncommercial](https://github.com/justcoding121/titanium-web-proxy/blob/develop/licenses/PolyForm-Noncommercial-1.0.0.txt). +Desktop debugger for HTTP and HTTPS traffic. Decrypt HTTPS (man-in-the-middle / MITM) only on machines you control. ![Titanium Inspector screenshot](../../wiki/images/inspector-screenshot.jpg) +## Quick use + +1. [Download](/download) and install Inspector for your OS. +2. Launch it — by default it starts listening and turns on system proxy (**Capturing** on). Default bind is usually `127.0.0.1:8866`. +3. Turn on **Decrypt HTTPS** when you need to see inside HTTPS (installs a local root certificate if needed; you may get an OS trust prompt). +4. Use the toolbar **System proxy** / **Capturing** checkboxes to pause either without quitting. + +HTTPS stays encrypted (opaque tunnels) until **Decrypt HTTPS** is on. + +Capture menu options (**Capturing**, **Decrypt HTTPS**, **System proxy**, auto-start prefs) show a check when on. Preferences such as **Session retention…**, **Excluded hosts…**, **Ignore insecure server certificates**, and **Logging…** live under **Options**. + ## Install -Prefer the [Download](/download) page (resolves the newest release that has Inspector assets, including prereleases). +Prefer the [Download](/download) page. ### Windows -- **MSI** — guided wizard (license, install folder, progress, Finished with optional Launch). Uninstall from **Settings → Apps** (or Programs and Features); the entry uses the Inspector icon. +- **MSI** — installer wizard; uninstall from **Settings → Apps**. - **Portable zip** — extract and run `TitaniumInspector.exe`. -```shell -# Stable community package only — not the 7.0 beta -winget install justcoding121.TitaniumInspector -``` - -Windows examples for the **7.0 beta** product tag: - -- [MSI](https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.4-beta/TitaniumInspector-win-x64.msi) -- [Portable zip](https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.4-beta/TitaniumInspector-win-x64.zip) +Stable links (`v7.0.5`): [MSI](https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.5/TitaniumInspector-win-x64.msi) · [zip](https://github.com/justcoding121/titanium-web-proxy/releases/download/v7.0.5/TitaniumInspector-win-x64.zip). Beta: the Download beta section. ### Linux -Extract the RID zip, then either run `./TitaniumInspector` (portable) or: +Extract the zip, then run `./TitaniumInspector`, or: ```shell chmod +x install.sh uninstall.sh TitaniumInspector ./install.sh # ~/.local/share/TitaniumInspector + desktop entry -# later: -./uninstall.sh ``` ### macOS -Extract the RID zip, then either run `./TitaniumInspector` (portable) or: +Extract the zip, then run `./TitaniumInspector`, or: ```shell chmod +x install-app.sh uninstall-app.sh TitaniumInspector ./install-app.sh # ~/Applications/Titanium Inspector.app -# later: -./uninstall-app.sh ``` ## Updates -**Options → Update channel** — Stable (default) or Beta. **Help → Check for updates…** checks only that channel and labels it in the dialog (e.g. *Update 7.0.4 (Stable)*). Choosing **Install and restart** downloads the package (MSI for a Program Files install, otherwise the RID zip), closes Inspector, applies the update, and relaunches. - -**Options → Check for updates on startup** uses the same channel and confirm dialog (never silent-install). - -## Quick use - -1. Launch Inspector — by default it starts listening and enables system proxy (Capturing on). -2. Check **Decrypt HTTPS** when you want MITM (installs the root CA if needed; may prompt for admin). -3. Use the toolbar **System proxy** / **Capturing** checkboxes to pause either without quitting. - -Default bind is typically `127.0.0.1:8866`. Bind address/port are **start-time** settings on the toolbar: editable when the proxy is stopped; disabled while running. Use **Start proxy** / **Stop proxy** (toolbar button or Capture menu) to switch. After Stop → Start, system proxy is turned back on if it was on before Stop, or if **Auto system proxy on start** is checked. - -HTTPS stays encrypted (opaque tunnels) until **Decrypt HTTPS** is enabled. - -Capture menu latching options (**Capturing**, **Decrypt HTTPS**, **System proxy**, auto-start prefs) show a check when on. Preferences such as **Session retention…**, **HTTPS sites to decrypt…**, **Ignore insecure server certificates** (off by default), and **Logging…** live under **Options**. **Reset Inspector settings…** restores preferences to factory defaults; it does not remove the root CA or clear sessions. - -The status strip keeps command feedback on the left and a live **Sessions: N** count on the right, so capture traffic does not wipe tips or export paths. - -**Install root CA (current user)** trusts the MITM CA on this PC. On Windows, the OS may show a Trusted Root **Yes/No** security dialog the first time that certificate is added (this is not UAC). Re-installing when the CA is already trusted does not prompt again; orphan same-name roots are cleaned up only when a new thumbprint is installed, or via **Remove** / **Clear and reinstall**. **Remove root CA** clears every same-name Titanium root in the current-user Trusted Root store (including orphans from earlier installs). **Clear and reinstall root CA…** mints a new private key, clears this install’s leaf certificate cache (next to `%AppData%\TitaniumInspector\rootCert.pfx`), removes same-name trusted roots, and prompts to reinstall trust. **Device CA setup…** opens a dialog with steps for phones/other devices and can **Export CA** from there (or use **Export root CA…** on the Capture menu). - -Leaf certificates for Inspector are stored under `%AppData%\TitaniumInspector\crts\` (beside the root PFX), not under the shared `%LocalAppData%\Titanium.Web.Proxy\crts` folder used by the library default. On first start after upgrade (and on every clear/reinstall), Inspector best-effort deletes that legacy shared `crts` folder; it never deletes a shared `rootCert.pfx`. +**Help → Update channel** — Stable (default) or Beta. **Help → Check for updates…** offers install only when there is a real change (newer build or channel switch). Accept downloads the package, closes Inspector, replaces the install, and relaunches. ## Right pane: Inspect vs Tools @@ -85,15 +64,19 @@ Use **Tools → Composer / Breakpoints / AutoResponder / Scripts…** to open th - **Headers** — request/response headers, cookies, query (labeled sections) - **Body** — request and response bodies as `=== Request ===` / `=== Response ===` (decoded / JSON when possible; `(empty)` if missing) - **Hex** — same labeled sections for raw bytes -- **WS Frames** — shown **only for WebSocket** sessions; best-effort text preview of messages (not a full opcode stream) +- **WS Frames** — shown for WebSocket sessions; live frames when available (direction, opcode, payload preview) +- **SSE** — shown for `text/event-stream` (or `Accept: text/event-stream`) responses; parses `event` / `id` / `data` blocks into a readable event list +- **Protobuf** — wire-format field dump for gRPC and gRPC-JSON-transcoded upstream frames (field number, wire type, value). MVP does **not** require a `.protoset` / descriptor set; the optional settings field `ProtobufDescriptorSetPath` is stored for a future typed decode. Until then, the Protobuf tab always shows the JSON wire dump. + +Search for WebSocket traffic with `is:ws`. Search for gRPC with `is:grpc`, and for gRPC-JSON transcoded sessions with `is:transcoded` (client REST/JSON vs upstream gRPC faces appear in the Headers/Body inspect panes). Quick filters on the toolbar toggle `hide:tunnel`, `hide:image`, and `is:error` into the same search box. Status classes (`status:2xx` … `status:5xx`), `process:`, and `content-type:` are also supported. The status strip shows **Sessions: N** with no filter, and **visible / total** when a search or quick filter is active. -Search for WebSocket traffic with `is:ws`. Quick filters on the toolbar toggle `hide:tunnel`, `hide:image`, and `is:error` into the same search box. Status classes (`status:2xx` … `status:5xx`), `process:`, and `content-type:` are also supported. The status strip shows **Sessions: N** with no filter, and **visible / total** when a search or quick filter is active. +**Network throttle:** use the toolbar **Throttle** combo (`None`, `Slow 3G`, `Fast 3G`, `LTE`) to add latency and bandwidth shaping on body writes / WebSocket frames during capture. Off by default (`None`); the hot path skips delay work when no profile is enabled. ### Tools (all traffic) Pipeline order on each request: -**Scripts → AutoResponder → Breakpoints → origin** +**Scripts → AutoResponder → Map Remote → Breakpoints → origin** #### Composer @@ -107,6 +90,14 @@ Pause matching requests (URL glob; `*` = all) so you can edit the body, **Contin If **Enabled**, the first matching rule returns a fake status/body **before** the real server (and before breakpoints). Match URLs with `*` wildcards. +**Map Local:** set an optional file path on the rule (or use **Browse…**). When the path is set, the response body is read from that file instead of the inline body field. Inline body is used when Map Local is empty. Missing files cause the rule to be skipped (request continues to breakpoints/origin). + +Optional **GraphQL operationName** on AutoResponder, Map Remote, and Breakpoints: when set, the rule only matches requests whose JSON body has that `operationName` (or a matching named operation in the `query` string). Same URL, different operations can take different rules. + +#### Map Remote + +If **Enabled**, the first matching rule rewrites the request URL to another absolute origin **before** breakpoints and the real server. Match with `*` wildcards. A single `*` in both match and target preserves the captured path/query suffix (for example match `https://prod.example/*` → target `http://127.0.0.1:5000/*`). Map Remote does not run when AutoResponder / Map Local already answered the request. + #### Scripts **Not JavaScript or C#.** One directive per line (comments with `#` or `//`): @@ -119,25 +110,52 @@ abort Applies to every captured request/response. On request, `abort` or `set-status` short-circuits AutoResponder, breakpoints, and the origin. +## Advanced -## Platform matrix (system proxy and root CA) +### Excluded hosts + +**Options → Excluded hosts…** edits two lists: + +| Layer | Effect | +|-------|--------| +| **OS bypass** | Traffic never reaches Inspector (when **System proxy** is on) | +| **Tunnel only** | Session stays visible but HTTPS stays opaque | + +Factory seeds keep common identity / SSO hosts on OS bypass so sign-in keeps working. Right-click a session → **Exclude host…**. Search: `is:opaque`. **Chrome QUIC** may bypass the proxy entirely — not fixable via host lists. + +### Root certificate (Decrypt HTTPS) + +**Install root CA (current user)** trusts the decrypt certificate on this PC (OS may show a Yes/No trust dialog). Use **Export root CA…** / **Device CA setup…** for phones or other devices. **Remove root CA** / **Clear and reinstall…** / **Trust CA in Firefox…** are on the Capture menu when you need cleanup or Firefox-specific trust. Prefer those menu actions over editing certificate stores by hand. + +### Platform matrix (system proxy and root CA) | Feature | Windows | macOS | Linux | |---------|---------|-------|-------| -| System proxy | WinINET (automatic) | `networksetup` (admin prompt if required) | GNOME `gsettings` + KDE + process `http(s)_proxy` | -| Root CA user trust | Current-user Root store | Login keychain (`security`) + .NET store | .NET store + user NSS (`certutil`, Chromium) | +| System proxy | WinINET (automatic) | `networksetup` (disables PAC/WPAD/SOCKS so CFNetwork/Firefox see HTTP(S); admin prompt if required) | GNOME `gsettings` + KDE + process `http(s)_proxy` + Chromium/Edge launch hooks + Firefox profile prefs | +| Instant browser switch | OS settings (live for most apps) | OS settings (live for most apps; Firefox may need restart) | Chromium/Edge quit+relaunch with `--proxy-server`; Firefox prefs + quit/relaunch | +| Root CA user trust | Current-user Root store | Login keychain (`security`) + .NET store | .NET store + user NSS (`certutil`, Chrome/Edge/Chromium incl. Snap/Flatpak DBs) | | Root CA machine / admin | UAC + `certutil` | System keychain (macOS auth dialog) | `pkexec` + `update-ca-certificates` | -| Cancel elevation | Leaves settings unchanged | Leaves settings unchanged | Leaves settings unchanged | +| Missing `certutil` | N/A for OS trust | **Trust CA in Firefox…** can run `brew install nss` when Homebrew is present | Recovery dialog can install `libnss3-tools` / `nss-tools` / `mozilla-nss-tools` via `pkexec` | +| Firefox | **Trust CA in Firefox…** sets `ImportEnterpriseRoots` (restart Firefox) | Install root CA writes profile `user.js` (`security.enterprise_roots.enabled`); NSS `certutil` is the fallback. Never modifies `Firefox.app`. | Profile `user.js` OS-root trust first; NSS import fallback; system proxy also writes `network.proxy.*` in the default profile | +| Cancel elevation / recovery | Leaves settings unchanged | Leaves settings unchanged | Leaves settings unchanged | Notes: - Headless Linux without polkit/GUI cannot show an admin dialog; use Export CA and install manually. -- Firefox may require trusting the CA in its own certificate store. +- **Trust CA in Firefox…** remains available for NSS profile import. **Install root CA** also best-effort writes `security.enterprise_roots.enabled` in the default profile `user.js` (macOS includes `FirefoxDeveloperEdition` and `Firefox Nightly` profile roots; Linux includes Snap and Flatpak). Inspector does **not** write into `Firefox.app`. If Firefox is running, Inspector can ask it to quit gracefully (with consent) before writing `cert9.db`; on macOS it falls back to SIGTERM if osascript is blocked. +- On Linux, **System proxy** also updates Chromium-family managed policy / Preferences / `.desktop` helpers and Firefox `prefs.js`, then relaunches already-open Chrome, Edge, Chromium, Brave, and Firefox so traffic switches without a manual restart. On exit or proxy off, settings are restored and browsers are relaunched without the Inspector endpoint (with a short fail-open tunnel if Chromium still pointed at a dead port). +- On Windows, the first Current User Root install may show an OS Trusted Root **Yes/No** dialog (not UAC); choose **Yes**. Inspector cannot replace that dialog. +- macOS without Homebrew: OS-root trust via `user.js` does not need `certutil`. Export CA and import under Firefox → Authorities only if enterprise-roots is not enough. +- Enabling **System proxy** on macOS turns off PAC, WPAD, and SOCKS so Firefox (CFNetwork) sees the HTTP(S) proxy; previous PAC/SOCKS settings are restored when System proxy is turned off. Firefox that was already open may need a restart. - KDE proxy reload is best-effort; a session restart may be needed if apps do not pick up changes. -- If user-level CA install fails, Inspector offers an elevated retry (OS admin prompt). +- If user-level CA install fails, Inspector offers an adaptive recovery dialog (tools / Keychain / admin). +- **Contributors:** unit/integration tests never open OS cert UI (`TITANIUM_SKIP_ROOT_STORE_UI=1`). For live System proxy / Install CA / browsers / Store apps UX, run [`tools/InspectorDesktopProbe`](../../tools/InspectorDesktopProbe/README.md) (`dotnet run --project tools/InspectorDesktopProbe -- all`). Results: `tools/InspectorDesktopProbe/results/last-run.json`. + ## Other features -- Session grid: method, status, host, URL, Protocol, duration, Wait (TTFB), size, process. Right-click menu: Replay, Load into Composer, Export selected HAR/archive, Copy URL. +- Session grid: method, status, host, URL, Protocol, duration, Wait (TTFB), size, process. Right-click menu: Replay, Load into Composer, Export selected HAR/archive, Copy URL, Copy as curl, Copy as fetch, Diff selected (exactly two sessions). +- **Copy as curl / fetch:** with one session selected, generate a shell `curl` command or a JavaScript `fetch(...)` call from the request URL, method, headers, and body (CONNECT tunnels are skipped). The snippet is copied to the clipboard. +- **Session Diff:** with exactly two sessions selected, compare method/URL/status/headers/bodies offline. The result opens on the Inspect **Diff** tab and is copied to the clipboard. - HAR / archive: Export all writes every captured session; Export selected writes the grid multi-selection. Import appends sessions from the file. Replay selected session. - System proxy and root CA install / untrust / export; Device CA setup dialog for external devices; **Allow Store apps…** on Windows - Search (`method:GET status:2xx host:example process:chrome is:ws hide:tunnel`); quick filters: Hide CONNECT, Hide images, Errors only diff --git a/website/docs/install.md b/website/docs/install.md index 50fe59440..ee9065a50 100644 --- a/website/docs/install.md +++ b/website/docs/install.md @@ -1,54 +1,81 @@ # Install -Full download buttons live on the [Download](/download) page. This page is the short install guide. +Prefer the [Download](/download) page for buttons. This page is a short install guide. -## Library +## Inspector (desktop debugger) -```shell -dotnet add package Titanium.Web.Proxy -dotnet add package Titanium.Web.Proxy --prerelease -``` +1. Open [Download](/download) and pick **Inspector** for your OS (Windows MSI, macOS DMG, Linux AppImage / deb / rpm, or a portable zip). +2. Install or extract, then launch **Titanium Inspector**. +3. Follow [Inspector](/docs/inspector) to capture and decrypt traffic. + +## CLI (reverse proxy) + +1. [Download](/download) the CLI for your OS. +2. Extract if needed so `titanium` (and `twp`) are on your PATH or in the current folder. +3. Create a config and run — see [Getting started](/docs/getting-started). + +**macOS optional:** if you use Homebrew, `brew tap justcoding121/titanium && brew install titanium`. + +Stable and beta builds are on [Download](/download) / [GitHub Releases](https://github.com/justcoding121/titanium-web-proxy/releases). -## CLI +**Start at boot:** `titanium service install -c ` (Administrator / sudo). Details: [CLI — service](/docs/cli#service). -On Windows, **winget is stable-only**: +**Updates:** ```shell -winget install justcoding121.TitaniumCli +titanium update +titanium version --check ``` -For **beta** (or any OS), take a self-contained zip from the [Download](/download) page or [GitHub Releases](https://github.com/justcoding121/titanium-web-proxy/releases) when `Titanium.Cli-*.zip` assets are published (e.g. `v7.0.4-beta`). +Use `--channel beta` when you intentionally follow beta. -Pick the **matching RID** (e.g. Alpine/K8s → `linux-musl-x64` or `linux-musl-arm64`, not `linux-x64`). HTTP/3 natives ship inside those zips — see [HTTP/3](/docs/http3). +## Plus (optional ops add-on) + +After the CLI is installed: ```shell -titanium update --channel beta -titanium version --check --channel beta -titanium http3-deps status +titanium update --plus +titanium version --check --plus ``` -`titanium update` checks the selected channel, downloads the RID zip, verifies SHA256, replaces the install directory after the process exits, and prints the new version. Use `--channel stable` (default) or `--channel beta` — it does not pick “whichever is newer” across channels. +There is no separate Plus download. Enable in config (`plus.enabled: true`). See [Plus](/docs/plus). -## Plus +## Library (.NET) ```shell -titanium update --plus --channel beta +dotnet add package Titanium.Web.Proxy +# Newer than stable: +dotnet add package Titanium.Web.Proxy --prerelease ``` -See [Plus](/docs/plus). There is no separate Plus download link. +## Advanced + +### Which Linux package? -## Inspector +- Most Linux desktops and servers: **AppImage**, **`.deb`**, **`.rpm`**, or the regular `linux-x64` / `linux-arm64` zip. +- **Alpine** or other **musl** containers: use the **Alpine / musl** zip (`linux-musl-x64` or `linux-musl-arm64`), not the regular Linux zip. +- HTTP/3 support is bundled in the platform packages — see [HTTP/3](/docs/http3). -Prefer [Download](/download). **winget is stable-only**: +### Trust / publisher + +- **Windows:** Signed releases show Authenticode publisher **Jehonathan Thomas**. SmartScreen reputation builds over time. +- **macOS:** Prefer a notarized **DMG** for Inspector when published. +- **Linux:** Prefer AppImage / `.deb` / `.rpm`. Verify releases with `SHA256SUMS` and `SHA256SUMS.asc`: ```shell -winget install justcoding121.TitaniumInspector +curl -fsSL https://titaniumproxy.com/titanium-releases.asc | gpg --import +# Fingerprint: A824 E886 0DAB 01FA DA94 4E2D 8E5A B43F 41C8 DE8F +sha256sum -c SHA256SUMS +gpg --verify SHA256SUMS.asc SHA256SUMS ``` -Or MSI / portable zip from [Download](/download) / [GitHub Releases](https://github.com/justcoding121/titanium-web-proxy/releases) when those assets are published (beta example: `v7.0.4-beta`). Linux and macOS Inspector builds are zip-only; Windows also ships an MSI. HTTP/3 natives are bundled the same way as the CLI ([HTTP/3](/docs/http3)). +### Update channels + +`titanium update` uses **stable** by default or **beta** when you pass `--channel beta`. It does not pick “whichever is newer” across channels. Same-version checks treat assembly `7.0.5.0` and feed tag `7.0.5` as equal. ## See also - [Download](/download) -- [Releases](/releases) - [Getting started](/docs/getting-started) +- [Releases](/releases) +- [Packaging notes (for contributors / packagers)](https://github.com/justcoding121/titanium-web-proxy/blob/develop/tools/packaging/PACKAGING.md) diff --git a/website/docs/library.md b/website/docs/library.md index d8d9c40a4..3cc368a26 100644 --- a/website/docs/library.md +++ b/website/docs/library.md @@ -1,14 +1,18 @@ # Library (embed) +> **For .NET library users** — operators who want a reverse proxy or desktop debugger should start with the [CLI](/docs/cli) or [Inspector](/docs/inspector) instead. + NuGet package **Titanium.Web.Proxy** (MIT). Target framework: **.NET 10**. ```shell dotnet add package Titanium.Web.Proxy -# Latest prerelease (e.g. 7.0.4-beta): +# Prerelease when newer than the stable NuGet feed: dotnet add package Titanium.Web.Proxy --prerelease ``` -## Explicit MITM proxy +Related MIT packages (also on NuGet): **Titanium.Web.Proxy.Abstractions** (shared contracts) and **Titanium.Web.Proxy.Configuration** (`twp.yaml` / dialect loaders — optional for library users). See [migration 6→7](https://github.com/justcoding121/titanium-web-proxy/blob/develop/docs/migration-6-to-7.md). + +## Explicit man-in-the-middle (MITM) proxy ```csharp using System.Net; @@ -59,4 +63,34 @@ Use `ForwardHost` on a transparent endpoint for a zero-cost terminate-lite path, `ProxyServer.SetAsSystemProxy` / `RestoreOriginalProxySettings` work on Windows (WinINET), macOS (`networksetup`), and Linux (GNOME + KDE + process environment). Unsupported platforms throw `NotSupportedException`. -`CertificateManager.TrustRootCertificate` installs into the current-user store on all platforms and additionally trusts for SSL on macOS (login keychain) and Linux (user NSS db). `TrustRootCertificateAsAdmin` shows an OS admin prompt (UAC / macOS authentication / polkit) for machine-wide trust. +`CertificateManager.TrustRootCertificate` installs into the current-user store on all platforms and additionally trusts for SSL on macOS (login keychain) and Linux (user NSS db). Check `LastOsTrustResult` for structured outcomes (e.g. missing `certutil`, Keychain Always Trust needed). `InstallNssCertutilAndRetryUserTrust` installs NSS tools via package manager / Homebrew after user consent. `FirefoxCertificateTrust` enables Windows `ImportEnterpriseRoots` or profile `user.js` (`security.enterprise_roots.enabled`) so Firefox trusts OS roots, and can import into the default Firefox NSS profile. `TrustRootCertificateAsAdmin` shows an OS admin prompt (UAC / macOS authentication / polkit) for machine-wide trust. + +## MITM hostname exclusions + +Two layers (both work with system proxy on **Windows**, **macOS**, and **Linux**): + +1. **OS bypass** — `SystemProxySettings.BypassRules` + `SetAsSystemProxy(..., settings)`. Traffic never hits the proxy when System proxy is on. Factory seed: `MitmExclusionDefaults.SystemProxyBypassRules` (Microsoft identity / SSO / RDP). +2. **Tunnel only** — `BeforeTunnelConnectRequest` → `e.DecryptSsl = false`, or `MitmExclusionDefaults.ApplyDecryptExclusions(...)`. CONNECT stays visible; no decrypt. Factory seed: `MitmExclusionDefaults.TunnelOnlyPinningDomains` (`dropbox.com`, `webex.com`). + +Use **`MitmExclusionMode.Merge`** (default, back-compat) to always re-inject factory hosts, or **`Replace`** so the caller lists are authoritative (you can remove identity hosts — risk breaking SSO while System proxy is on). + +```csharp +// Merge: factory identity hosts + extras +var settings = MitmExclusionDefaults.CreateSystemProxySettings( + proxyLoopback: true, + additionalBypassRules: ["*.corp.example.com"]); +proxyServer.SetAsSystemProxy(endPoint, ProxyProtocolType.AllHttp, settings); + +// Replace: full list you provide (factory not re-added) +var custom = MitmExclusionDefaults.CreateSystemProxySettings( + proxyLoopback: true, + MitmExclusionDefaults.SystemProxyBypassRules.Append("*.corp.example.com"), + MitmExclusionMode.Replace); + +MitmExclusionDefaults.ApplyDecryptExclusions(endPoint, () => true, + decryptSkipHosts: ["*.bank.example.com"], + decryptOnlyHosts: null, + MitmExclusionMode.Replace); +``` + +CLI: when `server.decryptSkipHosts` and/or `server.decryptOnlyHosts` are present in `twp.yaml`, exclusions apply with **Replace**. Omit both to leave Merge defaults. Optional `server.systemProxyBypassHosts` / `server.proxyLoopback` build OS-bypass settings the same way (present ⇒ Replace; omit ⇒ Merge). Removing identity hosts from OS bypass can break Microsoft SSO while System proxy is enabled. diff --git a/website/docs/performance.md b/website/docs/performance.md index 75da5ae22..527431d96 100644 --- a/website/docs/performance.md +++ b/website/docs/performance.md @@ -1,26 +1,45 @@ # Performance -Titanium targets low-overhead MITM and reverse proxying: connection pooling, HTTP/2 multiplexing, and buffer reuse. +Titanium targets low-overhead reverse proxying and HTTPS interception: connection pooling, HTTP/2 multiplexing, and buffer reuse. -## Summary (from publishable CI tables) +**RPS** is requests per second. gRPC bars are **RPC/s** (unary calls). Read **within** a chart (same OS), not Windows vs Linux as one number. Missing bars mean that peer cannot run that wire on that OS. -On matched **GitHub Actions 4 vCPU / 16 GiB** runners, Titanium is typically: +## Practical reverse RPS (tiny requests) -- **at or above YARP** for reverse-proxy workloads -- **ahead of nginx** on H2/H3→H1 reverse; **near parity** for the rest (nginx still edges tiny keep-alive H1) +Common reverse wires with **tiny keep-alive GET (~56 B)**, plus WebSocket and unary gRPC — one chart per OS. How to read medals and workload shape: [Performance wiki](https://github.com/justcoding121/titanium-web-proxy/wiki/Performance#why-this-comparison-is-fair). -MITM is Titanium-only among those peers (they cannot MITM). Absolute RPS varies by OS, TLS, and MsQuic packaging — compare **within a table**, not across Windows vs Linux. +### Windows -## Full measurements +![Practical reverse RPS on Windows (tiny requests)](../../wiki/images/rps-practical-windows.png) + +### Linux + +![Practical reverse RPS on Linux (tiny requests)](../../wiki/images/rps-practical-linux.png) + +### macOS + +![Practical reverse RPS on macOS (tiny requests)](../../wiki/images/rps-practical-macos.png) + +## Practical reverse RPS (64 KB) + +Typical reverse wires with **64 KB GET/POST** (plus 256 KB H1 terminate) — body work separate from the tiny-GET chart above. Windows and Linux below; macOS heavier bodies are not published yet. -Detailed tables, harness knobs, and methodology live in the project wiki: +### Windows + +![Practical reverse RPS on Windows (64 KB)](../../wiki/images/rps-practical-heavier-windows.png) + +### Linux + +![Practical reverse RPS on Linux (64 KB)](../../wiki/images/rps-practical-heavier-linux.png) + +## Heavier reverse workloads + +Larger bodies, POST, lossy links, TLS termination cost, and architecture-sensitive shapes (slow consumers, duplex, WebSocket). Full tables are on the [Performance wiki](https://github.com/justcoding121/titanium-web-proxy/wiki/Performance#heavier-reverse-workloads). + +## Full measurements -- [Performance wiki](https://github.com/justcoding121/titanium-web-proxy/wiki/Performance) -- [Performance profiling](https://github.com/justcoding121/titanium-web-proxy/wiki/Performance-Profiling) -- Local cool A/B lab (not publishable): [Performance Local Lab](https://github.com/justcoding121/titanium-web-proxy/wiki/Performance-Local-Lab) +Detailed tables and methodology: [Performance wiki](https://github.com/justcoding121/titanium-web-proxy/wiki/Performance) · [Performance profiling](https://github.com/justcoding121/titanium-web-proxy/wiki/Performance-Profiling) -Harness: [`tools/RpsLoadProbe`](https://github.com/justcoding121/titanium-web-proxy/tree/develop/tools/RpsLoadProbe) and [PERF-GATES.md](https://github.com/justcoding121/titanium-web-proxy/blob/develop/tools/RpsLoadProbe/PERF-GATES.md). +--- -```powershell -pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-saturation -``` +*How we measure:* matched GitHub Actions runners (~4 vCPU / 16 GiB) on Windows, Linux, and macOS; Titanium vs YARP, nginx, HAProxy, and Envoy; same client, origin, warmup, duration, and concurrency (sustain at 64). Absolute RPS varies slightly with runner noise. diff --git a/website/docs/plus.md b/website/docs/plus.md index 6b2c0fab5..06662047c 100644 --- a/website/docs/plus.md +++ b/website/docs/plus.md @@ -1,10 +1,29 @@ # Plus -Optional ops plugin for the CLI (and Inspector panels). Licensed under [PolyForm Noncommercial](https://github.com/justcoding121/titanium-web-proxy/blob/develop/licenses/PolyForm-Noncommercial-1.0.0.txt) — **not for commercial use** without a separate agreement. +Optional ops plugin for the CLI (and Inspector panels). For operators who want a dashboard, metrics, auth, and a thin web application firewall (WAF) without writing custom code. + +**Next:** install with `titanium update --plus`, enable in YAML, open the dashboard. + +## What you get + +| Outcome | Capability | +|---------|------------| +| Dashboard | HTML admin on an ephemeral port (or explicit `controlPlane.dashboardPort`) | +| Observability | Prometheus-style metrics for destination state / latency | +| Security | CIDR allow-list, JWT/OIDC (JWKS), API key / Basic auth | +| Web application firewall (WAF) | Thin deny-list (paths, methods, headers, body size) — not a full WAF suite | +| Control plane | Loopback HTTP API with shared-secret header; snapshot get/put; cache purge | +| Operations | Drain / healthy / maintenance destination states | +| Discovery | File watch, DNS poll; Consul / Kubernetes best-effort | +| State | Fixed-window per-IP rate limit (`state.mode=memory` or `state.redis`) | +| Resilience | Active HTTP/TCP health probes, circuit outlier ejection, bounded idempotent connection retries | +| CORS | Opt-in preflight + `Access-Control-*` response headers | +| Cache | In-memory HTTP response cache (`cache.enable`) | +| gRPC-JSON transcoding | REST/JSON ↔ gRPC (unary + multi-frame streaming; optional gzip) via FileDescriptorSet + `google.api.http` ([guide](/docs/grpc-json-transcoding)) | ## Install -Plus is distributed as a sidecar DLL next to the CLI. There is **no** public Plus download button on this site. +Plus is distributed as a sidecar next to the CLI. There is **no** public Plus download button on this site. ```shell titanium update --plus @@ -14,7 +33,11 @@ titanium update --plus --channel beta titanium version --check --plus --channel beta ``` -## Enable +`titanium version --check --plus` reports local → remote and exit code `2` when a newer Plus is available or Plus is missing. `titanium update --plus` installs or upgrades only when needed; if Plus is already current it prints that and skips the download. + +## Enable / disable + +Day-to-day control is config — keep Plus installed and toggle features: ```yaml plus: @@ -27,27 +50,63 @@ plus: cache.enable: "true" ``` +Set `plus.enabled: false` (or remove the `plus:` block) to stop using Plus without deleting it from disk. + Use a strong secret in production. Dev-only default secrets require an explicit environment opt-in on loopback. -**`ProxyServer` knobs** (profiles, timeouts, TLS, limits, upstream, …) are configured under `server:` in twp.yaml — not `plus.options`. See [Configuration](/docs/configuration). +**Engine knobs** (profiles, timeouts, TLS, limits, upstream, …) are configured under `server:` in twp.yaml — not `plus.options`. See [Configuration](/docs/configuration). -## What you get +## Remove -| Area | Capability | -|------|------------| -| Control plane | Loopback HTTP API with shared-secret header; snapshot get/put; cache purge | -| Dashboard | HTML admin on an ephemeral port (or explicit `controlPlane.dashboardPort`) | -| Observability | Prometheus-style metrics for destination state / latency | -| Operations | Drain / healthy / maintenance destination states | -| Discovery | File watch, DNS poll; Consul / Kubernetes best-effort | -| Security | CIDR allow-list, JWT/OIDC (JWKS) | -| WAF | Thin deny-list (paths, methods, headers, body size) — not a full WAF suite | -| State | Fixed-window per-IP rate limit (`state.mode=memory` or `state.redis`) | -| Resilience | Active HTTP/TCP health probes | -| Cache | In-memory HTTP response cache (`cache.enable`) | +To delete Plus from disk: + +```shell +titanium update --remove-plus +``` + +This removes Plus beside the CLI (and `.bak` / `.new`). It does **not** stop a running proxy, OS service, or the in-process Plus control plane / dashboard — stop `titanium run` or `titanium service stop`, then start again so Plus unloads. It does not edit your config; disable `plus.enabled` separately if it is still set. Re-install later with `titanium update --plus`. + +## `plus.options` keys (reference) + +String values under `plus.options` (examples): + +| Key | Role | +|-----|------| +| `discovery.mode` | `file`, `dns`, `consul`, or `k8s` | +| `discovery.file` | Path for `mode=file` | +| `discovery.dnsName` / `discovery.dnsPort` | DNS discovery target | +| `discovery.consulUrl` / `discovery.k8sUrl` | Best-effort HTTP poll endpoints | +| `discovery.intervalMs` / `discovery.clusterId` | Poll interval and cluster id | +| `security.allowCidrs` | Comma-separated client CIDR allow-list | +| `security.jwtAuthority` / `security.jwtAudience` / `security.jwksUrl` | JWT/OIDC validation | +| `security.apiKeys` | Comma-separated API keys (`X-Api-Key` by default) | +| `security.apiKeyHeader` | Alternate API key header name | +| `security.basicUsers` | Comma-separated `user:password` pairs for HTTP Basic | +| `cors.enabled` | Enable CORS helper (`true`) | +| `cors.allowOrigin` / `cors.allowMethods` / `cors.allowHeaders` / `cors.allowCredentials` / `cors.maxAgeSeconds` | CORS knobs | +| `waf.enabled` | Enable thin deny-list WAF | +| `waf.denyPaths` / `waf.denyMethods` / `waf.denyHeader` | Deny rules | +| `waf.maxBodyBytes` / `waf.rulesFile` | Body cap and optional rules file | +| `state.mode` | `memory` or use Redis via `state.redis` | +| `state.redis` / `state.rateLimitPerMinute` | Redis connection and rate limit | +| `resilience.activeHealth` | Enable active probes | +| `resilience.intervalMs` / `resilience.unhealthyThreshold` / `resilience.path` / `resilience.protocol` / `resilience.timeoutMs` | Probe knobs | +| `resilience.circuit.enabled` | Outlier ejection on consecutive 5xx → Unhealthy | +| `resilience.circuit.failureThreshold` / `resilience.circuit.cooldownMs` | Circuit knobs | +| `resilience.retry.idempotentAttempts` | Raise connection retries for safe methods (1–5) | +| `cache.enable` | In-memory response cache | +| `grpc.transcode.enabled` | Enable gRPC-JSON transcoding | +| `grpc.transcode.descriptorSet` | Path to FileDescriptorSet (`.pb`) | +| `grpc.transcode.services` | Comma-separated fully-qualified service names | +| `grpc.transcode.convertGrpcStatus` | Map non-OK `grpc-status` to HTTP + JSON body (default true) | +| `grpc.transcode.ignoreUnknownQueryParameters` | Ignore unknown query keys (default true) | +| `grpc.transcode.preserveProtoFieldNames` | Use proto field names in JSON (default false) | +| `grpc.transcode.alwaysPrintPrimitiveFields` | Always emit primitive defaults in JSON (default false) | +| `grpc.transcode.compression` | `true` or `gzip` to gzip framed payloads | ## See also - [CLI](/docs/cli) - [Configuration](/docs/configuration) - [Editions](/docs/editions) +- [gRPC-JSON transcoding](/docs/grpc-json-transcoding) diff --git a/website/docs/protocol-support.md b/website/docs/protocol-support.md index 68132e3b1..ac554be85 100644 --- a/website/docs/protocol-support.md +++ b/website/docs/protocol-support.md @@ -1,6 +1,10 @@ # Protocol support -Feature snapshot for HTTP/1.0, HTTP/1.1, HTTP/2, and HTTP/3. HTTP/2 is **on by default** (`ProxyServer.EnableHttp2 = true`). HTTP/3 is experimental (`TWP001`) and opt-in (`EnableHttp3 = true`). CLI/Inspector Release zips bundle MsQuic natives per RID; NuGet library hosts install system MsQuic — see [HTTP/3](/docs/http3). +Titanium speaks HTTP/1.0, HTTP/1.1, HTTP/2, and experimental HTTP/3, and can bridge between them when client and origin disagree. + +> **For implementers / .NET library users** — the tables below use API names (`EnableHttp2`, `EnableHttp3`, `TWP001`). Operators enabling HTTP/3 from the CLI can start with [HTTP/3](/docs/http3). + +HTTP/2 is **on by default**. HTTP/3 is experimental and opt-in. CLI/Inspector release zips bundle MsQuic natives per platform; Library hosts install system MsQuic — see [HTTP/3](/docs/http3). ## Protocol bridges diff --git a/website/docs/security.md b/website/docs/security.md index f34aa1d28..e94b3588f 100644 --- a/website/docs/security.md +++ b/website/docs/security.md @@ -1,6 +1,6 @@ # Security considerations -## HTTPS decryption (MITM) +## HTTPS decryption — man-in-the-middle (MITM) Decrypting HTTPS requires a root certificate that clients trust. Only install generated roots on machines you control. Machine-wide trust is stronger and riskier than per-user trust. diff --git a/website/docs/streaming-bodies.md b/website/docs/streaming-bodies.md index 7db160723..f4043ca03 100644 --- a/website/docs/streaming-bodies.md +++ b/website/docs/streaming-bodies.md @@ -1,6 +1,8 @@ # Streaming bodies -Titanium can stream request and response bodies across HTTP/1.x (plain and TLS), HTTP/2, and HTTP/3 instead of buffering entire payloads. +Titanium can stream large uploads and downloads instead of buffering entire payloads — across HTTP/1.x, HTTP/2, and HTTP/3. + +Full guidance, limitations, and examples: [Streaming-Bodies wiki](https://github.com/justcoding121/titanium-web-proxy/wiki/Streaming-Bodies). Synthetic / custom responses: [Synthetic-Responses wiki](https://github.com/justcoding121/titanium-web-proxy/wiki/Synthetic-Responses). ## When to stream @@ -10,12 +12,6 @@ Titanium can stream request and response bodies across HTTP/1.x (plain and TLS), ## Library hooks -Use session events and body-write callbacks on `SessionEventArgs` / related types. Prefer streaming APIs when you do not need the full buffer. +> **For .NET library users** — use session events and body-write callbacks on `SessionEventArgs` / related types. Prefer streaming APIs when you do not need the full buffer. For HTTP/3, per-chunk streaming hooks are part of the experimental surface (`TWP001`). - -## Details - -Full guidance, limitations, and examples: [Streaming-Bodies wiki](https://github.com/justcoding121/titanium-web-proxy/wiki/Streaming-Bodies). - -Synthetic / custom responses: [Synthetic-Responses wiki](https://github.com/justcoding121/titanium-web-proxy/wiki/Synthetic-Responses). diff --git a/website/download.data.ts b/website/download.data.ts index 64f48ef64..a51cb9adb 100644 --- a/website/download.data.ts +++ b/website/download.data.ts @@ -17,12 +17,22 @@ export interface DownloadAsset { } export interface ChannelDownloads { - /** GitHub release tag, or null when no product zip/MSI release exists for the channel. */ + /** GitHub release tag, or null when no product zip/MSI/DMG release exists for the channel. */ tag: string | null - cli: Partial> + cli: Partial> & { + /** Prefer AppImage / deb / rpm when present (glibc). */ + appimage?: Partial> + deb?: Partial> + rpm?: Partial> + } inspector: { msi?: DownloadAsset zip?: DownloadAsset + /** Prefer DMG when present (macOS). */ + dmg?: Partial> + appimage?: Partial> + deb?: Partial> + rpm?: Partial> } & Partial> } @@ -73,7 +83,9 @@ function hasProductAssets(r: GhRelease): boolean { (a) => a.name.startsWith('Titanium.Cli-') || a.name.startsWith('TitaniumInspector-') || - a.name.startsWith('Titanium.Plus-'), + a.name.startsWith('Titanium.Plus-') || + a.name.startsWith('titanium-cli') || + a.name.startsWith('titanium-inspector'), ) } @@ -89,13 +101,60 @@ function isBetaRelease(r: GhRelease): boolean { ) } +function ensureNested( + out: ChannelDownloads, + product: 'cli' | 'inspector', + kind: 'appimage' | 'deb' | 'rpm' | 'dmg', +): Record { + const bag = out[product] as Record + if (!bag[kind] || typeof bag[kind] !== 'object') bag[kind] = {} + return bag[kind] as Record +} + function assignAsset(out: ChannelDownloads, asset: DownloadAsset, name: string): void { for (const rid of CLI_RIDS) { if (name === `Titanium.Cli-${rid}.zip` && !out.cli[rid]) out.cli[rid] = asset if (name === `TitaniumInspector-${rid}.zip` && !out.inspector[rid]) out.inspector[rid] = asset } + if (name === 'TitaniumInspector-win-x64.msi' && !out.inspector.msi) out.inspector.msi = asset if (name === 'TitaniumInspector-win-x64.zip' && !out.inspector.zip) out.inspector.zip = asset + + // Mac DMG + for (const rid of ['osx-arm64', 'osx-x64'] as const) { + if (name === `TitaniumInspector-${rid}.dmg`) { + const dmg = ensureNested(out, 'inspector', 'dmg') + if (!dmg[rid]) dmg[rid] = asset + } + } + + // Linux AppImage / deb / rpm (glibc only) + for (const rid of ['linux-x64', 'linux-arm64'] as const) { + if (name === `TitaniumInspector-${rid}.AppImage`) { + const m = ensureNested(out, 'inspector', 'appimage') + if (!m[rid]) m[rid] = asset + } + if (name === `Titanium.Cli-${rid}.AppImage`) { + const m = ensureNested(out, 'cli', 'appimage') + if (!m[rid]) m[rid] = asset + } + if (name === `TitaniumInspector-${rid}.deb`) { + const m = ensureNested(out, 'inspector', 'deb') + if (!m[rid]) m[rid] = asset + } + if (name === `Titanium.Cli-${rid}.deb`) { + const m = ensureNested(out, 'cli', 'deb') + if (!m[rid]) m[rid] = asset + } + if (name === `TitaniumInspector-${rid}.rpm`) { + const m = ensureNested(out, 'inspector', 'rpm') + if (!m[rid]) m[rid] = asset + } + if (name === `Titanium.Cli-${rid}.rpm`) { + const m = ensureNested(out, 'cli', 'rpm') + if (!m[rid]) m[rid] = asset + } + } } function channelFromRelease(r: GhRelease): ChannelDownloads { diff --git a/website/download.md b/website/download.md index 64b599400..0edf48bc6 100644 --- a/website/download.md +++ b/website/download.md @@ -2,6 +2,7 @@ -Get CLI and Inspector builds from GitHub Releases. This page lists the **latest stable** and **latest beta** product releases that include zip/MSI assets (NuGet-only tags are skipped). +Download **Inspector** (desktop debugger) or the **CLI** (reverse proxy) for your OS. Prefer the primary installer when available: **Windows MSI**, **macOS DMG**, **Linux AppImage / deb / rpm**. Portable zips work everywhere and are what `titanium update` uses. -**winget** installs the last published **stable** community package only — use the buttons below or GitHub for beta. +Pick **Stable** for production or **Beta** for the newest builds. Then choose Inspector or CLI below. -HTTP/3 natives ship inside each RID zip (except Windows, which uses OS MsQuic on Win11 / Server 2022+). Alpine/K8s: use **`linux-musl-*`**, not `linux-x64`. Details: [HTTP/3](/docs/http3). +::: tip Quick picks +- **Windows:** MSI (Inspector) or zip (CLI). +- **macOS:** DMG when published, otherwise zip; CLI also via [Homebrew](#homebrew-macos-cli) when you prefer a tap. +- **Linux:** AppImage / `.deb` / `.rpm` for normal desktops; Alpine containers need the **musl** zip — see [Advanced](#advanced). +:::

                          @@ -23,20 +28,22 @@ HTTP/3 natives ship inside each RID zip (except Windows, which uses OS MsQuic on

                          {{ ch.hint }}

                          -

                          Titanium Inspector

                          +

                          Inspector

                          - Desktop MITM debugger. - Windows: MSI wizard (choose install folder, Finished + Launch) or portable zip. - Uninstall from Settings → Apps (branded icon). - Linux / macOS: zip — run portable, or use - install.sh / install-app.sh in the zip - (uninstall.sh / uninstall-app.sh to remove). + Desktop HTTPS / HTTP debugger (decrypt on machines you control). + Inspector guide.

                          +
                          + Titanium Inspector session grid with HTTPS decrypt and headers pane +
                          + Session grid, HTTPS decrypt, and inspectors. +
                          +
                          Windows MSI @@ -44,18 +51,35 @@ HTTP/3 natives ship inside each RID zip (except Windows, which uses OS MsQuic on Not published yet
                          +
                          + Linux x64 .deb + {{ ch.data.inspector.deb['linux-x64'].name }} Not published yet
                          +
                          + Linux arm64 .deb + {{ ch.data.inspector.deb['linux-arm64'].name }} + Not published yet +
                          +
                          + Linux arm64 .rpm + {{ ch.data.inspector.rpm['linux-arm64'].name }} Not published yet
                          @@ -64,19 +88,29 @@ HTTP/3 natives ship inside each RID zip (except Windows, which uses OS MsQuic on Not published yet
                          - macOS arm64 - {{ ch.data.inspector['osx-arm64'].name }} + Alpine / musl arm64 + {{ ch.data.inspector['linux-musl-arm64'].name }} Not published yet
                          +
                          -

                          CLI (titanium / twp)

                          -

                          Self-contained zip. Extract and run. Each zip includes both titanium and twp binaries.

                          +

                          CLI

                          +

                          + Command-line reverse / edge proxy (titanium / twp). + CLI guide. +

                          Windows x64 @@ -84,13 +118,35 @@ HTTP/3 natives ship inside each RID zip (except Windows, which uses OS MsQuic on Not published yet
                          - Linux arm64 (glibc) - {{ ch.data.cli['linux-arm64'].name }} + Linux x64 .deb + {{ ch.data.cli.deb['linux-x64'].name }} + Not published yet +
                          +
                          + Linux x64 .rpm + {{ ch.data.cli.rpm['linux-x64'].name }} + Not published yet +
                          + +
                          + Linux arm64 .deb + {{ ch.data.cli.deb['linux-arm64'].name }} + Not published yet +
                          +
                          + Linux arm64 .rpm + {{ ch.data.cli.rpm['linux-arm64'].name }} Not published yet
                          @@ -116,68 +172,43 @@ HTTP/3 natives ship inside each RID zip (except Windows, which uses OS MsQuic on
                          -## winget (Windows, stable only) - -```shell -winget install justcoding121.TitaniumCli -winget install justcoding121.TitaniumInspector -``` +## Homebrew (macOS CLI) ```shell -titanium run -c twp.yaml -titanium version --check -titanium update -titanium update --channel beta -titanium http3-deps status +brew tap justcoding121/titanium +brew install titanium ``` -`titanium update` self-updates the CLI from the release feed for the selected channel (stable by default). -## Titanium.Plus +## Plus -Plus is **not** a separate download on this page. After the CLI is installed: +Plus is **not** a separate download. After the CLI: ```shell -titanium update --plus --channel beta -titanium version --check --plus --channel beta +titanium update --plus ``` -For stable Plus updates, omit `--channel beta` (default channel is `stable`). - -Place the DLL beside the CLI (the updater does this), then enable Plus in config: - -```yaml -plus: - enabled: true - controlPlane: - host: "127.0.0.1" - port: 9080 - sharedSecret: "" -``` - -Plus is licensed under [PolyForm Noncommercial](https://github.com/justcoding121/titanium-web-proxy/blob/develop/licenses/PolyForm-Noncommercial-1.0.0.txt) — not for commercial use without a separate license agreement. +Then enable it in config — see [Plus](/docs/plus). ## Library (NuGet) ```shell dotnet add package Titanium.Web.Proxy +# Newer than stable: +dotnet add package Titanium.Web.Proxy --prerelease ``` -Prerelease: +## Advanced -```shell -dotnet add package Titanium.Web.Proxy --prerelease -``` +- **Alpine / musl containers:** use the Alpine / musl zip rows above — not the regular Linux zip. Details: [HTTP/3](/docs/http3) and [Install](/docs/install#advanced). +- **Windows signing:** Authenticode publisher **Jehonathan Thomas**. +- **Some GitHub tags are NuGet-only** (no CLI/Inspector assets). Prefer this page or a release that lists the installers you need. ## Release notes See [Releases](/releases) or [all assets on GitHub](https://github.com/justcoding121/titanium-web-proxy/releases). -::: tip Product zips vs NuGet tags -Some tags publish **NuGet only**. CLI / Inspector zip assets appear above only when a full product release is cut (`v*` tag via the release workflow). Prefer this page or GitHub Releases for binaries; **winget** remains stable-only. -::: - ## See also - [Install](/docs/install) +- [Getting started](/docs/getting-started) - [HTTP/3](/docs/http3) -- [Releases](/releases) diff --git a/website/index.md b/website/index.md index 2bd496771..c97c20024 100644 --- a/website/index.md +++ b/website/index.md @@ -4,7 +4,7 @@ title: Titanium Web Proxy hero: name: Titanium Web Proxy text: High-performance HTTP(S) proxy - tagline: Reverse / edge CLI, desktop Inspector, and optional Plus ops — on Windows, Linux, and macOS. Embed in .NET when you need a library. + tagline: Inspect HTTPS traffic, put a reverse proxy in front of your apps, or embed the same engine in .NET — on Windows, Linux, and macOS. image: src: /logo.svg alt: Titanium Web Proxy @@ -19,56 +19,67 @@ hero: text: GitHub link: https://github.com/justcoding121/titanium-web-proxy features: - - title: Intercept & modify - details: Explicit, transparent, and SOCKS4/5 endpoints. Decrypt HTTPS, stream bodies, and shape traffic — from the Inspector, CLI, or your own app. - - title: Reverse / edge CLI - details: Run `titanium` / `twp` with twp.yaml — routes, clusters, load balancing, TLS terminate, and ACME. Self-contained zips for every major OS. + - title: Debug HTTPS traffic + details: Decrypt and inspect requests in the desktop Inspector — sessions, headers, bodies, AutoResponder, and breakpoints. Only on machines you control. + - title: Reverse proxy from the CLI + details: Download the CLI, write a short YAML file, and run `titanium`. Routes, load balancing, TLS, and optional automatic certificates (ACME). - title: HTTP/1 · HTTP/2 · HTTP/3 - details: HTTP/2 on by default. HTTP/3 (QUIC) opt-in. Protocol bridges between client and origin versions. + details: HTTP/2 is on by default. HTTP/3 (QUIC) is optional. Titanium can bridge when the client and backend speak different versions. - title: Measured performance - details: Typically at or above YARP; ahead of nginx on H2/H3→H1 reverse, near parity for the rest. See the performance guide for publishable tables. + details: Compared on the same test harness against YARP, nginx, HAProxy, and Envoy. See the charts below and the performance guide. --- -## Editions +## What do you want to do?
                          -

                          Titanium.Cli

                          -

                          MIT · zip / winget

                          -

                          Standalone reverse / edge daemon for any stack: run, test, version, update.

                          +

                          Inspect traffic

                          +

                          Inspector · Windows / macOS / Linux

                          +

                          Desktop debugger for HTTP and HTTPS. Download → Inspector guide.

                          -

                          Titanium Inspector

                          -

                          PolyForm NC · MSI / zip

                          -

                          Desktop MITM debugger — session grid, inspectors, AutoResponder, breakpoints, HAR.

                          +

                          Run a reverse proxy

                          +

                          CLI · MIT

                          +

                          Standalone proxy for any backend stack. Download CLI → CLI guide.

                          -

                          Titanium.Plus

                          -

                          PolyForm NC

                          -

                          Control plane, dashboard, observability, discovery, WAF. Install with titanium update --plus.

                          +

                          Ops add-on

                          +

                          Plus · optional

                          +

                          Dashboard, metrics, auth helpers, and a thin WAF. After the CLI: titanium update --plus. Plus.

                          -

                          Titanium.Web.Proxy

                          -

                          MIT · NuGet

                          -

                          Optional .NET library — embed a MITM and/or reverse proxy in your app.

                          +

                          Embed in .NET

                          +

                          Library · NuGet · MIT

                          +

                          Same engine inside your app. Library guide · NuGet.

                          +## Performance + +Throughput (requests per second) vs **YARP**, **nginx**, **HAProxy**, and **Envoy** on matched GitHub Actions runners. Linux tiny-GET chart here; Windows, macOS, 64 KB practical charts, and heavier workloads on the [performance](/docs/performance) page. + +
                          + +![Practical reverse proxy throughput on Linux (tiny requests)](../wiki/images/rps-practical-linux.png) + +
                          + ## Quick start ::: code-group ```shell [CLI] # Download a CLI zip from /download, then: -titanium run -c twp.yaml titanium test -c twp.yaml +titanium run -c twp.yaml ``` ```yaml [twp.yaml] -schemaVersion: "7.0" +schemaVersion: "7.1" listeners: - host: "127.0.0.1" port: 8000 + # false = plain reverse proxy (no HTTPS decrypt) decryptSsl: false forwardHost: "127.0.0.1" forwardPort: 8080 @@ -99,6 +110,7 @@ proxyServer.Start(); - [Download CLI & Inspector](/download) - [Getting started](/docs/getting-started) -- [Configuration reference](/docs/configuration) +- [Performance](/docs/performance) +- [Configuration](/docs/configuration) - [Release notes](/releases) - [API reference](/api/Titanium.Web.Proxy.ProxyServer.html){target="_blank" rel="noreferrer"} diff --git a/website/public/titanium-releases.asc b/website/public/titanium-releases.asc new file mode 100644 index 000000000..fe25965a8 --- /dev/null +++ b/website/public/titanium-releases.asc @@ -0,0 +1,29 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGqYUhsBEADXD0YOzaakKri8Ynlx5k3mpPy4xGk0Lt6DOfTTCEBgkTSOSWqv +xLwsiRaS1Et5bHq3Ws54BOTxSdKzaLvZtnMtMYIifm5aK95nB4DPe3ocXj5xVOWF +qwXX1mKp1nNuNol5qlx3gwZBa4c7/0jwq5u6A8vOfvxv6HCKAaima0fRyGYwIEcT +Ho63aujO5FCXWXNq/RQD7UtjUYZOIZVBoqUnuZwW8nK8xIDc3lUQvCdu1E4FNvYP +jwsiPUlIbOb6IvQ6JMuBr62qHPyDMTHNe7TfVQRME0NyJnOoP8WPXaKSfGKHSRoy +4N3X+uggUCKCCCdFCdTZuPwXEF38BsVFHe450QUIipTb8SCyQkLJHMOT54xhvDc8 +ajHeHT8I2YGMc0z09u41WlQRUrjbFfg2TFnkRMmHtrRLdXa78TKTuxzJjkaKQlva +oeE3sqCjA+Q44tCu6pOJNM5tT1CIGZSmis/DdX8kplEfOkKKV7QPCmxyFawmQNGQ +pkNXXz8xXo13DKhuo26zMTAw2i3HHZBUKOCIkaHsgNTRbuRYDWcTRXexea4cczSC +KLrmGsWtsoHzLQn7PpptWUdlornTB1gb9wfuznnRvAhqANq5Uw1orHh9aH/J1wwb +acjtOkxNwZk7S3FVlUQ8/ydBIYWxWmCjs9NQB+dCGvmTz9LaF+dAlg7n6wARAQAB +tD5UaXRhbml1bSBSZWxlYXNlcyA8dGl0YW5pdW0tcmVsZWFzZXNAdXNlcnMubm9y +ZXBseS5naXRodWIuY29tPokCVwQTAQgAQRYhBKgk6IYNqwH62pROLY5atD9ByN6P +BQJqmFIbAhsDBQkDwmcABQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEI5a +tD9ByN6PjPsQAJFwBQNSjuqPR0QBH6UOyp44j0f+zcw3VHk7ilDvBQ7kwsIactyu +bgswez6Pghr3PkxOyDTZy7wBpqFAeS3fuWuHcesBcTATygJPKpvmFaYgOiKrTDf0 +5HYaWyz30LpUhdQrY8Ufr5sup6leR8oa6v+G4sp85Sc6XZBJBp1o4L2o9cJKPqxw +/ORYOlnqMSTqlAFkepBKcJdw31bBP5+vwQ+I6K6BKAZFiLCpKlrEupmdkT34YsAb +0BkixgrJCjMQUlUKcapl3EvN4zqnSgTtrxk33zlrzAIudM6lpOCPWRet5r+1RkYd +VfWJGWTrUNSusAqSGEOUGbm84LzirJniEu0M/4dnt+kFZ4ituuI9rvi+Cg8nCgUS +eKwZsQOeinSoIWOPne8Vmg76YQuNJ7lSV6RPw88HxfoV/w264Djp2m3SgOUk8bY2 +u8RR507F5WQeL4ngnA3bvvxEFnJ4Z8B0X3SZrn/JN+aePaUx8CJuPg/chvPzaTuM +Hr5kDYhSdWw5r8L4MxyhomCuPXrHpjrGN2toDKCEFt8igakmEApgLPKHOQZPMnA2 +iHyCNsRk36pCf/ZqcnTzfFpL81jIxHfjTsstu7urQPvoBk9wnYSaMNBB1p1xPsqE +M+pAXlAfLwLZOlhNGOO5NUpa5C7aOMQ7w3cy/N5/9tltdwTZ7vg93pnM +=KZMT +-----END PGP PUBLIC KEY BLOCK----- diff --git a/wiki/HTTP-3.md b/wiki/HTTP-3.md index 158d5d59b..0f48b4cc1 100644 --- a/wiki/HTTP-3.md +++ b/wiki/HTTP-3.md @@ -1,5 +1,7 @@ # HTTP/3 (QUIC) Support +> For operators enabling HTTP/3 on CLI/Inspector hosts, and for .NET library users opting into QUIC. + Titanium Web Proxy supports HTTP/3 as an **opt-in experimental** feature built on top of `System.Net.Quic` (which in turn uses the MsQuic native library). @@ -81,7 +83,7 @@ titanium http3-deps status titanium http3-deps install ``` -Detects apt (Microsoft repo), dnf/zypper, `apk add libmsquic`, or `brew install libmsquic`. Opt-in; needs network and sudo/admin. Not automatic in MSI/winget. +Detects apt (Microsoft repo), dnf/zypper, `apk add libmsquic`, or `brew install libmsquic`. Opt-in; needs network and sudo/admin. Not automatic in MSI / AppImage / zip installs. ## Quick start — reverse dual-listen (HttpClient / browsers) @@ -402,10 +404,11 @@ pattern as `ExplicitProxyEndPoint` without touching the transparent implementati **CLI / Inspector:** Release `osx-x64` / `osx-arm64` zips already bundle `libmsquic`, `libssl`, and `libcrypto` with `@loader_path`. Prefer those zips over a manual Homebrew install. +**Local Debug / `dotnet run` (macOS):** Builds copy Homebrew natives beside the output, but framework-dependent hosts still need that folder on `DYLD_FALLBACK_LIBRARY_PATH` because `System.Net.Quic` loads MsQuic by leaf name only (not from `AppContext.BaseDirectory`). Inspector and CLI call `Http3NativeBootstrap.EnsureAppLocalMsQuicVisible` at startup (and regenerate `Properties/launchSettings.json` on build) so a normal Debug launch enables HTTP/3 after `brew install libmsquic openssl@3`. + **NuGet library hosts:** MsQuic is **not** bundled with the .NET runtime on macOS. Bundle `libmsquic`, `libssl`, and `libcrypto` alongside your application and configure `@loader_path` so the libraries -can locate each other locally. When this is done correctly, `QuicListener.IsSupported` returns `true`. -Alternatively: `brew install libmsquic` or `titanium http3-deps install` on a machine that has the CLI. +can locate each other locally, then call `Http3NativeBootstrap.EnsureAppLocalMsQuicVisible` before the first `QuicListener.IsSupported` check (or ship self-contained so `System.Net.Quic.dll` sits next to the dylibs). Alternatively: `brew install libmsquic` or `titanium http3-deps install` on a machine that has the CLI. See the [MsQuic GitHub](https://github.com/microsoft/msquic) for library build and bundling instructions. diff --git a/wiki/Home.md b/wiki/Home.md index 2611e87be..045664231 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -1,13 +1,22 @@ # Titanium Web Proxy -> Canonical product docs, downloads, and release notes: **[https://titaniumproxy.com](https://titaniumproxy.com)** +A lightweight, high-performance HTTP(S) proxy for Windows, Linux, and macOS — reverse / edge CLI, desktop Inspector, optional Plus, and an embeddable .NET library. -A lightweight, high-performance HTTP(S) proxy — reverse / edge CLI, Inspector, and optional Plus on Windows, Linux, and macOS; embeddable .NET library via NuGet. This wiki documents major library APIs. For the full type reference, see the [API documentation](https://titaniumproxy.com/api/Titanium.Web.Proxy.ProxyServer.html). +**[Website](https://titaniumproxy.com)** · [Download](https://titaniumproxy.com/download) · [Install](https://titaniumproxy.com/docs/install) · [Getting started](https://titaniumproxy.com/docs/getting-started) · [Releases](https://titaniumproxy.com/releases) · [API reference](https://titaniumproxy.com/api/Titanium.Web.Proxy.ProxyServer.html) ## Contents +### Using Titanium + - [Getting started](#getting-started) - [Screenshots](#screenshots) +- [Performance](Performance) — measured throughput vs peers +- [Security considerations](Security-Considerations) +- [Protocol feature support](Protocol-Support) ([bridges](Protocol-Support#protocol-bridges)) +- [Migrating from 4.x to 5.0](Migration-4.x-to-5.0) + +### Library API (.NET) + - [Endpoints](#endpoints) - [Decrypting HTTPS](#decrypting-https) - [Intercepting requests and responses](#intercepting-requests-and-responses) @@ -19,23 +28,22 @@ A lightweight, high-performance HTTP(S) proxy — reverse / edge CLI, Inspector, - [Tunnel (CONNECT) interception](#tunnel-connect-interception) - [Upstream proxies](#upstream-proxies) - [Authentication](#authentication) -- [Performance](Performance) — measured latency, throughput, saturation RPS, and footprint -- [Performance profiling](Performance-Profiling) — how throughput hotspots are found (harness, dumps, stage timing) -- [Performance local lab](Performance-Local-Lab) — laptop cool A/B tables (not publishable) - [Performance and pooling](#performance-and-pooling) - [Logging and diagnostics](#logging-and-diagnostics) - [Request timing](#request-timing) - [Supported frameworks](#supported-frameworks) - [Breaking changes: unified logging and timing](#breaking-changes-unified-logging-and-timing) -- [Migrating from 4.x to 5.0](Migration-4.x-to-5.0) -- [Security considerations](Security-Considerations) -- [Protocol feature support](Protocol-Support) ([bridges](Protocol-Support#protocol-bridges)) + +### For contributors + +- [Performance profiling](Performance-Profiling) — how throughput hotspots are found +- [Performance local lab](Performance-Local-Lab) — laptop cool A/B tables (not publishable) ## Getting started Canonical downloads: **[https://titaniumproxy.com/download](https://titaniumproxy.com/download)** · install guide: **[https://titaniumproxy.com/docs/install](https://titaniumproxy.com/docs/install)**. -Install from [NuGet](https://www.nuget.org/packages/Titanium.Web.Proxy): +To embed the library, install from [NuGet](https://www.nuget.org/packages/Titanium.Web.Proxy): ```shell dotnet add package Titanium.Web.Proxy @@ -56,8 +64,7 @@ using Titanium.Web.Proxy.Models; using var proxyServer = new ProxyServer(); -// Built-in console sink is a bounded channel + background writer, so LogInformation -// never blocks a session thread on Console I/O. +// Console logging is asynchronous so it does not block request handling. proxyServer.Logging.MinimumLevel = LogLevel.Information; proxyServer.BeforeRequest += OnRequest; @@ -103,6 +110,10 @@ Outbound HTTP/2 probes that fail ALPN (`SEC_E_NO_APPLICATION_PROTOCOL` / “No c WPF proxy application screenshot +## Library API (.NET) + +This section covers the major `Titanium.Web.Proxy` APIs for embedding the engine in a .NET app. For the full type reference, see the [API documentation](https://titaniumproxy.com/api/Titanium.Web.Proxy.ProxyServer.html). + ## Endpoints Add one or more endpoints before calling `Start()`: @@ -566,8 +577,7 @@ proxyServer.AfterResponse += async (sender, e) => - .NET 10 -Versions prior to 4.0 also supported .NET Framework 4.6.2 and .NET 8; starting with 4.0, the package targets -.NET 10 only. +Current packages target **.NET 10** only. Older product lines also supported .NET Framework 4.6.2 and earlier .NET (including .NET 8). ## Breaking changes: unified logging and timing diff --git a/wiki/Migration-4.x-to-5.0.md b/wiki/Migration-4.x-to-5.0.md index 1c55fb317..620424ab0 100644 --- a/wiki/Migration-4.x-to-5.0.md +++ b/wiki/Migration-4.x-to-5.0.md @@ -1,5 +1,7 @@ # Migration guide: 4.x → 5.0 +> For developers upgrading embedded Titanium.Web.Proxy apps. + Version 5.0 bundles a large security- and correctness-hardening pass (RFC-compliance fixes, resource budgets, and defense-in-depth limits across every protocol the proxy speaks). Rather than stage these across an interim 6.0, all of them ship together in 5.0.0, since most of the individually-breaking diff --git a/wiki/Performance-Local-Lab.md b/wiki/Performance-Local-Lab.md index d1658dd98..25515f625 100644 --- a/wiki/Performance-Local-Lab.md +++ b/wiki/Performance-Local-Lab.md @@ -1,6 +1,8 @@ # Performance Local Lab -Local Windows laptop debugging / cool A/B tables. **Not publishable** � do not compare these absolutes to [Performance](Performance) GHA tables. Use cool paired ratios as a gate, then remasure on matched Windows+Linux GHA and paste CI medians onto Performance. +> **For contributors** — local laptop cool A/B tables. Not publishable; do not compare these absolutes to [Performance](Performance). + +Local Windows laptop debugging / cool A/B tables. **Not publishable** — do not compare these absolutes to [Performance](Performance) GHA tables. Use cool paired ratios as a gate, then remeasure on matched Windows+Linux GHA and paste CI medians onto Performance. Playbook (harness, dumps, stage timing, Memory techniques) stays on [Performance Profiling](Performance-Profiling). @@ -28,7 +30,7 @@ Local debug setup and historical High-perf / cool-paired tables. **Do not paste | Harness | RpsLoadProbe Release; arms run **sequentially** | -This box is **8 logical / ~32 GiB** — not the 4 vCPU / 16 GiB GHA class. Treat ratios as the local gate; remasure on CI before claiming a publishable win. +This box is **8 logical / ~32 GiB** — not the 4 vCPU / 16 GiB GHA class. Treat ratios as the local gate; remeasure on CI before claiming a publishable win. **Cool** = ~2 min idle, then paired A/B (alternate who goes first; **mean of both orders @ c=32**) — **authoritative local gate**; reverse tiny-GET / body cells below use those cool absolutes when cited. **Heated** = long sequential matrix (thermal skew). **🥇** = higher cool sustain in that row (or heated sustain only when no cool pair exists — noted). @@ -82,7 +84,7 @@ Reverse TWP/YARP cells for cool-audited arms are **cool paired means** (see note **TWP Memory / CPU** (heated 1-rep @ tip, `laptop-matrix-memory-20260824/`; warmup 2s / measure 8s; c=8–64): filled from compare-same / compare-bridges / compare-mitm peak-RPS step. Cool RPS cells unchanged. H2→H1 ~190–207 MiB (was ~425 laptop / ~848 CI); no outsized Memory arms vs prior H2 bag leak. Windows reverse tiny-GET: base matrix **2026-08-20** High-perf, Linux-matched harness (warmup 2s / measure 8s; concurrency 8, 16, 32, 64; median of 3 repeats except H2 TLS→H3 and H3→H1/H2, which have 2). CSVs under `tools/RpsLoadProbe/results/windows-20260820/` (`compare-same`, `compare-bridges`). MITM and heavier reverse: 1-repeat follow-up under `windows-20260820-quick/`. Absolute RPS swings with sequential-arm heat; prefer TWP÷YARP ratios. -**2026-08-21 remasure (through exact-body + H3 QPACK-normalized names):** H1 plain, H1 TLS, H1→H2, H3→H1, H3→H2 refreshed as mean of both arm orders at c=32 (`win-final-`*). Exact-size H2 origin body materialize (no MemoryStream+ToArray) and `HeaderNamesAreHttp2Normalized` on the H3 fast Request. Other reverse Windows rows still **2026-08-20** unless noted. +**2026-08-21 remeasure (through exact-body + H3 QPACK-normalized names):** H1 plain, H1 TLS, H1→H2, H3→H1, H3→H2 refreshed as mean of both arm orders at c=32 (`win-final-`*). Exact-size H2 origin body materialize (no MemoryStream+ToArray) and `HeaderNamesAreHttp2Normalized` on the H3 fast Request. Other reverse Windows rows still **2026-08-20** unless noted. **2026-08-22 matrix fill (missing plain cells):** Library fix so cleartext-listen reverse (`DecryptSsl=false`) honors `ForwardCleartext=false` as origin HTTPS (H1 plain→HTTPS). New probe arms: `reverse-http1-to-https` / `yarp-reverse-http1-to-https`, `http-mitm` (explicit plain→plain). Full Windows `compare-same` + `compare-bridges` + plain twins under `tools/RpsLoadProbe/results/windows-20260822-matrix/` (1-rep; warmup 2s / measure 8s; c=8,16,32,64). @@ -90,7 +92,7 @@ Windows reverse tiny-GET: base matrix **2026-08-20** High-perf, Linux-matched ha **Load generators:** Reverse inbound H3 arms use `**dotnet-httpclient`** (`http_version=3.0`, `RequestVersionExact`) after dual-listen reverse H3. MITM H3→H2 / H3→H3 / H3→H1 plain reuse the same dual-listen transparent reverse path as their reverse twins (`ForwardCleartext` / decrypt knobs). Older UDP-only `quic-http3` MITM H3→H1 TLS numbers are dual-crypto extras (`mitm-http3-to-http1`). -**Matched HttpClient TWP÷YARP — table cells are cool absolutes where cited:** **parity audit** `win-parity-audit-20260822-004214/` (both orders @ c=32): H1 plain **41,390 / 38,772** ≈ **1.07×**; H1 TLS **35,205 / 29,750** ≈ **1.18×**; H1→H3 **21,819 / 20,712** ≈ **1.05×**; H3→H3 **26,299 / 14,942** ≈ **1.76×** (YARP soft — treat absolute cautiously). **2026-08-22 cool paste** `win-cool-paste-20260822-063226/` (both orders @ c=32): H1→H2 **25,540 / 24,920** ≈ **1.02×**; H1 plain→HTTPS **30,844 / 30,621** ≈ **1.01×**; h2c→H3 **27,493 / 24,535** ≈ **1.12×**; H2 TLS→H3 **30,813 / 24,039** ≈ **1.28×**; h2c→H1 **46,517 / 42,994** ≈ **1.08×**; H3→H1 **22,325 / 23,773** ≈ **0.94×**; H3→H2 **22,297 / 21,914** ≈ **1.02×**. **2026-08-23 soft coolish (both orders @ c=32, after session-lite H2/H3 gate):** H3→H1 ≈ **1.09×**; h2c→H1 ≈ **1.05×**; H1→H3 ≈ **1.25×**. Published CI Win bridges @ `11e32f1c` still show those three ≤1.00× — tip remasure @ `62e5efcd` in flight. TWP-led H2 same-protocol rows unchanged (h2c↔h2c ≈ **1.17×**, etc.). +**Matched HttpClient TWP÷YARP — table cells are cool absolutes where cited:** **parity audit** `win-parity-audit-20260822-004214/` (both orders @ c=32): H1 plain **41,390 / 38,772** ≈ **1.07×**; H1 TLS **35,205 / 29,750** ≈ **1.18×**; H1→H3 **21,819 / 20,712** ≈ **1.05×**; H3→H3 **26,299 / 14,942** ≈ **1.76×** (YARP soft — treat absolute cautiously). **2026-08-22 cool paste** `win-cool-paste-20260822-063226/` (both orders @ c=32): H1→H2 **25,540 / 24,920** ≈ **1.02×**; H1 plain→HTTPS **30,844 / 30,621** ≈ **1.01×**; h2c→H3 **27,493 / 24,535** ≈ **1.12×**; H2 TLS→H3 **30,813 / 24,039** ≈ **1.28×**; h2c→H1 **46,517 / 42,994** ≈ **1.08×**; H3→H1 **22,325 / 23,773** ≈ **0.94×**; H3→H2 **22,297 / 21,914** ≈ **1.02×**. **2026-08-23 soft coolish (both orders @ c=32, after session-lite H2/H3 gate):** H3→H1 ≈ **1.09×**; h2c→H1 ≈ **1.05×**; H1→H3 ≈ **1.25×**. Published CI Win bridges @ `11e32f1c` still show those three ≤1.00× — tip remeasure @ `62e5efcd` in flight. TWP-led H2 same-protocol rows unchanged (h2c↔h2c ≈ **1.17×**, etc.). **Attempted H1→H3 micro-opts (2026-08-22, reverted):** Lowercasing H1 request names before QPACK + buffering tiny H3 origin bodies **without draining to FIN** **regressed** cool H1→H3 from ~1.13× to ~0.65× — kept out. **2026-08-23 kept:** same ≤64 KiB eager materialize in `ForwardOverQuicAsync` **plus drain-to-FIN before Dispose** (else RST poisons the QUIC pool → handshake-per-request; first attempt ~1.16×→~0.7×). Cool both orders ≈ **1.03–1.23×** (`cool-h3-origin-eager64-drain-20260823/`). Smoke H2→H3 / H3→H3 still lead; H3→H1 unchanged (~0.98× TY). @@ -121,7 +123,7 @@ nginx/Windows is a limited port — use it for **same-OS** comparison only, not nginx/Windows collapses on large reverse bodies in this harness; treat as same-OS only. -**2026-08-22 cool remasure (bodies) — H2→H1 cells are cool means** (`win-bodies-coalesce288-20260822/`, both orders @ c=32): 64 KiB **7,744 / 6,844** ≈ **1.13×** → TWP leads; 256 KiB **1,935 / 2,179** ≈ **0.89×** → YARP leads. H1 TLS→H1 64 KiB still heated (marker follows heated). H3→H1 64 KiB cool ≈ **0.96×** → YARP leads. 256 KiB H1/H3: heated → YARP leads. +**2026-08-22 cool remeasure (bodies) — H2→H1 cells are cool means** (`win-bodies-coalesce288-20260822/`, both orders @ c=32): 64 KiB **7,744 / 6,844** ≈ **1.13×** → TWP leads; 256 KiB **1,935 / 2,179** ≈ **0.89×** → YARP leads. H1 TLS→H1 64 KiB still heated (marker follows heated). H3→H1 64 KiB cool ≈ **0.96×** → YARP leads. 256 KiB H1/H3: heated → YARP leads. ### POST 64 KiB request + 64 KiB response @@ -135,7 +137,7 @@ nginx/Windows collapses on large reverse bodies in this harness; treat as same-O | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **1,973** | **2,001** | *Not possible* | *Not possible* | **1,802** | **1,893** | -H1 POST: TWP leads (heated and cool). H2 POST: YARP leads — heated ≈ **0.74×**, cool ≈ **~0.88–0.95×** with c=1 TWP ahead (~**1.2×**); residual is multiplex scaling, not single-stream cost. **H3 POST (2026-08-22):** `UpdateContentLength` on streamed uploads stamped CL=0 (`ab16a871`). Heated remasure `sustain0-verify/h3-post/` (c=8–64): TWP sustain **1,973** / YARP **1,802** ≈ **1.09×**. +H1 POST: TWP leads (heated and cool). H2 POST: YARP leads — heated ≈ **0.74×**, cool ≈ **~0.88–0.95×** with c=1 TWP ahead (~**1.2×**); residual is multiplex scaling, not single-stream cost. **H3 POST (2026-08-22):** `UpdateContentLength` on streamed uploads stamped CL=0 (`ab16a871`). Heated remeasure `sustain0-verify/h3-post/` (c=8–64): TWP sustain **1,973** / YARP **1,802** ≈ **1.09×**. ### Lossy / high-RTT (H2 HOL / H3 packet loss) @@ -166,7 +168,7 @@ H1 stays usable; H2 collapses under connection stalls (HOL). **H3 is the protoco | Duplex (both directions live) | HTTP/2 · TLS | HTTP/2 · TLS | **9** | **590** | *Not possible* | *Not possible* | 🥇 **2,455** | **2,455** | | Duplex (WebSocket / extended CONNECT) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **38,235** | **38,823** | **18,251** | **19,054** | **37,803** | **38,454** | -Slow consumer is sleep-bound (~16 × 8 ms per 256 KiB); H1/H2 sit in the same band. **H3 slow-consumer (2026-08-22):** fast path closed the origin socket for CL>16 KiB without `StreamBodyWriter` (`36d21f67`); remasure `sustain0-verify/h3-slow/` matches YARP at **248** sustain. Early-response H1: TWP leads (~1.25× YARP) — sequential H1 still finishes the exchange quickly when the origin answers after 8 KiB. **H3 early-response (2026-08-22):** cool mean ≈ **1.21×** YARP after overlapping origin upload with `ReceiveResponse` / `StreamBodyWriter` (`fix-early-tls/`). Early-response H2 and duplex H2: YARP leads on heated matrix; TWP H2↔H2 duplex sustain **9** vs peak **590** (errors at higher concurrency) vs YARP **2,455**. WebSocket echo: TWP leads (~1.01× YARP); nginx/Windows same-OS only. +Slow consumer is sleep-bound (~16 × 8 ms per 256 KiB); H1/H2 sit in the same band. **H3 slow-consumer (2026-08-22):** fast path closed the origin socket for CL>16 KiB without `StreamBodyWriter` (`36d21f67`); remeasure `sustain0-verify/h3-slow/` matches YARP at **248** sustain. Early-response H1: TWP leads (~1.25× YARP) — sequential H1 still finishes the exchange quickly when the origin answers after 8 KiB. **H3 early-response (2026-08-22):** cool mean ≈ **1.21×** YARP after overlapping origin upload with `ReceiveResponse` / `StreamBodyWriter` (`fix-early-tls/`). Early-response H2 and duplex H2: YARP leads on heated matrix; TWP H2↔H2 duplex sustain **9** vs peak **590** (errors at higher concurrency) vs YARP **2,455**. WebSocket echo: TWP leads (~1.01× YARP); nginx/Windows same-OS only. ### TLS termination cost (H1 TLS → cleartext origin) diff --git a/wiki/Performance-Profiling.md b/wiki/Performance-Profiling.md index 60d99daec..9df9dfc45 100644 --- a/wiki/Performance-Profiling.md +++ b/wiki/Performance-Profiling.md @@ -1,5 +1,7 @@ # Performance Profiling +> **For contributors** — techniques used to find throughput hotspots; not required reading for operators or .NET library users. + How the throughput hotspots behind the numbers on the [Performance](Performance) page were found. This page documents the techniques and tools so future performance work (or a regression hunt) can follow the same playbook instead of rediscovering it. Everything here was used in the 2026 pass that took the HTTP/2 bridge arms from ~6× behind the managed reverse peer to parity-or-close. ## Contents @@ -27,7 +29,7 @@ All throughput work starts from [RpsLoadProbe](https://github.com/justcoding121/ - Every TWP arm has a **control arm** — the managed reverse peer (and the native reverse peer where it can run the path) hosting the *identical* workload in the same session, so both sides see the same machine state. - Every `--ramp` arm is **three OS processes** (parent load generator + origin child + proxy child) with a parent-seeded loopback CA (`TWP_RPS_CERT_DIR`). Combined `--serve` is debug-only. Absolute RPS from older combined TLS/QUIC-origin cells is not comparable to split runs — prefer TWP÷peer ratios. - The probe **ramps concurrency** (typically c=8→64) and reports **sustainable RPS**: the last concurrency step that still met the error-rate and p99-latency SLO. A ramp that grows RPS but blows p99 is a queue, not throughput. -- Results land in timestamped CSVs under `tools/RpsLoadProbe/results/`. Publishable [Performance](Performance) tables cite **GitHub Actions** run IDs (median of 3 on matched 4 vCPU / 16 GiB runners). Cool paired A/B on a laptop proves a win before CI remasure; result tables live on [Performance Local Lab](Performance-Local-Lab). +- Results land in timestamped CSVs under `tools/RpsLoadProbe/results/`. Publishable [Performance](Performance) tables cite **GitHub Actions** run IDs (median of 3 on matched 4 vCPU / 16 GiB runners). Cool paired A/B on a laptop proves a win before CI remeasure; result tables live on [Performance Local Lab](Performance-Local-Lab). ```powershell # one suite @@ -45,7 +47,7 @@ On a laptop, thermal throttling dominates everything else: the *same* arm measur - **Prefer TWP÷peer ratios over absolutes.** The control arm soaks up the same throttling. - **For a targeted A/B question, run the two arms paired**: cooldown (~2 min idle), arm A, arm B immediately after — and alternate which goes first across repeats so heat bias cancels. This is how "MITM costs 0.65–0.75× of its reverse twin, and the delta is purely the extra origin TLS leg" was established: the two probe arms differ by exactly one flag (`ForwardCleartext`). - If an arm's ratio looks newly bad, **re-measure before profiling** — several "regressions" were heat. -- **Gate before publishing:** optimize against cool paired ratios on the [local lab](Performance-Local-Lab). After a cool win, remasure on matched Windows+Linux GHA (`workflow_dispatch` [RPS saturation](https://github.com/justcoding121/titanium-web-proxy/actions/workflows/rps-saturation.yml)) and paste medians into [Performance](Performance). Windows GHA removes laptop thermal skew; it is **not** the same as a cool pair — shared-VM noise still applies, so prefer TWP÷peer ratios. +- **Gate before publishing:** optimize against cool paired ratios on the [local lab](Performance-Local-Lab). After a cool win, remeasure on matched Windows+Linux GHA (`workflow_dispatch` [RPS saturation](https://github.com/justcoding121/titanium-web-proxy/actions/workflows/rps-saturation.yml)). For wiki-grade `compare-product` / bodies / arch, dispatch **comparison-group** `arm_shard` partitions (`1/3`…`3/3`, etc.) on the **same SHA**, then paste with `-RunIds` / multi-run heavier union. Prefer TWP÷peer ratios; do not mix absolute RPS across shards. ## Laptop result tables @@ -122,11 +124,11 @@ Systematic Windows reverse inventory (published TWP RSS > YARP) ranked H2→H1 a | H2 TLS→H3 | **139** MiB | **124** MiB | **~1.12×** | **~1.26×** (20.3k / 16.1k) | | h2c→H3 | **113** MiB | **119** MiB | **~0.95×** | **~1.10×** (21.1k / 19.1k) | -GC heap post-fix ~5.4 MiB; `VolatileNode[]` collapsed to one table (~0.6 MiB) with no Node storm. **GHA paste @ `571b6fba`:** Win H2→H1 Memory ÷YARP ~**1.04×** (95 / 92 MiB); Linux ~**0.99×**. H2→H3 / h2c→H3 cool remasure ≤~1.2× Memory ÷YARP — **no further dig** on those arms (same keep). +GC heap post-fix ~5.4 MiB; `VolatileNode[]` collapsed to one table (~0.6 MiB) with no Node storm. **GHA paste @ `571b6fba`:** Win H2→H1 Memory ÷YARP ~**1.04×** (95 / 92 MiB); Linux ~**0.99×**. H2→H3 / h2c→H3 cool remeasure ≤~1.2× Memory ÷YARP — **no further dig** on those arms (same keep). Harness: `tools/RpsLoadProbe/profile-memory-arm.ps1` (mid-measure gcdump + Heap dump). Linux: `Dockerfile.mem-profile` + `profile-memory-arm.sh` in Docker with `libmsquic` (diagnosis only — publishable numbers stay GHA). -**H3→H1 cool remasure (same session, `mem-audit-h3h1-post-synth`):** TWP **183** MiB / YARP **169** MiB ≈ **1.08×** Memory ÷YARP (RPS ~10.2k / ~6.4k — YARP soft on this box). GC heaps ~10 / ~9 MiB; no unbounded dict Node storm. Published CI was ~**1.57×** — laptop gap is already under the **≥1.3×** dig gate; residual treated as MsQuic / structural. **No H3 Memory code change** this pass; confirm with GHA Block C paste. +**H3→H1 cool remeasure (same session, `mem-audit-h3h1-post-synth`):** TWP **183** MiB / YARP **169** MiB ≈ **1.08×** Memory ÷YARP (RPS ~10.2k / ~6.4k — YARP soft on this box). GC heaps ~10 / ~9 MiB; no unbounded dict Node storm. Published CI was ~**1.57×** — laptop gap is already under the **≥1.3×** dig gate; residual treated as MsQuic / structural. **No H3 Memory code change** this pass; confirm with GHA Block C paste. ### Unjustified Memory dig gate (post-syntheticStreams) @@ -183,7 +185,7 @@ Dig through nginx `src/http` (proxy + upstream keepalive), `src/event`, and `src | Write coalesce + `sendfile` / `writev` chain | `ngx_output_chain.c`, `ngx_writev_chain.c` (“coalesce the neighbouring bufs”) | `Http2FrameWriter` coalesce budget (already have); H1 is already buffered `HttpStream` writes | **Done** for H2; H1 not a syscall-storm residual | | `worker_processes` + `accept_mutex` | `nginx.c` / `ngx_event_accept.c` | N/A — native multi-process fan-out; TWP is one managed process + thread pool | **N/A** — do not fake workers for RSS/RPS games | -**Concrete managed cut worth trying now:** **N/A** for new session-lites. Portable lessons are already landed or process-model only. Decode-time H2 session skip was **reverted**; the Windows Memory audit then found and **kept** the `syntheticStreams` registry leak fix (see [Memory (RSS)](#memory-rss--h2h1-vs-h1--h3)). Next dig is residual native RSS / MsQuic after GHA remasure. +**Concrete managed cut worth trying now:** **N/A** for new session-lites. Portable lessons are already landed or process-model only. Decode-time H2 session skip was **reverted**; the Windows Memory audit then found and **kept** the `syntheticStreams` registry leak fix (see [Memory (RSS)](#memory-rss--h2h1-vs-h1--h3)). Next dig is residual native RSS / MsQuic after GHA remeasure. ## Technique 2: async dumps — find where requests wait @@ -276,17 +278,17 @@ The [architecture-sensitive](Performance-Local-Lab#architecture-sensitive) lapto | H1→H2 / H3→H2 still ≪0.80 after pool | Cool A/B + c=1 + `dumpasync`/`dotnet-trace` @ c=32 (`results/h2-origin-choke/`) | **Not** dual-TLS polish: c=1 TWP **faster** (1.49×). Residual is **outbound `Http2OriginConnection.SendAsync` queueing** (TTFB≈SendAsync wait grows 624→2263 µs c=8→32; 13 parked `SendAsync` on H3→H2; 102 `SemaphoreSlim` TaskNodes on H1→H2; managed reverse peer only ~7 in-flight forwarders). Monitor slow-path ~2× managed reverse peer | Origin `Http2FrameWriter` exclusive drain: encode+enqueue under short `writeLock`, no `WriteAsync` under the lock (reference .NET server stack model). Cool H1→H2 **0.87× @ c=32** (28,996 / 33,336, `rps-ramp-20260818-170412`/`170452`); H3→H2 **0.64× @ c=32**. TTFB p50 262→894 µs. See `h2-origin-choke/POSTFIX.md` | | H1→H2 / H3→H2 still <0.80 after origin frame writer | Cool A/B + grow A/B + gcdump/trace (`results/residual-sub08/`) | Scaling wait on origin HEADERS (c=1 **1.04×**); **grow 4→32 regresses**; ForceRead/HPACK noise; Channel/Pipe not retained-heap | Ranked in `residual-sub08/CONCLUSIONS.md` | | Monitor.Enter_Slowpath ~9.5% after frame writer | syncblk + speedscope + pool-pick diag; post-fix traces (`POSTFIX.md`) | **~70%** Monitor was `TryPick` + `ConcurrentDictionary.Count` under `entry.Gate`. **c=32:** 0% soft-miss. **c=64:** ~21% soft-miss + CreationGate at max | **Shipped A+B+C:** Interlocked `ActiveStreamCount`; skip CreationGate on Gate-held `Count >= max`; snapshot pick outside Gate. Monitor exclusive **9.5% → 3.1%**. Phase C no further win. Long-window TWP @ c=32 unchanged (~28.7k). Residual still HEADERS fan-in | -| H1→H2 still ~0.71× after pool-pick; dumpasync showed ForceRead on origin ReadLoop | Cool remasure + code path (`POSTFIX-INTAKE.md`) | Origin ReadLoop still did ForceRead 9+payload and copied HEADERS to MemoryStream; DATA awaited BodyPipe on the loop | Shared `Http2FrameIntake` on origin + in-place END_HEADERS decode + sync BodyPipe write. **ForceRead removed.** Best long cool pair this session still **0.71×** (thermally soft absolutes) — next dig is post-headers path, not another receive rewrite | -| Post-intake: is residual WriteResponse / SessionEventArgs / still HEADERS wait? | dumpasync + topN + gcdump + stage timing (`POSTFIX-POST-HEADERS.md`) | Soft box (IDE CPU); dumpasync: **no** ForceRead, **no** InterimChannel/`SendAsync` park — bridges on client `ReadRequestLine`, origin on `FrameIntake.Fill`. Stage: TTFB ~93% of total, delivery ~5%. Pooling gates not cleared | **Wait shape fixed.** Do not pool or rewrite H1 write yet. Need cool quiet remasure + high-RPS alloc/CPU sample before next code change | -| Quiet remasure after restart: does cool ratio move? Gate A/B at high RPS? | Cool pairs + dumpasync + AllocationTick (`quiet-remeasure/QUIET-REMEASURE.md`) | High perf: H1→H2 c=32 **0.71×** (31.9k/44.7k); c=64 **0.87×**. High-RPS dump: ForceRead/Interim park still **0**. AllocTick: SessionEventArgs+HeaderCollection **4.5%** (<5% Gate A). Interim channel arrays ~7%+ but gated behind A. Monitor exclusive ~2.5% | **No library change.** c=32 residual confirmed; pooling/write gates still not cleared. Optional: YARP twin AllocTick for asymmetry | -| YARP twin AllocTick + InterimChannel passthrough lite | Twin `gc-verbose` + remasure (`INTERIM-LITE.md`) | TWP ~3× AllocTicks/request vs YARP; Interim Channel/segment ~11% TWP-only. Lazy `InterimChannel` when `on1xx` null; H1→H2 passthrough skips relay when no interception | Landed. Soft post-lite pair **0.82×** (26.8k/32.5k); cool High-perf confirm blocked by IDE CPU — remeasure on quiet box before publishing ≥0.80 | +| H1→H2 still ~0.71× after pool-pick; dumpasync showed ForceRead on origin ReadLoop | Cool remeasure + code path (`POSTFIX-INTAKE.md`) | Origin ReadLoop still did ForceRead 9+payload and copied HEADERS to MemoryStream; DATA awaited BodyPipe on the loop | Shared `Http2FrameIntake` on origin + in-place END_HEADERS decode + sync BodyPipe write. **ForceRead removed.** Best long cool pair this session still **0.71×** (thermally soft absolutes) — next dig is post-headers path, not another receive rewrite | +| Post-intake: is residual WriteResponse / SessionEventArgs / still HEADERS wait? | dumpasync + topN + gcdump + stage timing (`POSTFIX-POST-HEADERS.md`) | Soft box (IDE CPU); dumpasync: **no** ForceRead, **no** InterimChannel/`SendAsync` park — bridges on client `ReadRequestLine`, origin on `FrameIntake.Fill`. Stage: TTFB ~93% of total, delivery ~5%. Pooling gates not cleared | **Wait shape fixed.** Do not pool or rewrite H1 write yet. Need cool quiet remeasure + high-RPS alloc/CPU sample before next code change | +| Quiet remeasure after restart: does cool ratio move? Gate A/B at high RPS? | Cool pairs + dumpasync + AllocationTick (`quiet-remeasure/QUIET-REMEASURE.md`) | High perf: H1→H2 c=32 **0.71×** (31.9k/44.7k); c=64 **0.87×**. High-RPS dump: ForceRead/Interim park still **0**. AllocTick: SessionEventArgs+HeaderCollection **4.5%** (<5% Gate A). Interim channel arrays ~7%+ but gated behind A. Monitor exclusive ~2.5% | **No library change.** c=32 residual confirmed; pooling/write gates still not cleared. Optional: YARP twin AllocTick for asymmetry | +| YARP twin AllocTick + InterimChannel passthrough lite | Twin `gc-verbose` + remeasure (`INTERIM-LITE.md`) | TWP ~3× AllocTicks/request vs YARP; Interim Channel/segment ~11% TWP-only. Lazy `InterimChannel` when `on1xx` null; H1→H2 passthrough skips relay when no interception | Landed. Soft post-lite pair **0.82×** (26.8k/32.5k); cool High-perf confirm blocked by IDE CPU — remeasure on quiet box before publishing ≥0.80 | | Cool confirm after InterimChannel lite | Paired c=32 High perf (`interim-lite-confirm/CONFIRM.md`) | TWP **33.6k** / YARP **44.0k** = **0.76×** (was **0.71×** pre-lite). Phase-A-class absolutes | Lite helped (~+5–7% relative) but still ≪0.80. Next: TTFB residual dig on no-intercept path | | Post-lite TTFB dig @ ~31k RPS | dumpasync `--fields` + topN (`interim-lite-confirm/TTFB-DIG.md`) | **20** `SendAsync` on origin **writeLock** (Semaphore maxCount=1, `streamOpened=false`); **6** on `HeadersReceived`; InterimChannel still 0. Lite `on1xx=null` confirmed | Residual is **writeLock stream-open convoy**, not headers wait / WriteResponse. Next: shrink work under origin writeLock (HPACK encode+enqueue) | -| H3→H2 cool remasure + gap fix plan | Cool c=32 pair (`h3h2-fresh/CONFIRM.md`) + `FIX-PLAN.md` / canvas | H3→H2 **0.70×** (26.0k/36.9k) — wiki 0.33× stale. Same origin writeLock; H3 still always allocates InterimChannel | **P0** H3 Interim lite → **P1** shrink encode under writeLock → **P2** H3 Via/prep trim → **P3** remasure other H2 arms | -| P0+P1 bundle: H3 lite, Via skip, SoftStream=2, HPACK method cache | Cool High perf (`post-p0p1/`) | H1→H2 **0.89×** (37.3k/42.0k); soft confirm **0.82×**. H3→H2 **0.73×** (24.9k/34.2k). Max-conn 16 aborted (soft regress) | **H1→H2 c=32 bar closed.** Continue H3→H2 (≥0.80) + remasure other H2 arms | +| H3→H2 cool remeasure + gap fix plan | Cool c=32 pair (`h3h2-fresh/CONFIRM.md`) + `FIX-PLAN.md` / canvas | H3→H2 **0.70×** (26.0k/36.9k) — wiki 0.33× stale. Same origin writeLock; H3 still always allocates InterimChannel | **P0** H3 Interim lite → **P1** shrink encode under writeLock → **P2** H3 Via/prep trim → **P3** remeasure other H2 arms | +| P0+P1 bundle: H3 lite, Via skip, SoftStream=2, HPACK method cache | Cool High perf (`post-p0p1/`) | H1→H2 **0.89×** (37.3k/42.0k); soft confirm **0.82×**. H3→H2 **0.73×** (24.9k/34.2k). Max-conn 16 aborted (soft regress) | **H1→H2 c=32 bar closed.** Continue H3→H2 (≥0.80) + remeasure other H2 arms | | Remeasure H2 TLS→h2c / h2c→h2c after intake+lite era | Cool High perf c=32 20s (`passthrough-fresh/`) | H2 TLS→h2c **0.78×** (51.0k/65.8k); h2c→h2c **0.73×** (49.7k/68.3k) — up from ~0.66/0.70 wiki | Still ≪0.80 on passthrough; next dig client FrameWriter/HPACK (not origin pool) | | HPACK static GetIndex bug + encode under writeLock + scheme patch | Cool High perf (`post-hpack-static/` + `post-hpack-confirm/`) | `StaticTable.GetIndex(name,value)` compared ByteString to string → never matched; EncodeHeaderBlock allocated `new Uri` under writeLock; mixed-transport scheme 0x86↔0x87 patch; SoftStream=1; skip Via on H2 response IsFastPath; skip NoOp HPACK decode on verbatim compressed relay | **H2 TLS→h2c 0.81×** (45.8k/56.2k) **closed**. H1→H2 **0.85×**. H3→H2 **0.72×**, h2c→h2c **0.74×** still open | -| OriginRelayPool SoftCap 8→1/2 fan-out | Cool remasure (`post-relay-soft1/2`) | Soft=1/2 did not beat Soft≈8 on h2c→h2c (extra cleartext legs) | **Reverted** SoftCap formula; residual is not origin-leg count | +| OriginRelayPool SoftCap 8→1/2 fan-out | Cool remeasure (`post-relay-soft1/2`) | Soft=1/2 did not beat Soft≈8 on h2c→h2c (extra cleartext legs) | **Reverted** SoftCap formula; residual is not origin-leg count | | H3→H2 dump @ 26k RPS + QPACK dict encode | dumpasync (`h3-profile/`) + QPACK O(1) static lookup | **32/32** `SendAsync` on `HeadersReceived` (not writeLock); **8** origin `ReadLoop`s. SoftStream fan-out already enough | Residual is H3 session/QPACK/bridge CPU, not origin write convoy. QPACK static dict + response header path trim shipped; cool ratio still ≪0.80 — next SessionEventArgs-lite / pool | | H3 inbound ≪ H2 / ≪ YARP H3→H3 | Cool YARP-first matrix + shape (`h3-vs-h2/`, `h3-verbatim-fair/`) | Full `SessionEventArgs` + response QPACK decode/re-encode on every H3→H3 GET; YARP cool H3→H3 ~26–28k while TWP sat ~20k (0.70×) | Session-lite for H3→H2/H3/H1 + **verbatim origin→client H3 frame relay** (H2 compressed-relay analogue). Cool H3→H3 **1.14× YARP** (29.6k / 26.0k); H3→H2 / H3→H1 ≥0.80; MITM÷cleartext **0.93** | | H3 bodiless fast path + PrepareH2 skip + EncodeResponse + compressed DATA→wire | Cool High perf (`post-encode-response/`) | Skip InterceptionContext; drain FIN without body-pump lambdas; skip PrepareH2 RemoveHeader scan on IsFastPath; `QpackEncoder.EncodeResponse` (no List); compressed-relay DATA `ReadExact` into rented wire buffer; ReturnPayload after QPACK decode | Absolutes up (H3→H2 **31.7k**/44.1k; h2c→h2c **65.7k**/91.3k) but ratios still **~0.72×**. Lazy `BoundedBodyPipe` aborted (empty-body race). Skip linked-CTS on fast path aborted (abort cancel lost → ~0.67×). Next: SessionEventArgs-lite / pool | @@ -303,26 +305,26 @@ The [architecture-sensitive](Performance-Local-Lab#architecture-sensitive) lapto | Cool H3→H1 ~0.36× peer (12.1k / 33.4k) | Cool pair + trace @ c=32 | **Invalid ratio**: TWP `quic-http3` vs peer HttpClient. Trace was session/`HandleAsync`, not MsQuic-native | Match clients; later dual-listen reverse H3 enables **HttpClient both sides** (`matched-httpclient-h3/`, H3→H1 ≈ **0.87**) | | H3→H1 integ empty body; Windows ~0.79× YARP | DualListen / ForcedHttp11Origin + cool pair | Fast path buffered only known `Content-Length`; Kestrel `WriteAsync` often **chunked** → body never drained before pool Release; H1 Title-Case names paid QPACK `ToLower` every response | Drain chunked/connection-close via `LimitedStream` before Release; `NormalizeNamesToLowerAscii` + `HeaderNamesAreHttp2Normalized`; decode H2 HEADERS into Response headers (no second collection). Cool Windows H3→H1 ≈ **0.96×**, H3→H2 ≈ **1.06×**; Linux H3→H1/H2/H3 ≈ **1.04× / 1.15× / 1.20×** ([32552296839](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32552296839), [32552295495](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32552295495)) | | H1→H3 “name normalize + tiny-body coalesce” looked like H1→H2 gap | Cool A/B (`win-parity-audit-20260822-*`) | Hypothesis: Title-Case QPACK tax + missing H1→H2-style fast commit / TLS coalesce | Name-normalize path **fully reverted** (~**1.13× → 0.65×**). Eager-buffer alone also poisoned the pool — see next row. | -| H1→H3 Win CI ~0.94×; H1 client + StreamBodyWriter = header-only TLS record | Cool A/B + dispose/RST dig (`cool-h3-origin-eager64-drain-20260823/`) | Known-CL ≤64 KiB H3 origin bodies streamed via `StreamBodyWriter` → H1 `WriteResponseAsync` then body (same class as lossy H1). First eager-buffer attempt disposed the Quic stream before FIN → **RST / pool poison** (~1.16×→~0.7×) | Eager-buffer ≤64 KiB **and drain frames to FIN** before `DisposeAsync` in `ForwardOverQuicAsync`. Cool TY/YT ≈ **1.23× / 1.03×**. CI remasure bridges next. | +| H1→H3 Win CI ~0.94×; H1 client + StreamBodyWriter = header-only TLS record | Cool A/B + dispose/RST dig (`cool-h3-origin-eager64-drain-20260823/`) | Known-CL ≤64 KiB H3 origin bodies streamed via `StreamBodyWriter` → H1 `WriteResponseAsync` then body (same class as lossy H1). First eager-buffer attempt disposed the Quic stream before FIN → **RST / pool poison** (~1.16×→~0.7×) | Eager-buffer ≤64 KiB **and drain frames to FIN** before `DisposeAsync` in `ForwardOverQuicAsync`. Cool TY/YT ≈ **1.23× / 1.03×**. CI remeasure bridges next. | | H2→H1 64 KiB ~0.87× YARP (tiny-GET already parity) | Cool pair + code compare vs Kestrel/YARP | Streamed path stripped Content-Length then empty END_STREAM DATA; pump wrote 8 KiB fills → 8 DATA frames + trailer; `HttpStream` double-buffered socket→8 KiB→dest; QueueDataFrame + 32 KiB flatten | Keep CL + END_STREAM on last DATA; `HttpStream` large-read bypass; in-place DATA framing (flatten **kept**); skip LimitedStream/Via on known-CL fast path; raise flatten budget to **288 KiB**. Cool 64 KiB ≈ **1.13×**; 256 KiB ≈ **0.89×**. Dropping flatten alone still ~0.65× | | H2 POST cool ~0.88× / 256 KiB H2→H1 ~0.90× | Shape c=1 vs c=32 + YARP `StreamCopier` (64 KiB) compare | c=1 TWP **leads** POST (~1.2×); c=32 loses when YARP healthy — multiplex tax (frame-loop copy + shared client writer). Extra body memcpy / coalesce experiments | **Kept:** ArrayPool request-body channel + `TryReserve` on `CopyFromAsync`. **Do not:** reserve >1 frame before enqueue; slice control frames into coalesced DATA; drop flatten | | H3 early-response Win CI ~0.76× (Linux already ~1.02×) | Cool A/B (`fix-early-tls/`) + origin/YARP duplex compare | `ForwardOverTcpAsync` wrote the full request body before `ReceiveResponse` while the probe origin overlaps after 8 KiB (YARP `StreamCopier` same). H3+MsQuic amplifies the serialization on Windows | Overlap streamed upload with `ReceiveResponse`; fold remaining upload into `StreamBodyWriter` via `Task.WhenAll`. Cool mean ≈ **1.21×** YARP. Do **not** re-land Http3Frame coalesce 256→16 KiB (hurt POST) | | Duplex H2 Win CI ~0.63× / Linux ~0.31× (`compare-arch`) | Code path + CI medians [32688089789](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32688089789); short local cool noisy | H2 TLS↔H2 TLS overlapping 64 KiB POST. Interception-off reverse already concurrent-relays frames both ways (not H1 sequential; not the H3 pre-overlap `ForwardOverTcpAsync` bug). YARP `HttpForwarder`/`StreamCopier` + Kestrel still leads; TWP CPU underutilized vs YARP on the cell | **No product cut.** Document as irreducible YARP-led concurrent-copier cell; keep published CI ratios. Do **not** port Kestrel/`StreamCopier` for this row alone. | -| H3→H1 64 KiB GET Win CI ~0.56× / Linux ~0.82× | Cool A/B (`h3-64k-rebaseline/`) + CI remasure | Cool mean ≈ **1.13×** (3118/2688 & 3488/3181); stale CI was pre-`StreamBodyWriter` | No library change. Publishable [32611185635](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32611185635) @ `cd276c83`: Win ≈ **1.15×** (3,752 / 3,269), Linux ≈ **1.25×** (5,295 / 4,247). Next body gap: Win H1 TLS 256 KiB ≈ **0.85×** | +| H3→H1 64 KiB GET Win CI ~0.56× / Linux ~0.82× | Cool A/B (`h3-64k-rebaseline/`) + CI remeasure | Cool mean ≈ **1.13×** (3118/2688 & 3488/3181); stale CI was pre-`StreamBodyWriter` | No library change. Publishable [32611185635](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32611185635) @ `cd276c83`: Win ≈ **1.15×** (3,752 / 3,269), Linux ≈ **1.25×** (5,295 / 4,247). Next body gap: Win H1 TLS 256 KiB ≈ **0.85×** | | H1 TLS→H1 256 KiB Win CI ~0.85× | Cool A/B + shape (`h1-256k-cool/`) + YARP StreamCopier compare | Cool c=1 ≈ **0.83×** (per-request); `CopyBytesToStream` FillBuffer’d **8 KiB** forever — H2 large-read bypass never ran on H1 known-CL copy | Rent **64 KiB** + `ReadAsync` when parser window empty (`HttpStream.CopyBytesToStream`, `106e73b9`). Cool c=1 ≈ **1.16×**, c=32 ≈ **1.09×**. Publishable [32614286032](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32614286032): Win ≈ **1.12×** (2,617 / 2,347). | | H1 TLS new-conn Win CI ~0.84x (Linux TWP leads) | Cool A/B + Kestrel SocketConnectionListener / ConnectionDispatcher + bare ceiling | Nested SslStream + ClientHello peek + ECDSA + Task.Run + BeginAccept APM + per-accept linger/timeouts + RetryPolicy closures; **lite path forwarded `Connection: close` to origin** → no origin pool under NC | Peek/unwrap/RSA/no-keepalive; abortive SO_LINGER(0) on close; AcceptAsync; CTS pool; session-lite; WaitForData-before-SslStream; 8 KiB rent. AcceptIOQueue **no win**. Bare NC `Connection: close` response-skip fixed. **Strip hop-by-hop Connection before origin write** on H1 terminate lite. Publishable [32625349927](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32625349927) @ `13059143`: Win NC ≈ **1.01×**, Linux NC ≈ **1.01×** YARP (nginx 1st on Linux NC — TWP 2nd). | | H1→H3 100% err after session-lite (`03159694`) | Bisect `11e32f1c`→`03159694` + curl serve | H1 terminate lite gated only on `ForwardHost` + bodiless GET — **H1→H3/H2** with forced upstream H3/H2 took TCP H1 lite against QUIC/h2-only origins | Skip session-lite when connection-level `UpstreamHttpProtocol` is Http2/Http3 (`62e5efcd`). Soft coolish H1→H3 ≈ **1.25×**, h2c→H1 ≈ **1.05×**, H3→H1 ≈ **1.09×**. | -| WarmTls H1→H3 + CachedServerAuthOptions gate broke H2 reverse | Local lossy H2 + ALPN fail (`No common application protocol`) | Expanding fixed-cert to any warmed `CachedServerAuthOptions` pinned **http/1.1-only ALPN** while H2 clients offer `h2` | Gate fixed-cert on `!EnableHttp2` only (H3 clients use QuicListener). H1→H3 host: `EnableHttp2=false` + WarmTls (`8ac422ee`). Cool H1→H3 ≈ **1.16×**; remasure bridges/bodies/lossy @ tip. | +| WarmTls H1→H3 + CachedServerAuthOptions gate broke H2 reverse | Local lossy H2 + ALPN fail (`No common application protocol`) | Expanding fixed-cert to any warmed `CachedServerAuthOptions` pinned **http/1.1-only ALPN** while H2 clients offer `h2` | Gate fixed-cert on `!EnableHttp2` only (H3 clients use QuicListener). H1→H3 host: `EnableHttp2=false` + WarmTls (`8ac422ee`). Cool H1→H3 ≈ **1.16×**; remeasure bridges/bodies/lossy @ tip. | | H2/H3→H1 64 KiB / lossy H2 still tax many DATA fills | Code compare vs H1 ≤64 KiB coalesce | H2→H1 / H3→H1 eager-buffer capped at **16 KiB** while lossy/bodies GET is **64 KiB** → stream via ~4× 16 KiB fills (shim delayMs per read) | Raise eager known-CL threshold to **64 KiB** (`Http2ToHttp11BridgeHandler` + `Http3OriginBridge`, `8ac422ee`). CI bodies Win H3→H1 64 KiB ≈ **1.09×** ([32631121563](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32631121563)). | | H3→H1 tiny Win CI ~0.90×; cool order-noisy ~0.94–1.02× | Cool both-order dig + HEADERS+DATA coalesce experiment | `SendResponseAsync` does separate QuicStream writes for HEADERS then DATA + Flush | **Reverted** single-write HEADERS+DATA coalesce (`cool-h3-headers-data-coalesce-20260823/`): cool mean ≈ **0.96×** (no win). | -| H3→H1 c=1 leads ~1.25×; c=32 loses (~0.96×) — multiplex shape | Cool c=1 both orders + `dumpasync` @ c=32 (`dig-h3h1-dump2-20260823/`) + SampleProfiler | **32/32** parked on origin `ReceiveResponse`→`FillBuffer` (not writeLock/SoftCap). CPU: both TWP and YARP ~60% `LowLevelLifoSemaphore` wait — not a unique ThreadPool starve. QPACK/Normalize ≪1% exclusive | Next: cycle-time after status line (body buffer → QPACK → Quic write → pool Release) vs YARP `HttpForwarder`; optional AllocTick asymmetry. Soft remasure @ `8e5c181b` (`cool-h3h1-shape-20260823/`): c=1 ≈ **1.60×**, c=32 ≈ **1.07×** — CI Win still ~0.89× @ prior tip; remasure bridges in flight. | +| H3→H1 c=1 leads ~1.25×; c=32 loses (~0.96×) — multiplex shape | Cool c=1 both orders + `dumpasync` @ c=32 (`dig-h3h1-dump2-20260823/`) + SampleProfiler | **32/32** parked on origin `ReceiveResponse`→`FillBuffer` (not writeLock/SoftCap). CPU: both TWP and YARP ~60% `LowLevelLifoSemaphore` wait — not a unique ThreadPool starve. QPACK/Normalize ≪1% exclusive | Next: cycle-time after status line (body buffer → QPACK → Quic write → pool Release) vs YARP `HttpForwarder`; optional AllocTick asymmetry. Soft remeasure @ `8e5c181b` (`cool-h3h1-shape-20260823/`): c=1 ≈ **1.60×**, c=32 ≈ **1.07×** — CI Win still ~0.89× @ prior tip; remeasure bridges in flight. | | H3→H1 early origin Release / skip client Flush before CompleteWrites | Cool A/B (`cool-h3h1-early-release-20260823/`, `cool-h3h1-skip-flush-20260823/`) | Hypothesis: free H1 socket sooner / avoid MsQuic Flush tax on fast path | **No cool win** (~0.95–0.98×). Reverted both. | | H1→H3 / h2c→H3 Win CI closed @ `8789d6de` | CI bridges [32636039240](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32636039240) | Eager ≤64 KiB H3 origin body + FIN drain before Dispose | Win H1→H3 ≈ **1.04×**, h2c→H3 ≈ **1.05×**. Still open: Win h2c→H1 ≈ **0.96×**, H3→H1 ≈ **0.89×**; lossy H2 16 vs 17. | | H3→H1 ForwardHost Host rewrite (match YARP HttpForwarder) | Cool A/B (`cool-h3h1-host-rewrite-20260823/`) | Hypothesis: :authority `localhost:` on H1 Host wire vs origin `127.0.0.1:` | **No cool win** (~0.94–0.99×). Reverted. | -| h2c→H1 early origin Release before EmitSynthetic | Cool lead ~1.03×; CI remasure [32638840153](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32638840153) @ `253e8716` | Hypothesis: free H1 socket before H2 frame emit | **CI miss**: Win h2c→H1 still ~**0.95×**; **Lin h2c→H1 regressed** ~1.03×→**0.96×**. Reverted. | +| h2c→H1 early origin Release before EmitSynthetic | Cool lead ~1.03×; CI remeasure [32638840153](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32638840153) @ `253e8716` | Hypothesis: free H1 socket before H2 frame emit | **CI miss**: Win h2c→H1 still ~**0.95×**; **Lin h2c→H1 regressed** ~1.03×→**0.96×**. Reverted. | | h2c→H1 ForwardHost Host rewrite | Cool A/B (`cool-h2ch1-fwdhost2-20260823/`) | Same Host rewrite on H2→H1 bridge | **No clear cool win** (YT ~0.97×). Reverted. | -| Lossy H1 Win remasure @ `253e8716` ≈ **1.00×** (662/662) | CI lossy [32638842839](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32638842839) | Prior ~0.99× noise | Closed **≥1.00×** (nginx 1st 634 — TWP 2nd). Win lossy H2 still 16 vs 17. | -| Lossy H2 HOL: NullOriginStream lacked SETTINGS_MAX_CONCURRENT_STREAMS | Cool + CI lossy [32643126466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32643126466); bridges tax @ same tip | Hardcoding MaxStreams=8 on NullOriginStream closed lossy (Win **3.47×**) but tax'd Win tiny-GET h2c→H1 / H2→H1 (~0.90×) via extra TCP handshakes at c=64. | **Landed** lossy-only: probe sets `ResourceLimits.MaxConcurrentStreamsPerConnection=8` when `IsLossy` (`WithMaxConcurrentStreams…`); Http2Helper appends SETTINGS. Tiny-GET keeps default 256. Cool lossy **29/15**; cool h2c ≈ **1.04×**. Remasure lossy+bridges. Dispose harden on `TcpClientConnection` kept. | +| Lossy H1 Win remeasure @ `253e8716` ≈ **1.00×** (662/662) | CI lossy [32638842839](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32638842839) | Prior ~0.99× noise | Closed **≥1.00×** (nginx 1st 634 — TWP 2nd). Win lossy H2 still 16 vs 17. | +| Lossy H2 HOL: NullOriginStream lacked SETTINGS_MAX_CONCURRENT_STREAMS | Cool + CI lossy [32643126466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32643126466); bridges tax @ same tip | Hardcoding MaxStreams=8 on NullOriginStream closed lossy (Win **3.47×**) but tax'd Win tiny-GET h2c→H1 / H2→H1 (~0.90×) via extra TCP handshakes at c=64. | **Landed** lossy-only: probe sets `ResourceLimits.MaxConcurrentStreamsPerConnection=8` when `IsLossy` (`WithMaxConcurrentStreams…`); Http2Helper appends SETTINGS. Tiny-GET keeps default 256. Cool lossy **29/15**; cool h2c ≈ **1.04×**. Remeasure lossy+bridges. Dispose harden on `TcpClientConnection` kept. | | Lossy H1 Win cool ~0.86× (p50 +16 ms vs YARP) | Cool A/B + userspace delay shim analysis | Fast-path `WriteResponse` then `CopyBody` emitted a **header-only TLS record** before body; shim pays `delayMs` per read → ~3 extra 5 ms trips | Materialize known-CL ≤64 KiB on fast path + coalesce headers+body (`bc768069`). Cool ≈ **1.00×**; CI [32620889168](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32620889168): Win **663/664**, Linux **1199/1196**. | | GHA `compare-post`/`compare-arch` failed; laptop H3 POST/slow passed | Failed run logs ([32602145518](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32602145518), [32602146550](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32602146550)) | (1) Dual-listen: TCP ephemeral then QUIC UDP same port → Windows `WSAEADDRINUSE` when UDP busy/excluded. (2) Incomplete `StreamBodyWriter` + `DataAvailable==0` pooled origin sockets with unread CL → next request `H3_INTERNAL_ERROR` (HeadersRead slow-consumer + warmup cancel amplifies on 4 vCPU) | Retry ephemeral TCP+QUIC bind in `ProxyServer.Start`/`AddEndPoint`; always close origin on incomplete StreamBodyWriter; YARP/nginx dual-stack free-port pick | | H3→H1 latency bundle (skip drain / skip Flush / HEADERS+DATA coalesce) | Cool absolute win (`cool-h3h1-latency-bundle-20260823/`) + CI bridges [32652931261](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32652931261) @ `3f948409` | Cool c=64 TWP ~25k (tip ~20–23k); laptop YARP ~30k → cool TY ~0.85×. Trace @ c=64: p50 gap not exclusive CPU. | **CI miss**: Win H3→H1 **0.92× → ~0.87×** (13,391 / 15,444). Lin H3→H1 still leads (~1.11×). Lossy Lin H3 improved ~0.76×→~**0.89×** (278/314) but still <1.00×. **Reverted** (`2bf18d75`). | diff --git a/wiki/Performance.md b/wiki/Performance.md index 587e02703..6fbac348e 100644 --- a/wiki/Performance.md +++ b/wiki/Performance.md @@ -1,59 +1,55 @@ # Performance -Titanium targets **low-overhead MITM proxying**: connection pooling, HTTP/2 multiplexing, and buffer reuse. Numbers below are **Release** measurements with [RpsLoadProbe](https://github.com/justcoding121/titanium-web-proxy/tree/develop/tools/RpsLoadProbe) (and BenchmarkDotNet / Basic example where noted). Publishable tables cite **GitHub Actions** medians on matched **4-core-class** runners (`ubuntu-latest` / `windows-latest`: **4 vCPU / 16 GiB**; `macos-15-intel`: **4-core / 14 GB**). Do not publish from `macos-latest` (3-core / 7 GB). Absolute RPS still varies by OS kernel, TLS, and MsQuic packaging — compare **within a table**, not across Windows vs Linux vs macOS. +Throughput and footprint of **Titanium** as a reverse / edge proxy and as a decrypting (**MITM**) proxy, measured on the same harness against **YARP**, **nginx**, **HAProxy**, and **Envoy** where each OS can run them. -Control arms: **nginx** (native C reverse-proxy ceiling; Linux is authoritative) and **YARP** (`Yarp.ReverseProxy`, managed .NET reverse proxy). Neither can MITM (no CONNECT / forged certs). FiddlerCore is not compared (commercial debugger license; not a throughput peer). +**RPS** is requests per second (gRPC tables use **RPC/s**). Numbers are Release builds on matched GitHub-hosted runners: Windows and Linux at **4 vCPU / 16 GiB**, macOS at **`macos-15-intel` 4-core / 14 GB**. Read within one table — absolute RPS is not comparable across operating systems. *Not possible* means that product cannot run that path on that OS; *Not measured* means the path exists but no published number yet. -For pooling knobs and certificate first-visit tuning, see [Performance and pooling](Home#performance-and-pooling). For the local cool A/B lab, laptop tables, and profiling notes, see [Performance Profiling](Performance-Profiling). +For pooling knobs and certificate first-visit tuning, see [Performance and pooling](Home#performance-and-pooling). Laptop cool A/B tables (not publishable) live on [Performance Local Lab](Performance-Local-Lab). + +## Why this comparison is fair + +- Same load generator, same origin process, and the same warmup / measure windows (2s / 8s) with the same concurrency ramp (8, 16, 32, 64). +- Every reverse arm is three OS processes: load generator + origin + proxy. Origin-direct omits the proxy; peers are never in-process with the client. +- Same runner class per table (`windows-latest` / `ubuntu-latest` / `macos-15-intel`). Laptop numbers are never mixed into these tables. +- Peers use equivalent TLS/ALPN and streaming-friendly settings on the same loopback shape. HAProxy and Envoy are Linux/macOS only — Windows cells are *Not possible*. +- MITM (HTTPS decryption with forged certificates) is Titanium-only; peers cannot MITM. Those tables show Titanium MITM overhead versus its own reverse path on the same wires. +- **Tiny keep-alive GET** (~56-byte JSON) is the industry RPS shape (same class as wrk / TechEmpower). It is also real for small JSON APIs and health checks. +- **Same-protocol H2↔H2 / H3↔H3** on that shape is Titanium’s **best case**: with interception off, Titanium copies frames instead of decoding and re-encoding headers (peers do a full HTTP decode). Medals there are not the typical reverse-proxy job. +- **Typical reverse** is H1 TLS→H1 or H2→H1 (~1.1× YARP on tiny GET). With **larger bodies**, see [Heavier reverse](#heavier-reverse-workloads): at 64 KiB H2 TLS→H2 TLS, Titanium is **behind** YARP (~0.69–0.76×). + +## How to read the tables + +- **Sustain** = last concurrency that still met error/latency SLOs. **Peak** = highest RPS in that ramp. +- 🥇 = best among Titanium / nginx / YARP on Reverse rows (highest RPS; on a tie, lower memory then lower CPU%). Gold medals are **per cell on tiny GET** — an H2↔H2 gold is that frame-copy best case, not “Titanium is 1.7× on all reverse.” For larger-body H2→H2, see the [heavier tables](#heavier-reverse-workloads). +- **MITM** tables are Titanium-only. **Lite÷Reverse** / **Full÷Reverse** = Titanium MITM sustain ÷ Titanium reverse sustain on the same Client×Origin pair — the overhead of decrypting and intercepting versus bare reverse, not versus nginx or YARP. +- *Not possible* = cannot do that path. *Not measured* = path exists but no published number yet. ## Contents +- [Why this comparison is fair](#why-this-comparison-is-fair) +- [How to read the tables](#how-to-read-the-tables) - [Measurement environment](#measurement-environment) - [Windows (GitHub-hosted `windows-latest`)](#windows-github-hosted-windows-latest) - [Linux (GitHub-hosted `ubuntu-latest`)](#linux-github-hosted-ubuntu-latest) - [macOS (GitHub-hosted `macos-15-intel`)](#macos-github-hosted-macos-15-intel) - - [Tiered cadence](#tiered-cadence) - [Saturation control](#saturation-control) -- [Windows — Titanium vs nginx vs YARP](#windows--titanium-vs-nginx-vs-yarp) -- [Linux — Titanium vs nginx vs YARP](#linux--titanium-vs-nginx-vs-yarp) - - [Tiny JSON reverse is nginx’s best case on Linux](#tiny-json-reverse-is-nginxs-best-case-on-linux) - - [Why isn’t HTTP/3 > HTTP/2 > HTTP/1 in raw RPS?](#why-isnt-http3--http2--http1-in-raw-rps) -- [macOS — Titanium vs nginx vs YARP](#macos--titanium-vs-nginx-vs-yarp) - - [Reverse](#reverse-2) - - [MITM (TWP only)](#mitm-twp-only-2) +- [Windows — Titanium vs nginx vs HAProxy vs Envoy vs YARP](#windows--titanium-vs-nginx-vs-haproxy-vs-envoy-vs-yarp) +- [Linux — Titanium vs nginx vs HAProxy vs Envoy vs YARP](#linux--titanium-vs-nginx-vs-haproxy-vs-envoy-vs-yarp) +- [macOS — Titanium vs nginx vs HAProxy vs Envoy vs YARP](#macos--titanium-vs-nginx-vs-haproxy-vs-envoy-vs-yarp) - [Editions (CLI / Plus / Intercept)](#editions-cli--plus--intercept) - [Cross-version (7.0 vs 6.0)](#cross-version-70-vs-60) - [Heavier reverse workloads](#heavier-reverse-workloads) - - [Windows — heavier reverse GET (64 KiB / 256 KiB)](#windows--heavier-reverse-get-64-kib--256-kib) - - [Linux — heavier reverse GET (64 KiB / 256 KiB)](#linux--heavier-reverse-get-64-kib--256-kib) - - [Windows — POST 64 KiB request + 64 KiB response](#windows--post-64-kib-request--64-kib-response) - - [Linux — POST 64 KiB request + 64 KiB response](#linux--post-64-kib-request--64-kib-response) - - [Windows — lossy / high-RTT (H2 HOL / H3 loss)](#windows--lossy--high-rtt-h2-hol--h3-loss) - - [Linux — lossy / high-RTT (H2 HOL / H3 loss)](#linux--lossy--high-rtt-h2-hol--h3-loss) - - [Architecture-sensitive](#architecture-sensitive) - - [TLS termination cost (H1 TLS → cleartext origin)](#tls-termination-cost-h1-tls--cleartext-origin) +- [Unary gRPC (H2 TLS)](#unary-grpc-h2-tls) - [Other measurements](#other-measurements) - [Raising limits on large hosts](#raising-limits-on-large-hosts) +- [Maintainer notes](#maintainer-notes) ## Measurement environment -All three OS use the **4-core-class** public-repo GitHub-hosted runners: **Windows / Linux** at **4 vCPU / 16 GiB / 14 GB SSD**, **macOS** at **`macos-15-intel` 4-core / 14 GB** (not `macos-latest`). Same harness knobs (`workflow_dispatch` [RPS saturation](https://github.com/justcoding121/titanium-web-proxy/actions/workflows/rps-saturation.yml): warmup 2s / measure 8s; concurrency 8, 16, 32, 64; median of 3 repeats; `--stop-on-slo-fail` default on). Every `--ramp` arm is **three OS processes** (parent load generator + origin child + proxy child), except **origin-direct** arms (load gen + origin only). Prefer **TWP÷YARP** / **TWP÷nginx** ratios over absolute RPS. +All three OS use the **4-core-class** public-repo GitHub-hosted runners: **Windows / Linux** at **4 vCPU / 16 GiB / 14 GB SSD**, **macOS** at **`macos-15-intel` 4-core / 14 GB** (not `macos-latest`). Same harness knobs: warmup 2s / measure 8s; concurrency 8, 16, 32, 64; median of 3 repeats. Prefer Titanium÷YARP / Titanium÷nginx ratios over absolute RPS. Laptop High-perf / cool-paired Windows numbers live on [Performance Local Lab](Performance-Local-Lab). Do not mix those absolutes into the tables below. -### Tiered cadence - -| Tier | Mode | When | -|------|------|------| -| Daily / per-PR | `compare-spot` | minutes | -| Milestone | `compare-terminate` / `compare-matrix` | ~1–2h | -| Editions | `compare-editions` | ~60 min (CLI / Plus / Intercept stress arms) | -| Cross-version (Gate 2) | `compare-cross-version` | ~1–2h vs committed 6.0 baselines | -| Release / wiki | `compare-product` | ~3–4h | -| Heavier tables | `compare-bodies` / `post` / `lossy` / `arch` / `bridges` / `tls-cost` | dispatch independently | - -See [PERF-GATES.md](https://github.com/justcoding121/titanium-web-proxy/blob/develop/tools/RpsLoadProbe/PERF-GATES.md). - ### Windows (GitHub-hosted `windows-latest`) | | | @@ -63,6 +59,8 @@ See [PERF-GATES.md](https://github.com/justcoding121/titanium-web-proxy/blob/dev | RAM | **16** GiB | | Runtime | .NET 10.0.x | | nginx | nginx/Windows **1.31.3** (same-OS only; no QUIC) | +| HAProxy | *Not possible* on Windows (no official port) | +| Envoy | *Not possible* on Windows (upstream discontinued Windows builds) | | YARP | Yarp.ReverseProxy **2.3.0** | | Harness | RpsLoadProbe Release; median of 3 repeats | @@ -75,6 +73,8 @@ See [PERF-GATES.md](https://github.com/justcoding121/titanium-web-proxy/blob/dev | RAM | **16** GiB | | Runtime | .NET 10.0.11 | | nginx | nginx/**1.31.4** (nginx.org mainline, `--with-http_v3_module`) | +| HAProxy | HAProxy **3.2.23** built with `USE_QUIC` (GHA; Ubuntu distro 2.8 is not QUIC-capable) | +| Envoy | pinned GitHub release static binary **1.36.7** (HTTP/3 compiled in) | | YARP | Yarp.ReverseProxy **2.3.0** | | Harness | RpsLoadProbe Release; median of 3 repeats where noted | @@ -87,6 +87,8 @@ See [PERF-GATES.md](https://github.com/justcoding121/titanium-web-proxy/blob/dev | RAM | **14** GB | | Runtime | .NET 10.0.x | | nginx | Homebrew nginx with `--with-http_v3_module` (workflow fails if missing) | +| HAProxy | Homebrew `haproxy` with `USE_QUIC` (workflow fails if missing; 3.2.23 osx source fallback) | +| Envoy | Homebrew bottle when present; else pinned darwin-amd64 **1.36.7** (official GitHub assets are Linux-only). HTTP/3 compiled in. | | MsQuic | Homebrew `libmsquic` + `openssl@3` on `DYLD_LIBRARY_PATH` / `DYLD_FALLBACK_LIBRARY_PATH` (`QuicListener.IsSupported`) | | YARP | Yarp.ReverseProxy **2.3.0** | | Harness | RpsLoadProbe Release; median of 3 repeats where noted | @@ -95,13 +97,7 @@ Do **not** use `macos-latest` (Apple Silicon, 3-core / 7 GB) for publishable sat ### Saturation control -Calibration for the shared 4 vCPU loopback shape: how close client + origin are to saturated before ranking reverse peers. Tiny keep-alive GET. Median of **3** repeats @ `9d7c2966` — [32866709227](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32866709227). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Block A **% of origin-HttpClient** uses median **peak** RPS. Blocks B/C use peer÷YARP / ÷nginx on median peak (not % of H1 origin). **RPS cells** embed median RSS / CPU for the **proxy child** plus its **full descendant tree** (serve-proxy → nginx master → workers); origin-direct samples the **origin** child. Product matrices below use matched `dotnet-httpclient` only (not bombardier). **H3→H1** (saturation Block C): Win TWP RSS **103** MiB vs YARP **119** (~**0.87×**); Linux **142** vs **181** (~**0.78×**). RPS vs YARP (**0.99×** / **1.1×**). - - - -```powershell -pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-saturation -``` +Calibration for the shared 4 vCPU loopback shape: how close client + origin are to saturated before ranking reverse peers. Tiny keep-alive GET. Median of **3** repeats @ `9a2b3a1e` — [34441539402](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441539402). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Block A **% of origin-HttpClient** uses median **peak** RPS. Blocks B/C use peer÷YARP / ÷nginx on median peak (not % of H1 origin). **RPS cells** embed median RSS / CPU for the **proxy child** plus its **full descendant tree** (serve-proxy → nginx master → workers); origin-direct samples the **origin** child. Product matrices below use matched `dotnet-httpclient` only (not bombardier). #### Block A — H1 plain @@ -116,23 +112,23 @@ pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-saturation | Arm | Generator | Sustain | Peak | % of origin-HttpClient | |---|---|---:|---:|---:| -| origin-direct | dotnet-httpclient | **50,717**
                          (53 MiB / 41.6% CPU) | **50,717**
                          (53 MiB / 41.6% CPU) | **100.0%** | -| origin-direct-bombardier | bombardier | **38,335**
                          (55 MiB / 22.7% CPU) | **38,335**
                          (55 MiB / 22.7% CPU) | **75.6%** | -| bare-reverse-http1 | dotnet-httpclient | **25,467**
                          (55 MiB / 46.5% CPU) | **25,467**
                          (55 MiB / 46.5% CPU) | **50.2%** | -| nginx-reverse-http1 | dotnet-httpclient | **13,259**
                          (121 MiB / 25.0% CPU) | **13,422**
                          (121 MiB / 25.0% CPU) | **26.5%** | -| yarp-reverse-http1 | dotnet-httpclient | **21,331**
                          (89 MiB / 49.9% CPU) | **21,331**
                          (89 MiB / 49.9% CPU) | **42.1%** | -| twp-reverse-http1 | dotnet-httpclient | 🥇 **25,358**
                          (75 MiB / 49.2% CPU) | **25,358**
                          (75 MiB / 49.2% CPU) | **50.0%** | +| origin-direct | dotnet-httpclient | **50,319**
                          (55 MiB / 40.4% CPU) | **50,319**
                          (55 MiB / 40.4% CPU) | **100.0%** | +| origin-direct-bombardier | bombardier | **38,565**
                          (56 MiB / 21.8% CPU) | **38,565**
                          (56 MiB / 21.8% CPU) | **76.6%** | +| bare-reverse-http1 | dotnet-httpclient | **25,016**
                          (58 MiB / 43.8% CPU) | **25,016**
                          (58 MiB / 43.8% CPU) | **49.7%** | +| nginx-reverse-http1 | dotnet-httpclient | **13,405**
                          (125 MiB / 24.8% CPU) | **13,405**
                          (125 MiB / 24.8% CPU) | **26.6%** | +| yarp-reverse-http1 | dotnet-httpclient | **20,802**
                          (87 MiB / 49.0% CPU) | **20,802**
                          (87 MiB / 49.0% CPU) | **41.3%** | +| twp-reverse-http1 | dotnet-httpclient | 🥇 **24,558**
                          (75 MiB / 48.0% CPU) | **24,558**
                          (75 MiB / 48.0% CPU) | **48.8%** | **Linux** (`ubuntu-latest`) | Arm | Generator | Sustain | Peak | % of origin-HttpClient | |---|---|---:|---:|---:| -| origin-direct | dotnet-httpclient | **68,992**
                          (78 MiB / 41.8% CPU) | **68,992**
                          (78 MiB / 41.8% CPU) | **100.0%** | -| origin-direct-bombardier | bombardier | **41,996**
                          (78 MiB / 34.4% CPU) | **41,996**
                          (78 MiB / 34.4% CPU) | **60.9%** | -| bare-reverse-http1 | dotnet-httpclient | **32,077**
                          (60 MiB / 45.0% CPU) | **32,077**
                          (60 MiB / 45.0% CPU) | **46.5%** | -| nginx-reverse-http1 | dotnet-httpclient | 🥇 **38,039**
                          (72 MiB / 40.9% CPU) | **38,039**
                          (72 MiB / 40.9% CPU) | **55.1%** | -| yarp-reverse-http1 | dotnet-httpclient | **26,736**
                          (113 MiB / 49.2% CPU) | **26,736**
                          (113 MiB / 49.2% CPU) | **38.8%** | -| twp-reverse-http1 | dotnet-httpclient | **31,431**
                          (84 MiB / 50.0% CPU) | **31,431**
                          (84 MiB / 50.0% CPU) | **45.6%** | +| origin-direct | dotnet-httpclient | **102,038**
                          (80 MiB / 44.2% CPU) | **102,038**
                          (80 MiB / 44.2% CPU) | **100.0%** | +| origin-direct-bombardier | bombardier | **61,434**
                          (80 MiB / 37.4% CPU) | **61,434**
                          (80 MiB / 37.4% CPU) | **60.2%** | +| bare-reverse-http1 | dotnet-httpclient | **46,442**
                          (70 MiB / 46.0% CPU) | **46,442**
                          (70 MiB / 46.0% CPU) | **45.5%** | +| nginx-reverse-http1 | dotnet-httpclient | 🥇 **56,585**
                          (76 MiB / 40.3% CPU) | **56,585**
                          (76 MiB / 40.3% CPU) | **55.5%** | +| yarp-reverse-http1 | dotnet-httpclient | **41,835**
                          (116 MiB / 49.3% CPU) | **41,835**
                          (116 MiB / 49.3% CPU) | **41.0%** | +| twp-reverse-http1 | dotnet-httpclient | **47,721**
                          (95 MiB / 50.9% CPU) | **47,721**
                          (95 MiB / 50.9% CPU) | **46.8%** | Reverse peers are about **50–46%** of the origin-direct HttpClient peak on this runner class (Win TWP **50.0%**, Lin TWP **45.6%**). Prefer the **%** column over absolute RPS across runs. Bare and origin-direct are controls (not medal peers). @@ -144,284 +140,257 @@ Peer ratios (÷YARP / ÷nginx) on median peak; **RPS cells** embed `(MiB / CPU%) | Arm | Generator | Sustain | Peak | ÷YARP | ÷nginx | |---|---|---:|---:|---:|---:| -| nginx-reverse-http2 | dotnet-httpclient | **8,264**
                          (248 MiB / 24.8% CPU) | **8,411**
                          (248 MiB / 24.8% CPU) | **0.29×** | **1×** | -| yarp-reverse-http2 | dotnet-httpclient | **29,096**
                          (95 MiB / 53.0% CPU) | **29,096**
                          (95 MiB / 53.0% CPU) | **1×** | **3.46×** | -| twp-reverse-http2-cleartext | dotnet-httpclient | 🥇 **35,180**
                          (96 MiB / 52.0% CPU) | **35,180**
                          (96 MiB / 52.0% CPU) | **1.21×** | **4.18×** | +| nginx-reverse-http2 | dotnet-httpclient | **7,992**
                          (141 MiB / 24.6% CPU) | **7,992**
                          (141 MiB / 24.6% CPU) | **0.28×** | **1.00×** | +| yarp-reverse-http2 | dotnet-httpclient | **28,294**
                          (97 MiB / 54.9% CPU) | **28,294**
                          (97 MiB / 54.9% CPU) | **1.00×** | **3.54×** | +| twp-reverse-http2-cleartext | dotnet-httpclient | 🥇 **33,720**
                          (105 MiB / 52.2% CPU) | **33,720**
                          (105 MiB / 52.2% CPU) | **1.19×** | **4.22×** | **Linux** (`ubuntu-latest`) | Arm | Generator | Sustain | Peak | ÷YARP | ÷nginx | |---|---|---:|---:|---:|---:| -| nginx-reverse-http2 | dotnet-httpclient | **14,490**
                          (97 MiB / 19.4% CPU) | **14,490**
                          (97 MiB / 19.4% CPU) | **0.51×** | **1×** | -| yarp-reverse-http2 | dotnet-httpclient | **28,448**
                          (120 MiB / 49.0% CPU) | **28,448**
                          (120 MiB / 49.0% CPU) | **1×** | **1.96×** | -| twp-reverse-http2-cleartext | dotnet-httpclient | 🥇 **33,751**
                          (120 MiB / 51.7% CPU) | **33,751**
                          (120 MiB / 51.7% CPU) | **1.19×** | **2.33×** | +| nginx-reverse-http2 | dotnet-httpclient | **23,276**
                          (102 MiB / 18.9% CPU) | **23,276**
                          (102 MiB / 18.9% CPU) | **0.53×** | **1.00×** | +| yarp-reverse-http2 | dotnet-httpclient | **44,299**
                          (122 MiB / 48.0% CPU) | **44,299**
                          (122 MiB / 48.0% CPU) | **1.00×** | **1.90×** | +| twp-reverse-http2-cleartext | dotnet-httpclient | 🥇 **49,167**
                          (124 MiB / 52.1% CPU) | **49,167**
                          (124 MiB / 52.1% CPU) | **1.11×** | **2.11×** | #### Block C — H3→H1 -Same layout as Block B. Requires QuicListener; nginx only with `http_v3_module` (Windows nginx has no QUIC). +Same layout as Block B. Requires QuicListener. nginx needs `http_v3_module` (Windows nginx has no QUIC). HAProxy needs `USE_QUIC` (GHA Linux/macOS require it). Envoy 1.20+ includes HTTP/3. **Windows** (`windows-latest`) | Arm | Generator | Sustain | Peak | ÷YARP | ÷nginx | |---|---|---:|---:|---:|---:| -| nginx-reverse-http3-cleartext | dotnet-httpclient | *Not possible* (no QUIC) | *Not possible* | — | — | -| yarp-reverse-http3-cleartext | dotnet-httpclient | **14,549**
                          (119 MiB / 50.8% CPU) | **15,166**
                          (119 MiB / 50.8% CPU) | **1×** | **—** | -| twp-reverse-http3-cleartext | dotnet-httpclient | 🥇 **15,028**
                          (103 MiB / 46.5% CPU) | **15,028**
                          (103 MiB / 46.5% CPU) | **0.99×** | **—** | +| nginx-reverse-http3-cleartext | dotnet-httpclient | *Not possible (no QUIC)* | *Not possible (no QUIC)* | — | — | +| yarp-reverse-http3-cleartext | dotnet-httpclient | **14,041**
                          (142 MiB / 51.8% CPU) | **14,041**
                          (142 MiB / 51.8% CPU) | **1.00×** | — | +| twp-reverse-http3-cleartext | dotnet-httpclient | 🥇 **14,348**
                          (104 MiB / 43.8% CPU) | **14,348**
                          (104 MiB / 43.8% CPU) | **1.02×** | — | **Linux** (`ubuntu-latest`) | Arm | Generator | Sustain | Peak | ÷YARP | ÷nginx | |---|---|---:|---:|---:|---:| -| nginx-reverse-http3-cleartext | dotnet-httpclient | **0**
                          (103 MiB / 22.4% CPU) | **15,186**
                          (103 MiB / 22.4% CPU) | **0.85×** | **1×** | -| yarp-reverse-http3-cleartext | dotnet-httpclient | **17,856**
                          (181 MiB / 49.5% CPU) | **17,856**
                          (181 MiB / 49.5% CPU) | **1×** | **1.18×** | -| twp-reverse-http3-cleartext | dotnet-httpclient | 🥇 **20,378**
                          (142 MiB / 50.4% CPU) | **20,378**
                          (142 MiB / 50.4% CPU) | **1.14×** | **1.34×** | - -**How to read the tables** - -- **Reverse** = bare transparent fixed-forward (no TWP plugins / interception). nginx knobs match TWP/YARP streaming (`keepalive 256`, `proxy_buffering off`). **MITM** = TWP-only table on the same Client×Origin wires: **Lite** = no-op handlers (unchanged-lite finish reuses reverse compressed relay); **Full** = mutating handlers that append up to four unique headers per direction (RPS harness adds one; product uses `MitmCompressedRelayHelper` — no probe name in library code). Remove/replace/non-unique header growth and body mutation still force full decode/re-encode. nginx/YARP cannot MITM. **HTTP/3 has no cleartext client** (QUIC always encrypted). -- **Sustainable** = last concurrency that still met error/latency SLOs. **Peak** = highest RPS in that ramp. -- 🥇 = best among **TWP / nginx / YARP** on Reverse rows (or saturation blocks): highest RPS; on an RPS tie, lower Memory (RSS) then lower CPU%. MITM is TWP-only. **Lite÷Reverse** / **Full÷Reverse** = TWP MITM lite or full sustain ÷ TWP Reverse sustain on the same Client×Origin from the same `compare-product` job. -- *Not possible* = product cannot do that path. *Not measured* = path exists but no published number yet for that OS. -- Product refresh: `compare-product` @ `af6feb9c` — [33480574506](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33480574506). Heavier/saturation/tls: - -```powershell -pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-product -pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-bodies -pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-post -pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-lossy -pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-tls-cost -pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-arch -pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-saturation -``` +| nginx-reverse-http3-cleartext | dotnet-httpclient | **0**
                          (110 MiB / 21.8% CPU) | **24,928**
                          (110 MiB / 21.8% CPU) | **0.89×** | **1.00×** | +| yarp-reverse-http3-cleartext | dotnet-httpclient | **27,936**
                          (195 MiB / 48.8% CPU) | **27,936**
                          (195 MiB / 48.8% CPU) | **1.00×** | **1.12×** | +| twp-reverse-http3-cleartext | dotnet-httpclient | 🥇 **30,636**
                          (159 MiB / 52.7% CPU) | **30,636**
                          (159 MiB / 52.7% CPU) | **1.10×** | **1.23×** | -## Windows — Titanium vs nginx vs YARP +## Windows — Titanium vs nginx vs HAProxy vs Envoy vs YARP Client / origin: HTTP version and whether TLS is used (`plain` = cleartext, `TLS` = encrypted, `QUIC` = HTTP/3). ### Reverse -Median of **3 repeats** on `windows-latest` (4 vCPU / 16 GiB). Bare reverse 5×5 @ `af6feb9c` — `compare-product` [33480574506](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33480574506). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Prefer TWP÷peer ratios over absolute RPS. **RPS cells** include median RSS / CPU at the peak-RPS step as `
                          (MiB / CPU%)`. nginx terminate peers use `keepalive 256` + streaming buffers. Laptop High-perf / cool-paired numbers stay on the [local lab](Performance-Local-Lab). - -**Load generators:** Reverse inbound H3 arms use **`dotnet-httpclient`** (`http_version=3.0`, `RequestVersionExact`). nginx/Windows is same-OS only (no QUIC). - -| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak | -|---|---|---:|---:|---:|---:|---:|---:| -| HTTP/1 · plain | HTTP/1 · plain | 🥇 **31765**
                          (70 MiB / 45.4% CPU) | 🥇 **31765**
                          (70 MiB / 45.4% CPU) | **19819**
                          (121 MiB / 24.7% CPU) | **19819**
                          (121 MiB / 24.7% CPU) | **27235**
                          (85 MiB / 50.7% CPU) | **27235**
                          (85 MiB / 50.7% CPU) | -| HTTP/1 · plain | HTTP/1 · TLS | 🥇 **26973**
                          (83 MiB / 51% CPU) | 🥇 **26973**
                          (83 MiB / 51% CPU) | *Not possible* | *Not possible* | **24335**
                          (91 MiB / 49.2% CPU) | **24335**
                          (91 MiB / 49.2% CPU) | -| HTTP/1 · plain | HTTP/2 · plain | 🥇 **42934**
                          (101 MiB / 46.8% CPU) | 🥇 **42934**
                          (101 MiB / 46.8% CPU) | *Not possible* | *Not possible* | **40127**
                          (91 MiB / 52% CPU) | **40127**
                          (91 MiB / 52% CPU) | -| HTTP/1 · plain | HTTP/2 · TLS | 🥇 **38043**
                          (109 MiB / 46.8% CPU) | 🥇 **38043**
                          (109 MiB / 46.8% CPU) | *Not possible* | *Not possible* | **36577**
                          (98 MiB / 49.9% CPU) | **36577**
                          (98 MiB / 49.9% CPU) | -| HTTP/1 · plain | HTTP/3 · QUIC | **21706**
                          (110 MiB / 49.4% CPU) | **21706**
                          (110 MiB / 49.4% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | 🥇 **21810**
                          (119 MiB / 51.6% CPU) | 🥇 **21810**
                          (119 MiB / 51.6% CPU) | -| HTTP/1 · TLS | HTTP/1 · plain | 🥇 **26410**
                          (86 MiB / 48% CPU) | 🥇 **26410**
                          (86 MiB / 48% CPU) | **12883**
                          (138 MiB / 24.7% CPU) | **12883**
                          (138 MiB / 24.7% CPU) | **22776**
                          (103 MiB / 47.9% CPU) | **22776**
                          (103 MiB / 47.9% CPU) | -| HTTP/1 · TLS | HTTP/1 · TLS | 🥇 **23846**
                          (83 MiB / 45.8% CPU) | 🥇 **23846**
                          (83 MiB / 45.8% CPU) | *Not possible* | *Not possible* | **21266**
                          (102 MiB / 48.3% CPU) | **21266**
                          (102 MiB / 48.3% CPU) | -| HTTP/1 · TLS | HTTP/2 · plain | 🥇 **33826**
                          (124 MiB / 46.1% CPU) | 🥇 **33826**
                          (124 MiB / 46.1% CPU) | *Not possible* | *Not possible* | **33091**
                          (104 MiB / 48.1% CPU) | **33091**
                          (104 MiB / 48.1% CPU) | -| HTTP/1 · TLS | HTTP/2 · TLS | 🥇 **31308**
                          (114 MiB / 48.6% CPU) | 🥇 **31308**
                          (114 MiB / 48.6% CPU) | *Not possible* | *Not possible* | **30826**
                          (108 MiB / 48.2% CPU) | **30826**
                          (108 MiB / 48.2% CPU) | -| HTTP/1 · TLS | HTTP/3 · QUIC | 🥇 **18940**
                          (114 MiB / 51.3% CPU) | 🥇 **18940**
                          (114 MiB / 51.3% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | **18561**
                          (123 MiB / 51.2% CPU) | **18561**
                          (123 MiB / 51.2% CPU) | -| HTTP/2 · plain | HTTP/1 · plain | 🥇 **42295**
                          (93 MiB / 52% CPU) | 🥇 **42295**
                          (93 MiB / 52% CPU) | *Not possible* | *Not possible* | **39066**
                          (85 MiB / 48.6% CPU) | **39066**
                          (85 MiB / 48.6% CPU) | -| HTTP/2 · plain | HTTP/1 · TLS | 🥇 **35700**
                          (101 MiB / 50.3% CPU) | 🥇 **35700**
                          (101 MiB / 50.3% CPU) | *Not possible* | *Not possible* | **33324**
                          (93 MiB / 50.3% CPU) | **33324**
                          (93 MiB / 50.3% CPU) | -| HTTP/2 · plain | HTTP/2 · plain | 🥇 **93806**
                          (74 MiB / 35.4% CPU) | 🥇 **93806**
                          (74 MiB / 35.4% CPU) | *Not possible* | *Not possible* | **70526**
                          (95 MiB / 52.1% CPU) | **70526**
                          (95 MiB / 52.1% CPU) | -| HTTP/2 · plain | HTTP/2 · TLS | 🥇 **79366**
                          (82 MiB / 37% CPU) | 🥇 **79366**
                          (82 MiB / 37% CPU) | *Not possible* | *Not possible* | **60217**
                          (103 MiB / 49.1% CPU) | **60217**
                          (103 MiB / 49.1% CPU) | -| HTTP/2 · plain | HTTP/3 · QUIC | 🥇 **32456**
                          (126 MiB / 52.2% CPU) | 🥇 **32456**
                          (126 MiB / 52.2% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | **31624**
                          (124 MiB / 51.8% CPU) | **31624**
                          (124 MiB / 51.8% CPU) | -| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **39964**
                          (103 MiB / 52.5% CPU) | 🥇 **39964**
                          (103 MiB / 52.5% CPU) | **11592**
                          (137 MiB / 24.4% CPU) | **11592**
                          (137 MiB / 24.4% CPU) | **35637**
                          (92 MiB / 50.1% CPU) | **35637**
                          (92 MiB / 50.1% CPU) | -| HTTP/2 · TLS | HTTP/1 · TLS | 🥇 **35693**
                          (101 MiB / 50.4% CPU) | 🥇 **35693**
                          (101 MiB / 50.4% CPU) | *Not possible* | *Not possible* | **30944**
                          (93 MiB / 50.8% CPU) | **30944**
                          (93 MiB / 50.8% CPU) | -| HTTP/2 · TLS | HTTP/2 · plain | 🥇 **85084**
                          (91 MiB / 38.6% CPU) | 🥇 **85084**
                          (91 MiB / 38.6% CPU) | *Not possible* | *Not possible* | **59124**
                          (107 MiB / 53.2% CPU) | **59124**
                          (107 MiB / 53.2% CPU) | -| HTTP/2 · TLS | HTTP/2 · TLS | 🥇 **76202**
                          (91 MiB / 34.3% CPU) | 🥇 **76202**
                          (91 MiB / 34.3% CPU) | *Not possible* | *Not possible* | **52852**
                          (100 MiB / 49% CPU) | **52852**
                          (100 MiB / 49% CPU) | -| HTTP/2 · TLS | HTTP/3 · QUIC | 🥇 **31867**
                          (138 MiB / 55.3% CPU) | 🥇 **31867**
                          (138 MiB / 55.3% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | **28613**
                          (125 MiB / 53.3% CPU) | **28613**
                          (125 MiB / 53.3% CPU) | -| HTTP/3 · QUIC | HTTP/1 · plain | **19458**
                          (108 MiB / 43.9% CPU) | **19458**
                          (108 MiB / 43.9% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | 🥇 **19475**
                          (156 MiB / 49.9% CPU) | 🥇 **19475**
                          (156 MiB / 49.9% CPU) | -| HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **17440**
                          (113 MiB / 45.5% CPU) | 🥇 **17440**
                          (113 MiB / 45.5% CPU) | *Not possible* | *Not possible* | **15866**
                          (163 MiB / 50.5% CPU) | **15866**
                          (163 MiB / 50.5% CPU) | -| HTTP/3 · QUIC | HTTP/2 · plain | 🥇 **29275**
                          (122 MiB / 49.9% CPU) | 🥇 **29275**
                          (122 MiB / 49.9% CPU) | *Not possible (no H3 to H2)* | *Not possible (no H3 to H2)* | **27148**
                          (165 MiB / 49.6% CPU) | **27148**
                          (165 MiB / 49.6% CPU) | -| HTTP/3 · QUIC | HTTP/2 · TLS | 🥇 **26686**
                          (124 MiB / 47.1% CPU) | 🥇 **26686**
                          (124 MiB / 47.1% CPU) | *Not possible (no H3 to H2)* | *Not possible (no H3 to H2)* | **24802**
                          (171 MiB / 48.8% CPU) | **24802**
                          (171 MiB / 48.8% CPU) | -| HTTP/3 · QUIC | HTTP/3 · QUIC | 🥇 **16986**
                          (119 MiB / 44.9% CPU) | 🥇 **16986**
                          (119 MiB / 44.9% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | **14141**
                          (163 MiB / 49.7% CPU) | **14141**
                          (163 MiB / 49.7% CPU) | +Median of **3 repeats** on `windows-latest` (4 vCPU / 16 GiB). Bare reverse 5×5 @ `9a2b3a1e` — `compare-product` [34441526151](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441526151). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Prefer TWP÷peer ratios over absolute RPS. **RPS cells** include median RSS / CPU at the peak-RPS step as `
                          (MiB / CPU%)`. nginx terminate peers use `keepalive 256` + streaming buffers. **HAProxy / Envoy are Linux-only peers** (no official Windows port). Laptop High-perf / cool-paired numbers stay on the [local lab](Performance-Local-Lab). Product 5×5 is **~56-byte JSON keep-alive GET**; H2/H3 same-protocol cells are mostly header work with a tiny body (Titanium best case) — see [Why this comparison is fair](#why-this-comparison-is-fair). + +**Load generators:** Reverse inbound H3 arms use **`dotnet-httpclient`** (`http_version=3.0`, `RequestVersionExact`). nginx/Windows is same-OS only (no QUIC). HAProxy/Envoy are Linux-only terminate peers. + +| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | HAProxy sustain | HAProxy peak | Envoy sustain | Envoy peak | YARP sustain | YARP peak | +|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| HTTP/1 · plain | HTTP/1 · plain | 🥇 **23134**
                          (75 MiB / 47.5% CPU) | 🥇 **23134**
                          (75 MiB / 47.5% CPU) | **13916**
                          (125 MiB / 24.9% CPU) | **13916**
                          (125 MiB / 24.9% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **21699**
                          (86 MiB / 48.8% CPU) | **21699**
                          (86 MiB / 48.8% CPU) | +| HTTP/1 · plain | HTTP/1 · TLS | 🥇 **20749**
                          (89 MiB / 52.8% CPU) | 🥇 **20749**
                          (89 MiB / 52.8% CPU) | **8366**
                          (135 MiB / 24.6% CPU) | **8366**
                          (135 MiB / 24.6% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **18784**
                          (100 MiB / 49% CPU) | **18784**
                          (100 MiB / 49% CPU) | +| HTTP/1 · plain | HTTP/2 · plain | 🥇 **45733**
                          (115 MiB / 50.5% CPU) | 🥇 **45733**
                          (115 MiB / 50.5% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **39820**
                          (90 MiB / 49.2% CPU) | **39820**
                          (90 MiB / 49.2% CPU) | +| HTTP/1 · plain | HTTP/2 · TLS | 🥇 **32162**
                          (120 MiB / 46.7% CPU) | 🥇 **32162**
                          (120 MiB / 46.7% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **29670**
                          (98 MiB / 49.7% CPU) | **29670**
                          (98 MiB / 49.7% CPU) | +| HTTP/1 · plain | HTTP/3 · QUIC | 🥇 **18315**
                          (106 MiB / 50.6% CPU) | 🥇 **18315**
                          (106 MiB / 50.6% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **17823**
                          (117 MiB / 51% CPU) | **17823**
                          (117 MiB / 51% CPU) | +| HTTP/1 · TLS | HTTP/1 · plain | 🥇 **25720**
                          (90 MiB / 48.4% CPU) | 🥇 **25720**
                          (90 MiB / 48.4% CPU) | **12987**
                          (141 MiB / 24.8% CPU) | **12987**
                          (141 MiB / 24.8% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **22690**
                          (102 MiB / 48.2% CPU) | **22690**
                          (102 MiB / 48.2% CPU) | +| HTTP/1 · TLS | HTTP/1 · TLS | 🥇 **18942**
                          (91 MiB / 48.6% CPU) | 🥇 **18942**
                          (91 MiB / 48.6% CPU) | **7218**
                          (143 MiB / 24.8% CPU) | **7218**
                          (143 MiB / 24.8% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **17296**
                          (104 MiB / 49.7% CPU) | **17296**
                          (104 MiB / 49.7% CPU) | +| HTTP/1 · TLS | HTTP/2 · plain | 🥇 **27614**
                          (111 MiB / 44.5% CPU) | 🥇 **27614**
                          (111 MiB / 44.5% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **26015**
                          (104 MiB / 47.6% CPU) | **26015**
                          (104 MiB / 47.6% CPU) | +| HTTP/1 · TLS | HTTP/2 · TLS | 🥇 **32132**
                          (117 MiB / 45.4% CPU) | 🥇 **32132**
                          (117 MiB / 45.4% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **29999**
                          (103 MiB / 48.2% CPU) | **29999**
                          (103 MiB / 48.2% CPU) | +| HTTP/1 · TLS | HTTP/3 · QUIC | 🥇 **16275**
                          (110 MiB / 51.3% CPU) | 🥇 **16275**
                          (110 MiB / 51.3% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **15797**
                          (124 MiB / 51.4% CPU) | **15797**
                          (124 MiB / 51.4% CPU) | +| HTTP/2 · plain | HTTP/1 · plain | 🥇 **34933**
                          (89 MiB / 55% CPU) | 🥇 **34933**
                          (89 MiB / 55% CPU) | **9362**
                          (127 MiB / 24.4% CPU) | **9362**
                          (127 MiB / 24.4% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **31625**
                          (86 MiB / 54.4% CPU) | **31625**
                          (86 MiB / 54.4% CPU) | +| HTTP/2 · plain | HTTP/1 · TLS | 🥇 **35086**
                          (105 MiB / 49% CPU) | 🥇 **35086**
                          (105 MiB / 49% CPU) | **8849**
                          (138 MiB / 24.9% CPU) | **8849**
                          (138 MiB / 24.9% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **32740**
                          (92 MiB / 50.1% CPU) | **32740**
                          (92 MiB / 50.1% CPU) | +| HTTP/2 · plain | HTTP/2 · plain | 🥇 **112638**
                          (61 MiB / 28.3% CPU) | 🥇 **112638**
                          (61 MiB / 28.3% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **64290**
                          (90 MiB / 49.6% CPU) | **64290**
                          (90 MiB / 49.6% CPU) | +| HTTP/2 · plain | HTTP/2 · TLS | 🥇 **70105**
                          (62 MiB / 20.1% CPU) | 🥇 **70105**
                          (62 MiB / 20.1% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **43971**
                          (99 MiB / 35.9% CPU) | **43971**
                          (99 MiB / 35.9% CPU) | +| HTTP/2 · plain | HTTP/3 · QUIC | 🥇 **33088**
                          (128 MiB / 53.1% CPU) | 🥇 **33088**
                          (128 MiB / 53.1% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **31406**
                          (130 MiB / 52% CPU) | **31406**
                          (130 MiB / 52% CPU) | +| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **34943**
                          (97 MiB / 52.2% CPU) | 🥇 **34943**
                          (97 MiB / 52.2% CPU) | **8430**
                          (141 MiB / 24.8% CPU) | **8430**
                          (141 MiB / 24.8% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **29441**
                          (96 MiB / 53.8% CPU) | **29441**
                          (96 MiB / 53.8% CPU) | +| HTTP/2 · TLS | HTTP/1 · TLS | 🥇 **29487**
                          (97 MiB / 53.6% CPU) | 🥇 **29487**
                          (97 MiB / 53.6% CPU) | **6542**
                          (144 MiB / 24.6% CPU) | **6542**
                          (144 MiB / 24.6% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **25117**
                          (98 MiB / 54.2% CPU) | **25117**
                          (98 MiB / 54.2% CPU) | +| HTTP/2 · TLS | HTTP/2 · plain | 🥇 **107152**
                          (75 MiB / 29.1% CPU) | 🥇 **107152**
                          (75 MiB / 29.1% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **59098**
                          (102 MiB / 52.7% CPU) | **59098**
                          (102 MiB / 52.7% CPU) | +| HTTP/2 · TLS | HTTP/2 · TLS | 🥇 **84487**
                          (73 MiB / 28.8% CPU) | 🥇 **84487**
                          (73 MiB / 28.8% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **49546**
                          (98 MiB / 49% CPU) | **49546**
                          (98 MiB / 49% CPU) | +| HTTP/2 · TLS | HTTP/3 · QUIC | 🥇 **30034**
                          (127 MiB / 53.4% CPU) | 🥇 **30034**
                          (127 MiB / 53.4% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **25937**
                          (123 MiB / 51.5% CPU) | **25937**
                          (123 MiB / 51.5% CPU) | +| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **19348**
                          (107 MiB / 44% CPU) | 🥇 **19348**
                          (107 MiB / 44% CPU) | *Not measured* | *Not measured* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **18041**
                          (143 MiB / 50.9% CPU) | **18041**
                          (143 MiB / 50.9% CPU) | +| HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **13646**
                          (113 MiB / 46.7% CPU) | 🥇 **13646**
                          (113 MiB / 46.7% CPU) | *Not measured* | *Not measured* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **13437**
                          (145 MiB / 52.4% CPU) | **13437**
                          (145 MiB / 52.4% CPU) | +| HTTP/3 · QUIC | HTTP/2 · plain | 🥇 **34128**
                          (136 MiB / 47.6% CPU) | 🥇 **34128**
                          (136 MiB / 47.6% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **25450**
                          (166 MiB / 50.1% CPU) | **25450**
                          (166 MiB / 50.1% CPU) | +| HTTP/3 · QUIC | HTTP/2 · TLS | 🥇 **33850**
                          (138 MiB / 48% CPU) | 🥇 **33850**
                          (138 MiB / 48% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **26828**
                          (168 MiB / 48.5% CPU) | **26828**
                          (168 MiB / 48.5% CPU) | +| HTTP/3 · QUIC | HTTP/3 · QUIC | 🥇 **23150**
                          (121 MiB / 41.8% CPU) | 🥇 **23150**
                          (121 MiB / 41.8% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **12064**
                          (167 MiB / 53.1% CPU) | **12064**
                          (167 MiB / 53.1% CPU) | ### MITM (TWP only) -Same Client×Origin wires with interception on (`compare-product` [33480574506](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33480574506)). **Lite** = no-op handlers (unchanged-lite finish). **Full** = append-only header mutation (harness: one probe header each way; product: generic append-only relay via `MitmCompressedRelayHelper`). nginx/YARP cannot MITM. **Lite÷Reverse** / **Full÷Reverse** vs bare reverse (same job). Completion gate: Lite and Full ≥ **0.70×** reverse sustain @ c=64 (median of 3 GHA runs). +Same Client×Origin wires with interception on (`compare-product` [34441526151](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441526151)). **Lite** = no-op handlers (unchanged-lite finish). **Full** = append-only header mutation (harness: one probe header each way; product: generic append-only relay via `MitmCompressedRelayHelper`). nginx/HAProxy/Envoy/YARP cannot MITM. **Lite÷Reverse** / **Full÷Reverse** vs bare reverse (**same job / comparison-group shard**). Completion gate: Lite ≥ **0.50×** and Full ≥ **0.50×** reverse sustain @ c=64 (median of 3 GHA runs); reverse TWP÷YARP ≥ **0.70×** (no terminate-peer gate). **v1 append-only relay (2026-08-27):** Pre-fix H2→H2 Full÷Reverse was **0.13–0.16×** ([32960766249](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32960766249)). Post-fix @ `df172718`: H2 plain→H2 plain Full **0.77–0.79×**, H3→H1 Full **0.91–0.93×**, all MITM arms ≥ **0.70×** on median of [33041445371](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33041445371), [33055267086](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33055267086), [33055272140](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33055272140). -**v2 drop-only + non-unique append (2026-08-27):** `MitmStaticRebuildHelper` rebuilds static HPACK/QPACK after 1–4 unique header drops; trailing non-unique appends stay on compressed relay. @ `af6feb9c`: all MITM arms ≥ **0.70×** on GHA median ([33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622), [33105885748](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33105885748) Linux; [33087085235](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087085235), [33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622) Windows). H2 plain→H2 plain Full **0.77–0.79×** (Win) / **0.78×** (Lin). +**v2 drop-only + non-unique append (2026-08-27):** `MitmStaticRebuildHelper` rebuilds static HPACK/QPACK after 1–4 unique header drops; trailing non-unique appends stay on compressed relay. @ `9a2b3a1e`: all MITM arms ≥ **0.70×** on GHA median ([33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622), [33105885748](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33105885748) Linux; [33087085235](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087085235), [33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622) Windows). H2 plain→H2 plain Full **0.77–0.79×** (Win) / **0.78×** (Lin). | Client | Origin | Lite sustain | Full sustain | Lite÷Reverse | Full÷Reverse | |---|---|---:|---:|---:|---:| -| HTTP/1 · plain | HTTP/1 · plain | **30957**
                          (78 MiB / 49.1% CPU) | **30842**
                          (78 MiB / 49.4% CPU) | **0.97×** | **0.97×** | -| HTTP/1 · plain | HTTP/1 · TLS | **26248**
                          (92 MiB / 48.6% CPU) | **25580**
                          (92 MiB / 51.3% CPU) | **0.97×** | **0.95×** | -| HTTP/1 · plain | HTTP/2 · plain | **41326**
                          (108 MiB / 50.6% CPU) | **40463**
                          (121 MiB / 49.1% CPU) | **0.96×** | **0.94×** | -| HTTP/1 · plain | HTTP/2 · TLS | **37282**
                          (114 MiB / 48.7% CPU) | **36238**
                          (112 MiB / 52% CPU) | **0.98×** | **0.95×** | -| HTTP/1 · plain | HTTP/3 · QUIC | **21261**
                          (114 MiB / 51.6% CPU) | **21190**
                          (111 MiB / 50.3% CPU) | **0.98×** | **0.98×** | -| HTTP/1 · TLS | HTTP/1 · plain | **26092**
                          (88 MiB / 50.8% CPU) | **25382**
                          (89 MiB / 50% CPU) | **0.99×** | **0.96×** | -| HTTP/1 · TLS | HTTP/1 · TLS | **23588**
                          (90 MiB / 48.2% CPU) | **22792**
                          (94 MiB / 47.8% CPU) | **0.99×** | **0.96×** | -| HTTP/1 · TLS | HTTP/2 · plain | **32692**
                          (128 MiB / 48.5% CPU) | **32245**
                          (135 MiB / 47.3% CPU) | **0.97×** | **0.95×** | -| HTTP/1 · TLS | HTTP/2 · TLS | **30786**
                          (126 MiB / 49.1% CPU) | **29996**
                          (130 MiB / 46.6% CPU) | **0.98×** | **0.96×** | -| HTTP/1 · TLS | HTTP/3 · QUIC | **18795**
                          (120 MiB / 50% CPU) | **18276**
                          (108 MiB / 50% CPU) | **0.99×** | **0.96×** | -| HTTP/2 · plain | HTTP/1 · plain | **40649**
                          (102 MiB / 55.4% CPU) | **39482**
                          (96 MiB / 52.3% CPU) | **0.96×** | **0.93×** | -| HTTP/2 · plain | HTTP/1 · TLS | **35181**
                          (100 MiB / 53.7% CPU) | **34295**
                          (102 MiB / 50.9% CPU) | **0.99×** | **0.96×** | -| HTTP/2 · plain | HTTP/2 · plain | **74824**
                          (74 MiB / 50.6% CPU) | **71844**
                          (72 MiB / 49.1% CPU) | **0.8×** | **0.77×** | -| HTTP/2 · plain | HTTP/2 · TLS | **66275**
                          (81 MiB / 47% CPU) | **63497**
                          (84 MiB / 45.8% CPU) | **0.84×** | **0.8×** | -| HTTP/2 · plain | HTTP/3 · QUIC | **32821**
                          (126 MiB / 54.3% CPU) | **31729**
                          (130 MiB / 54.1% CPU) | **1.01×** | **0.98×** | -| HTTP/2 · TLS | HTTP/1 · plain | **39452**
                          (104 MiB / 52.6% CPU) | **38289**
                          (104 MiB / 50.7% CPU) | **0.99×** | **0.96×** | -| HTTP/2 · TLS | HTTP/1 · TLS | **34100**
                          (105 MiB / 51.7% CPU) | **33191**
                          (101 MiB / 54.2% CPU) | **0.96×** | **0.93×** | -| HTTP/2 · TLS | HTTP/2 · plain | **72281**
                          (94 MiB / 46.7% CPU) | **68787**
                          (88 MiB / 48.9% CPU) | **0.85×** | **0.81×** | -| HTTP/2 · TLS | HTTP/2 · TLS | **64218**
                          (96 MiB / 44.7% CPU) | **62976**
                          (93 MiB / 45.3% CPU) | **0.84×** | **0.83×** | -| HTTP/2 · TLS | HTTP/3 · QUIC | **31894**
                          (129 MiB / 54.8% CPU) | **30828**
                          (133 MiB / 54.8% CPU) | **1×** | **0.97×** | -| HTTP/3 · QUIC | HTTP/1 · plain | **18343**
                          (106 MiB / 45.2% CPU) | **18216**
                          (114 MiB / 44.6% CPU) | **0.94×** | **0.94×** | -| HTTP/3 · QUIC | HTTP/1 · TLS | **16578**
                          (123 MiB / 47.2% CPU) | **15794**
                          (121 MiB / 45.5% CPU) | **0.95×** | **0.91×** | -| HTTP/3 · QUIC | HTTP/2 · plain | **27129**
                          (134 MiB / 50.1% CPU) | **26620**
                          (129 MiB / 48.1% CPU) | **0.93×** | **0.91×** | -| HTTP/3 · QUIC | HTTP/2 · TLS | **25192**
                          (134 MiB / 46.7% CPU) | **24512**
                          (136 MiB / 50.2% CPU) | **0.94×** | **0.92×** | -| HTTP/3 · QUIC | HTTP/3 · QUIC | **15673**
                          (119 MiB / 52.4% CPU) | **15215**
                          (119 MiB / 48.8% CPU) | **0.92×** | **0.9×** | - -## Linux — Titanium vs nginx vs YARP +| HTTP/1 · plain | HTTP/1 · plain | **25054**
                          (82 MiB / 50.4% CPU) | **24740**
                          (82 MiB / 51.6% CPU) | **1.08×** | **1.07×** | +| HTTP/1 · plain | HTTP/1 · TLS | **20351**
                          (95 MiB / 54.2% CPU) | **19992**
                          (95 MiB / 52% CPU) | **0.98×** | **0.96×** | +| HTTP/1 · plain | HTTP/2 · plain | **45468**
                          (110 MiB / 51.5% CPU) | **43600**
                          (120 MiB / 49.4% CPU) | **0.99×** | **0.95×** | +| HTTP/1 · plain | HTTP/2 · TLS | **32619**
                          (118 MiB / 46.8% CPU) | **31475**
                          (125 MiB / 46.3% CPU) | **1.01×** | **0.98×** | +| HTTP/1 · plain | HTTP/3 · QUIC | **17858**
                          (105 MiB / 47.7% CPU) | **17488**
                          (104 MiB / 52.6% CPU) | **0.98×** | **0.95×** | +| HTTP/1 · TLS | HTTP/1 · plain | **24388**
                          (98 MiB / 47.9% CPU) | **24773**
                          (95 MiB / 48.2% CPU) | **0.95×** | **0.96×** | +| HTTP/1 · TLS | HTTP/1 · TLS | **18766**
                          (94 MiB / 49.4% CPU) | **18334**
                          (96 MiB / 49.4% CPU) | **0.99×** | **0.97×** | +| HTTP/1 · TLS | HTTP/2 · plain | **27503**
                          (112 MiB / 47.3% CPU) | **27245**
                          (112 MiB / 46.1% CPU) | **1×** | **0.99×** | +| HTTP/1 · TLS | HTTP/2 · TLS | **31677**
                          (118 MiB / 47.9% CPU) | **30700**
                          (117 MiB / 44.2% CPU) | **0.99×** | **0.96×** | +| HTTP/1 · TLS | HTTP/3 · QUIC | **15862**
                          (113 MiB / 50.9% CPU) | **15726**
                          (112 MiB / 50.8% CPU) | **0.97×** | **0.97×** | +| HTTP/2 · plain | HTTP/1 · plain | **34123**
                          (95 MiB / 55.4% CPU) | **33304**
                          (96 MiB / 56.8% CPU) | **0.98×** | **0.95×** | +| HTTP/2 · plain | HTTP/1 · TLS | **34643**
                          (106 MiB / 50.5% CPU) | **34092**
                          (109 MiB / 52.9% CPU) | **0.99×** | **0.97×** | +| HTTP/2 · plain | HTTP/2 · plain | **86231**
                          (71 MiB / 41.3% CPU) | **80246**
                          (73 MiB / 41.9% CPU) | **0.77×** | **0.71×** | +| HTTP/2 · plain | HTTP/2 · TLS | **72044**
                          (77 MiB / 36.5% CPU) | **67973**
                          (77 MiB / 39.7% CPU) | **1.03×** | **0.97×** | +| HTTP/2 · plain | HTTP/3 · QUIC | **33179**
                          (124 MiB / 53.4% CPU) | **32949**
                          (129 MiB / 53.1% CPU) | **1×** | **1×** | +| HTTP/2 · TLS | HTTP/1 · plain | **33820**
                          (98 MiB / 55.2% CPU) | **32652**
                          (107 MiB / 54.9% CPU) | **0.97×** | **0.93×** | +| HTTP/2 · TLS | HTTP/1 · TLS | **28427**
                          (100 MiB / 56.2% CPU) | **27770**
                          (105 MiB / 53.2% CPU) | **0.96×** | **0.94×** | +| HTTP/2 · TLS | HTTP/2 · plain | **84248**
                          (86 MiB / 40.5% CPU) | **80554**
                          (84 MiB / 39.7% CPU) | **0.79×** | **0.75×** | +| HTTP/2 · TLS | HTTP/2 · TLS | **68743**
                          (86 MiB / 38.1% CPU) | **65184**
                          (81 MiB / 39.6% CPU) | **0.81×** | **0.77×** | +| HTTP/2 · TLS | HTTP/3 · QUIC | **29843**
                          (126 MiB / 54.4% CPU) | **29276**
                          (133 MiB / 53.5% CPU) | **0.99×** | **0.97×** | +| HTTP/3 · QUIC | HTTP/1 · plain | **18648**
                          (117 MiB / 44.9% CPU) | **17458**
                          (114 MiB / 44.7% CPU) | **0.96×** | **0.9×** | +| HTTP/3 · QUIC | HTTP/1 · TLS | **12964**
                          (123 MiB / 46.8% CPU) | **12392**
                          (116 MiB / 47.5% CPU) | **0.95×** | **0.91×** | +| HTTP/3 · QUIC | HTTP/2 · plain | **30904**
                          (132 MiB / 48.6% CPU) | **29726**
                          (133 MiB / 50.6% CPU) | **0.91×** | **0.87×** | +| HTTP/3 · QUIC | HTTP/2 · TLS | **31489**
                          (143 MiB / 48.5% CPU) | **30510**
                          (143 MiB / 48.3% CPU) | **0.93×** | **0.9×** | +| HTTP/3 · QUIC | HTTP/3 · QUIC | **16061**
                          (120 MiB / 49.2% CPU) | **15312**
                          (119 MiB / 49.5% CPU) | **0.69×** | **0.66×** | + +## Linux — Titanium vs nginx vs HAProxy vs Envoy vs YARP ### Reverse -Median of **3 repeats** on `ubuntu-latest` (4 vCPU / 16 GiB). Bare reverse 5×5 @ `af6feb9c` — `compare-product` [33480574506](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33480574506). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. **Linux nginx is the authoritative nginx baseline.** nginx terminate peers use `keepalive 256` + streaming buffers. The RPS workflow installs nginx.org mainline (`http_v3_module`) and `libmsquic`. Prefer ratios over absolute RPS. - -| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak | -|---|---|---:|---:|---:|---:|---:|---:| -| HTTP/1 · plain | HTTP/1 · plain | **31956**
                          (84 MiB / 50.4% CPU) | **31956**
                          (84 MiB / 50.4% CPU) | 🥇 **38906**
                          (72 MiB / 41.1% CPU) | 🥇 **38906**
                          (72 MiB / 41.1% CPU) | **28082**
                          (112 MiB / 50.2% CPU) | **28082**
                          (112 MiB / 50.2% CPU) | -| HTTP/1 · plain | HTTP/1 · TLS | 🥇 **24395**
                          (104 MiB / 50.5% CPU) | 🥇 **24395**
                          (104 MiB / 50.5% CPU) | *Not possible* | *Not possible* | **21991**
                          (131 MiB / 50.6% CPU) | **21991**
                          (131 MiB / 50.6% CPU) | -| HTTP/1 · plain | HTTP/2 · plain | 🥇 **38738**
                          (150 MiB / 51.2% CPU) | 🥇 **38738**
                          (150 MiB / 51.2% CPU) | *Not possible* | *Not possible* | **35233**
                          (123 MiB / 49.5% CPU) | **35233**
                          (123 MiB / 49.5% CPU) | -| HTTP/1 · plain | HTTP/2 · TLS | 🥇 **30789**
                          (144 MiB / 51.4% CPU) | 🥇 **30789**
                          (144 MiB / 51.4% CPU) | *Not possible* | *Not possible* | **29836**
                          (132 MiB / 48.2% CPU) | **29836**
                          (132 MiB / 48.2% CPU) | -| HTTP/1 · plain | HTTP/3 · QUIC | 🥇 **22535**
                          (132 MiB / 53.4% CPU) | 🥇 **22535**
                          (132 MiB / 53.4% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | **0**
                          (114 MiB / 60.8% CPU) | **0**
                          (114 MiB / 60.8% CPU) | -| HTTP/1 · TLS | HTTP/1 · plain | **23092**
                          (105 MiB / 49.6% CPU) | **23092**
                          (105 MiB / 49.6% CPU) | 🥇 **27141**
                          (98 MiB / 41.4% CPU) | 🥇 **27141**
                          (98 MiB / 41.4% CPU) | **20122**
                          (135 MiB / 50.4% CPU) | **20122**
                          (135 MiB / 50.4% CPU) | -| HTTP/1 · TLS | HTTP/1 · TLS | 🥇 **18887**
                          (109 MiB / 48.8% CPU) | 🥇 **18887**
                          (109 MiB / 48.8% CPU) | *Not possible* | *Not possible* | **16873**
                          (136 MiB / 49.6% CPU) | **16873**
                          (136 MiB / 49.6% CPU) | -| HTTP/1 · TLS | HTTP/2 · plain | 🥇 **27224**
                          (152 MiB / 50.4% CPU) | 🥇 **27224**
                          (152 MiB / 50.4% CPU) | *Not possible* | *Not possible* | **24848**
                          (144 MiB / 48.5% CPU) | **24848**
                          (144 MiB / 48.5% CPU) | -| HTTP/1 · TLS | HTTP/2 · TLS | 🥇 **22866**
                          (149 MiB / 48.5% CPU) | 🥇 **22866**
                          (149 MiB / 48.5% CPU) | *Not possible* | *Not possible* | **21905**
                          (144 MiB / 47.9% CPU) | **21905**
                          (144 MiB / 47.9% CPU) | -| HTTP/1 · TLS | HTTP/3 · QUIC | 🥇 **17606**
                          (138 MiB / 52.3% CPU) | 🥇 **17606**
                          (138 MiB / 52.3% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | **0**
                          (131 MiB / 58.7% CPU) | **0**
                          (131 MiB / 58.7% CPU) | -| HTTP/2 · plain | HTTP/1 · plain | 🥇 **36810**
                          (115 MiB / 52.6% CPU) | 🥇 **36810**
                          (115 MiB / 52.6% CPU) | *Not possible* | *Not possible* | **34419**
                          (114 MiB / 50.2% CPU) | **34419**
                          (114 MiB / 50.2% CPU) | -| HTTP/2 · plain | HTTP/1 · TLS | 🥇 **27798**
                          (119 MiB / 51.4% CPU) | 🥇 **27798**
                          (119 MiB / 51.4% CPU) | *Not possible* | *Not possible* | **25536**
                          (124 MiB / 50.1% CPU) | **25536**
                          (124 MiB / 50.1% CPU) | -| HTTP/2 · plain | HTTP/2 · plain | 🥇 **65970**
                          (100 MiB / 39.6% CPU) | 🥇 **65970**
                          (100 MiB / 39.6% CPU) | *Not possible* | *Not possible* | **48598**
                          (131 MiB / 47.7% CPU) | **48598**
                          (131 MiB / 47.7% CPU) | -| HTTP/2 · plain | HTTP/2 · TLS | 🥇 **49700**
                          (110 MiB / 39.8% CPU) | 🥇 **49700**
                          (110 MiB / 39.8% CPU) | *Not possible* | *Not possible* | **39399**
                          (125 MiB / 45.7% CPU) | **39399**
                          (125 MiB / 45.7% CPU) | -| HTTP/2 · plain | HTTP/3 · QUIC | 🥇 **26283**
                          (142 MiB / 50.1% CPU) | 🥇 **26283**
                          (142 MiB / 50.1% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | **0**
                          (112 MiB / 64% CPU) | **0**
                          (112 MiB / 64% CPU) | -| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **34462**
                          (124 MiB / 52.4% CPU) | 🥇 **34462**
                          (124 MiB / 52.4% CPU) | **15289**
                          (97 MiB / 19.4% CPU) | **15289**
                          (97 MiB / 19.4% CPU) | **29076**
                          (120 MiB / 50% CPU) | **29076**
                          (120 MiB / 50% CPU) | -| HTTP/2 · TLS | HTTP/1 · TLS | 🥇 **26928**
                          (117 MiB / 50.6% CPU) | 🥇 **26928**
                          (117 MiB / 50.6% CPU) | *Not possible* | *Not possible* | **22965**
                          (127 MiB / 50.1% CPU) | **22965**
                          (127 MiB / 50.1% CPU) | -| HTTP/2 · TLS | HTTP/2 · plain | 🥇 **58637**
                          (108 MiB / 39.6% CPU) | 🥇 **58637**
                          (108 MiB / 39.6% CPU) | *Not possible* | *Not possible* | **38866**
                          (127 MiB / 47% CPU) | **38866**
                          (127 MiB / 47% CPU) | -| HTTP/2 · TLS | HTTP/2 · TLS | 🥇 **48010**
                          (115 MiB / 39.3% CPU) | 🥇 **48010**
                          (115 MiB / 39.3% CPU) | *Not possible* | *Not possible* | **33621**
                          (124 MiB / 46% CPU) | **33621**
                          (124 MiB / 46% CPU) | -| HTTP/2 · TLS | HTTP/3 · QUIC | 🥇 **24743**
                          (148 MiB / 49.5% CPU) | 🥇 **24743**
                          (148 MiB / 49.5% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | **0**
                          (118 MiB / 59.2% CPU) | **0**
                          (118 MiB / 59.2% CPU) | -| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **20263**
                          (145 MiB / 50% CPU) | 🥇 **20263**
                          (145 MiB / 50% CPU) | **0**
                          (104 MiB / 22.6% CPU) | **15118**
                          (104 MiB / 22.6% CPU) | **18541**
                          (182 MiB / 50.8% CPU) | **18541**
                          (182 MiB / 50.8% CPU) | -| HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **16062**
                          (152 MiB / 47.5% CPU) | 🥇 **16062**
                          (152 MiB / 47.5% CPU) | *Not possible* | *Not possible* | **15436**
                          (196 MiB / 51.5% CPU) | **15436**
                          (196 MiB / 51.5% CPU) | -| HTTP/3 · QUIC | HTTP/2 · plain | 🥇 **26790**
                          (142 MiB / 53% CPU) | 🥇 **26790**
                          (142 MiB / 53% CPU) | *Not possible (no H3 to H2)* | *Not possible (no H3 to H2)* | **24071**
                          (192 MiB / 49.1% CPU) | **24071**
                          (192 MiB / 49.1% CPU) | -| HTTP/3 · QUIC | HTTP/2 · TLS | 🥇 **23409**
                          (143 MiB / 51.9% CPU) | 🥇 **23409**
                          (143 MiB / 51.9% CPU) | *Not possible (no H3 to H2)* | *Not possible (no H3 to H2)* | **21214**
                          (195 MiB / 47.6% CPU) | **21214**
                          (195 MiB / 47.6% CPU) | -| HTTP/3 · QUIC | HTTP/3 · QUIC | 🥇 **19805**
                          (152 MiB / 46% CPU) | 🥇 **19805**
                          (152 MiB / 46% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | **0**
                          (188 MiB / 60.5% CPU) | **0**
                          (188 MiB / 60.5% CPU) | +Median of **3 repeats** on `ubuntu-latest` (4 vCPU / 16 GiB). Bare reverse 5×5 @ `9a2b3a1e` — `compare-product` [34441526151](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441526151). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. **Linux nginx is the authoritative nginx baseline.** HAProxy (3.2 `USE_QUIC`) and Envoy (GitHub release, HTTP/3 compiled in) run on the same loopback shape as nginx/YARP. nginx terminate peers use `keepalive 256` + streaming buffers. The RPS workflow installs nginx.org mainline (`http_v3_module`), a QUIC-enabled HAProxy, Envoy, and `libmsquic`. Prefer ratios over absolute RPS. Product 5×5 is **~56-byte JSON keep-alive GET**; H2/H3 same-protocol cells are mostly header work with a tiny body (Titanium best case) — see [Why this comparison is fair](#why-this-comparison-is-fair). + +| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | HAProxy sustain | HAProxy peak | Envoy sustain | Envoy peak | YARP sustain | YARP peak | +|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| HTTP/1 · plain | HTTP/1 · plain | **36672**
                          (94 MiB / 50.5% CPU) | **36672**
                          (94 MiB / 50.5% CPU) | 🥇 **44112**
                          (76 MiB / 40.4% CPU) | 🥇 **44112**
                          (76 MiB / 40.4% CPU) | **41748**
                          (67 MiB / 41.3% CPU) | **41748**
                          (67 MiB / 41.3% CPU) | **24415**
                          (116 MiB / 59.5% CPU) | **24415**
                          (116 MiB / 59.5% CPU) | **32393**
                          (115 MiB / 48.8% CPU) | **32393**
                          (115 MiB / 48.8% CPU) | +| HTTP/1 · plain | HTTP/1 · TLS | **28506**
                          (107 MiB / 49.3% CPU) | **28506**
                          (107 MiB / 49.3% CPU) | 🥇 **34687**
                          (93 MiB / 40.7% CPU) | 🥇 **34687**
                          (93 MiB / 40.7% CPU) | **32839**
                          (71 MiB / 43% CPU) | **32839**
                          (71 MiB / 43% CPU) | **22383**
                          (117 MiB / 56.4% CPU) | **22383**
                          (117 MiB / 56.4% CPU) | **25710**
                          (138 MiB / 49.6% CPU) | **25710**
                          (138 MiB / 49.6% CPU) | +| HTTP/1 · plain | HTTP/2 · plain | 🥇 **45673**
                          (129 MiB / 52.7% CPU) | 🥇 **45673**
                          (129 MiB / 52.7% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **26830**
                          (68 MiB / 42.8% CPU) | **26830**
                          (68 MiB / 42.8% CPU) | **27447**
                          (116 MiB / 61% CPU) | **27447**
                          (116 MiB / 61% CPU) | **40965**
                          (125 MiB / 49.4% CPU) | **40965**
                          (125 MiB / 49.4% CPU) | +| HTTP/1 · plain | HTTP/2 · TLS | 🥇 **37934**
                          (147 MiB / 49.1% CPU) | 🥇 **37934**
                          (147 MiB / 49.1% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **35699**
                          (68 MiB / 43% CPU) | **35699**
                          (68 MiB / 43% CPU) | **19949**
                          (117 MiB / 61.9% CPU) | **19949**
                          (117 MiB / 61.9% CPU) | **35520**
                          (132 MiB / 47.3% CPU) | **35520**
                          (132 MiB / 47.3% CPU) | +| HTTP/1 · plain | HTTP/3 · QUIC | 🥇 **26151**
                          (141 MiB / 53.8% CPU) | 🥇 **26151**
                          (141 MiB / 53.8% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | *Not measured* | *Not measured* | **12126**
                          (120 MiB / 56.3% CPU) | **12126**
                          (120 MiB / 56.3% CPU) | **24086**
                          (155 MiB / 48.3% CPU) | **24086**
                          (155 MiB / 48.3% CPU) | +| HTTP/1 · TLS | HTTP/1 · plain | **27742**
                          (111 MiB / 48.9% CPU) | **27742**
                          (111 MiB / 48.9% CPU) | **33232**
                          (100 MiB / 41.5% CPU) | **33232**
                          (100 MiB / 41.5% CPU) | 🥇 **33283**
                          (85 MiB / 43.6% CPU) | 🥇 **33283**
                          (85 MiB / 43.6% CPU) | **21660**
                          (127 MiB / 56.4% CPU) | **21660**
                          (127 MiB / 56.4% CPU) | **24735**
                          (142 MiB / 49.4% CPU) | **24735**
                          (142 MiB / 49.4% CPU) | +| HTTP/1 · TLS | HTTP/1 · TLS | **23878**
                          (111 MiB / 48.2% CPU) | **23878**
                          (111 MiB / 48.2% CPU) | 🥇 **28042**
                          (103 MiB / 41.6% CPU) | 🥇 **28042**
                          (103 MiB / 41.6% CPU) | **27394**
                          (86 MiB / 43.8% CPU) | **27394**
                          (86 MiB / 43.8% CPU) | **19309**
                          (128 MiB / 55% CPU) | **19309**
                          (128 MiB / 55% CPU) | **21716**
                          (142 MiB / 49.4% CPU) | **21716**
                          (142 MiB / 49.4% CPU) | +| HTTP/1 · TLS | HTTP/2 · plain | 🥇 **34010**
                          (140 MiB / 51.4% CPU) | 🥇 **34010**
                          (140 MiB / 51.4% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **21760**
                          (83 MiB / 42.9% CPU) | **21760**
                          (83 MiB / 42.9% CPU) | **18208**
                          (127 MiB / 58.6% CPU) | **18208**
                          (127 MiB / 58.6% CPU) | **30979**
                          (141 MiB / 49.2% CPU) | **30979**
                          (141 MiB / 49.2% CPU) | +| HTTP/1 · TLS | HTTP/2 · TLS | 🥇 **29656**
                          (157 MiB / 48.6% CPU) | 🥇 **29656**
                          (157 MiB / 48.6% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **29078**
                          (83 MiB / 44% CPU) | **29078**
                          (83 MiB / 44% CPU) | **21903**
                          (126 MiB / 56.9% CPU) | **21903**
                          (126 MiB / 56.9% CPU) | **27210**
                          (146 MiB / 48.3% CPU) | **27210**
                          (146 MiB / 48.3% CPU) | +| HTTP/1 · TLS | HTTP/3 · QUIC | 🥇 **22269**
                          (152 MiB / 52.9% CPU) | 🥇 **22269**
                          (152 MiB / 52.9% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | *Not measured* | *Not measured* | **11093**
                          (130 MiB / 55.9% CPU) | **11093**
                          (130 MiB / 55.9% CPU) | **19851**
                          (166 MiB / 49.4% CPU) | **19851**
                          (166 MiB / 49.4% CPU) | +| HTTP/2 · plain | HTTP/1 · plain | 🥇 **41263**
                          (126 MiB / 53.1% CPU) | 🥇 **41263**
                          (126 MiB / 53.1% CPU) | **18170**
                          (79 MiB / 19.6% CPU) | **18170**
                          (79 MiB / 19.6% CPU) | **27652**
                          (69 MiB / 24.4% CPU) | **27652**
                          (69 MiB / 24.4% CPU) | **17979**
                          (118 MiB / 23% CPU) | **17979**
                          (118 MiB / 23% CPU) | **39219**
                          (115 MiB / 48.3% CPU) | **39219**
                          (115 MiB / 48.3% CPU) | +| HTTP/2 · plain | HTTP/1 · TLS | 🥇 **32750**
                          (131 MiB / 51% CPU) | 🥇 **32750**
                          (131 MiB / 51% CPU) | **15078**
                          (100 MiB / 19.2% CPU) | **15078**
                          (100 MiB / 19.2% CPU) | **22285**
                          (71 MiB / 24.5% CPU) | **22285**
                          (71 MiB / 24.5% CPU) | **16529**
                          (119 MiB / 23.1% CPU) | **16529**
                          (119 MiB / 23.1% CPU) | **31159**
                          (125 MiB / 48.4% CPU) | **31159**
                          (125 MiB / 48.4% CPU) | +| HTTP/2 · plain | HTTP/2 · plain | 🥇 **97239**
                          (90 MiB / 38% CPU) | 🥇 **97239**
                          (90 MiB / 38% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **30261**
                          (69 MiB / 24.3% CPU) | **30261**
                          (69 MiB / 24.3% CPU) | **24868**
                          (116 MiB / 21.8% CPU) | **24868**
                          (116 MiB / 21.8% CPU) | **55752**
                          (122 MiB / 47.1% CPU) | **55752**
                          (122 MiB / 47.1% CPU) | +| HTTP/2 · plain | HTTP/2 · TLS | 🥇 **67702**
                          (93 MiB / 38.2% CPU) | 🥇 **67702**
                          (93 MiB / 38.2% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **23815**
                          (68 MiB / 24.4% CPU) | **23815**
                          (68 MiB / 24.4% CPU) | **16231**
                          (118 MiB / 21.5% CPU) | **16231**
                          (118 MiB / 21.5% CPU) | **44692**
                          (129 MiB / 45.3% CPU) | **44692**
                          (129 MiB / 45.3% CPU) | +| HTTP/2 · plain | HTTP/3 · QUIC | 🥇 **30188**
                          (150 MiB / 51.4% CPU) | 🥇 **30188**
                          (150 MiB / 51.4% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | *Not measured* | *Not measured* | **5340**
                          (121 MiB / 24.8% CPU) | **5340**
                          (121 MiB / 24.8% CPU) | **28445**
                          (156 MiB / 45.5% CPU) | **28445**
                          (156 MiB / 45.5% CPU) | +| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **38662**
                          (128 MiB / 52.3% CPU) | 🥇 **38662**
                          (128 MiB / 52.3% CPU) | **17745**
                          (100 MiB / 18.8% CPU) | **17745**
                          (100 MiB / 18.8% CPU) | **24393**
                          (81 MiB / 24.3% CPU) | **24393**
                          (81 MiB / 24.3% CPU) | **17919**
                          (128 MiB / 22.5% CPU) | **17919**
                          (128 MiB / 22.5% CPU) | **34084**
                          (122 MiB / 48.1% CPU) | **34084**
                          (122 MiB / 48.1% CPU) | +| HTTP/2 · TLS | HTTP/1 · TLS | 🥇 **30318**
                          (119 MiB / 49.8% CPU) | 🥇 **30318**
                          (119 MiB / 49.8% CPU) | **15083**
                          (110 MiB / 19.6% CPU) | **15083**
                          (110 MiB / 19.6% CPU) | **20646**
                          (85 MiB / 24.4% CPU) | **20646**
                          (85 MiB / 24.4% CPU) | **16394**
                          (128 MiB / 22.2% CPU) | **16394**
                          (128 MiB / 22.2% CPU) | **27788**
                          (131 MiB / 48.5% CPU) | **27788**
                          (131 MiB / 48.5% CPU) | +| HTTP/2 · TLS | HTTP/2 · plain | 🥇 **85544**
                          (102 MiB / 38% CPU) | 🥇 **85544**
                          (102 MiB / 38% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **53648**
                          (85 MiB / 24.2% CPU) | **53648**
                          (85 MiB / 24.2% CPU) | **24277**
                          (126 MiB / 21.5% CPU) | **24277**
                          (126 MiB / 21.5% CPU) | **44751**
                          (125 MiB / 46.4% CPU) | **44751**
                          (125 MiB / 46.4% CPU) | +| HTTP/2 · TLS | HTTP/2 · TLS | 🥇 **63379**
                          (99 MiB / 36% CPU) | 🥇 **63379**
                          (99 MiB / 36% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **22388**
                          (84 MiB / 24.4% CPU) | **22388**
                          (84 MiB / 24.4% CPU) | **21510**
                          (125 MiB / 20.6% CPU) | **21510**
                          (125 MiB / 20.6% CPU) | **38643**
                          (128 MiB / 45.3% CPU) | **38643**
                          (128 MiB / 45.3% CPU) | +| HTTP/2 · TLS | HTTP/3 · QUIC | 🥇 **28423**
                          (151 MiB / 50.5% CPU) | 🥇 **28423**
                          (151 MiB / 50.5% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | *Not measured* | *Not measured* | **5476**
                          (129 MiB / 24.6% CPU) | **5476**
                          (129 MiB / 24.6% CPU) | **25085**
                          (163 MiB / 45.8% CPU) | **25085**
                          (163 MiB / 45.8% CPU) | +| HTTP/3 · QUIC | HTTP/1 · plain | **23715**
                          (149 MiB / 52.2% CPU) | **23715**
                          (149 MiB / 52.2% CPU) | **0**
                          (107 MiB / 21.6% CPU) | **19206**
                          (107 MiB / 21.6% CPU) | 🥇 **30835**
                          (86 MiB / 25.2% CPU) | 🥇 **30835**
                          (86 MiB / 25.2% CPU) | **3**
                          (130 MiB / 0.1% CPU) | **3**
                          (130 MiB / 0.1% CPU) | **21546**
                          (186 MiB / 49% CPU) | **21546**
                          (186 MiB / 49% CPU) | +| HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **19505**
                          (160 MiB / 49.3% CPU) | 🥇 **19505**
                          (160 MiB / 49.3% CPU) | **0**
                          (117 MiB / 22.7% CPU) | **14850**
                          (117 MiB / 22.7% CPU) | *Not measured* | *Not measured* | *Not measured* | *Not measured* | **18423**
                          (197 MiB / 49.9% CPU) | **18423**
                          (197 MiB / 49.9% CPU) | +| HTTP/3 · QUIC | HTTP/2 · plain | 🥇 **31386**
                          (160 MiB / 56.2% CPU) | 🥇 **31386**
                          (160 MiB / 56.2% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **29864**
                          (86 MiB / 25.4% CPU) | **29864**
                          (86 MiB / 25.4% CPU) | *Not measured* | *Not measured* | **26924**
                          (197 MiB / 48% CPU) | **26924**
                          (197 MiB / 48% CPU) | +| HTTP/3 · QUIC | HTTP/2 · TLS | 🥇 **27669**
                          (154 MiB / 52.3% CPU) | 🥇 **27669**
                          (154 MiB / 52.3% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | *Not measured* | *Not measured* | *Not measured* | *Not measured* | **24488**
                          (199 MiB / 47.6% CPU) | **24488**
                          (199 MiB / 47.6% CPU) | +| HTTP/3 · QUIC | HTTP/3 · QUIC | 🥇 **21858**
                          (164 MiB / 47.9% CPU) | 🥇 **21858**
                          (164 MiB / 47.9% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | *Not measured* | *Not measured* | *Not measured* | *Not measured* | **17840**
                          (209 MiB / 47% CPU) | **17840**
                          (209 MiB / 47% CPU) | ### MITM (TWP only) -Same Client×Origin wires with interception on (`compare-product` [33480574506](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33480574506)). **Lite** = no-op handlers (unchanged-lite finish). **Full** = append-only header mutation (harness: one probe header each way; product: generic append-only relay via `MitmCompressedRelayHelper`). nginx/YARP cannot MITM. **Lite÷Reverse** / **Full÷Reverse** vs bare reverse (same job). Completion gate: Lite and Full ≥ **0.70×** reverse sustain @ c=64 (median of 3 GHA runs). +Same Client×Origin wires with interception on (`compare-product` [34441526151](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441526151)). **Lite** = no-op handlers (unchanged-lite finish). **Full** = append-only header mutation (harness: one probe header each way; product: generic append-only relay via `MitmCompressedRelayHelper`). nginx/HAProxy/Envoy/YARP cannot MITM. **Lite÷Reverse** / **Full÷Reverse** vs bare reverse (**same job / comparison-group shard**). Completion gate: Lite ≥ **0.50×** and Full ≥ **0.50×** reverse sustain @ c=64 (median of 3 GHA runs); reverse TWP÷YARP ≥ **0.70×** (no terminate-peer gate). **v1 append-only relay (2026-08-27):** Pre-fix H2→H2 Full÷Reverse was **0.13–0.16×** ([32960766249](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32960766249)). Post-fix @ `df172718`: H2 plain→H2 plain Full **0.77–0.79×**, H3→H1 Full **0.91–0.93×**, all MITM arms ≥ **0.70×** on median of [33041445371](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33041445371), [33055267086](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33055267086), [33055272140](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33055272140). -**v2 drop-only + non-unique append (2026-08-27):** `MitmStaticRebuildHelper` rebuilds static HPACK/QPACK after 1–4 unique header drops; trailing non-unique appends stay on compressed relay. @ `af6feb9c`: all MITM arms ≥ **0.70×** on GHA median ([33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622), [33105885748](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33105885748) Linux; [33087085235](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087085235), [33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622) Windows). H2 plain→H2 plain Full **0.77–0.79×** (Win) / **0.78×** (Lin). +**v2 drop-only + non-unique append (2026-08-27):** `MitmStaticRebuildHelper` rebuilds static HPACK/QPACK after 1–4 unique header drops; trailing non-unique appends stay on compressed relay. @ `9a2b3a1e`: all MITM arms ≥ **0.70×** on GHA median ([33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622), [33105885748](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33105885748) Linux; [33087085235](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087085235), [33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622) Windows). H2 plain→H2 plain Full **0.77–0.79×** (Win) / **0.78×** (Lin). | Client | Origin | Lite sustain | Full sustain | Lite÷Reverse | Full÷Reverse | |---|---|---:|---:|---:|---:| -| HTTP/1 · plain | HTTP/1 · plain | **31292**
                          (88 MiB / 51.1% CPU) | **30801**
                          (91 MiB / 50.8% CPU) | **0.98×** | **0.96×** | -| HTTP/1 · plain | HTTP/1 · TLS | **23731**
                          (108 MiB / 51.9% CPU) | **22947**
                          (109 MiB / 51.6% CPU) | **0.97×** | **0.94×** | -| HTTP/1 · plain | HTTP/2 · plain | **37387**
                          (137 MiB / 53.2% CPU) | **35415**
                          (143 MiB / 52.7% CPU) | **0.97×** | **0.91×** | -| HTTP/1 · plain | HTTP/2 · TLS | **30454**
                          (147 MiB / 51.6% CPU) | **29464**
                          (147 MiB / 51.4% CPU) | **0.99×** | **0.96×** | -| HTTP/1 · plain | HTTP/3 · QUIC | **21823**
                          (135 MiB / 53.7% CPU) | **21980**
                          (144 MiB / 53.9% CPU) | **0.97×** | **0.98×** | -| HTTP/1 · TLS | HTTP/1 · plain | **23290**
                          (110 MiB / 50.6% CPU) | **22559**
                          (110 MiB / 50.5% CPU) | **1.01×** | **0.98×** | -| HTTP/1 · TLS | HTTP/1 · TLS | **19315**
                          (114 MiB / 49.4% CPU) | **18937**
                          (111 MiB / 49.4% CPU) | **1.02×** | **1×** | -| HTTP/1 · TLS | HTTP/2 · plain | **26567**
                          (160 MiB / 51.4% CPU) | **25890**
                          (157 MiB / 50.7% CPU) | **0.98×** | **0.95×** | -| HTTP/1 · TLS | HTTP/2 · TLS | **22278**
                          (154 MiB / 50% CPU) | **22313**
                          (159 MiB / 50% CPU) | **0.97×** | **0.98×** | -| HTTP/1 · TLS | HTTP/3 · QUIC | **16922**
                          (153 MiB / 52.1% CPU) | **16979**
                          (154 MiB / 51.8% CPU) | **0.96×** | **0.96×** | -| HTTP/2 · plain | HTTP/1 · plain | **35765**
                          (116 MiB / 54.1% CPU) | **34939**
                          (118 MiB / 54.3% CPU) | **0.97×** | **0.95×** | -| HTTP/2 · plain | HTTP/1 · TLS | **27522**
                          (127 MiB / 53% CPU) | **26709**
                          (117 MiB / 52.2% CPU) | **0.99×** | **0.96×** | -| HTTP/2 · plain | HTTP/2 · plain | **54296**
                          (104 MiB / 49.7% CPU) | **52074**
                          (104 MiB / 49.2% CPU) | **0.82×** | **0.79×** | -| HTTP/2 · plain | HTTP/2 · TLS | **42205**
                          (104 MiB / 46% CPU) | **41931**
                          (105 MiB / 46.2% CPU) | **0.85×** | **0.84×** | -| HTTP/2 · plain | HTTP/3 · QUIC | **26214**
                          (141 MiB / 50.9% CPU) | **25584**
                          (144 MiB / 50.7% CPU) | **1×** | **0.97×** | -| HTTP/2 · TLS | HTTP/1 · plain | **33503**
                          (120 MiB / 53.7% CPU) | **32119**
                          (121 MiB / 53% CPU) | **0.97×** | **0.93×** | -| HTTP/2 · TLS | HTTP/1 · TLS | **25438**
                          (121 MiB / 51.6% CPU) | **25191**
                          (119 MiB / 51.7% CPU) | **0.94×** | **0.94×** | -| HTTP/2 · TLS | HTTP/2 · plain | **47472**
                          (113 MiB / 47.1% CPU) | **46658**
                          (112 MiB / 47.1% CPU) | **0.81×** | **0.8×** | -| HTTP/2 · TLS | HTTP/2 · TLS | **39669**
                          (114 MiB / 45.3% CPU) | **37771**
                          (112 MiB / 44.8% CPU) | **0.83×** | **0.79×** | -| HTTP/2 · TLS | HTTP/3 · QUIC | **24322**
                          (149 MiB / 49.8% CPU) | **24840**
                          (148 MiB / 50.3% CPU) | **0.98×** | **1×** | -| HTTP/3 · QUIC | HTTP/1 · plain | **18224**
                          (142 MiB / 51% CPU) | **18214**
                          (142 MiB / 50.9% CPU) | **0.9×** | **0.9×** | -| HTTP/3 · QUIC | HTTP/1 · TLS | **13748**
                          (161 MiB / 49.4% CPU) | **14734**
                          (157 MiB / 48.5% CPU) | **0.86×** | **0.92×** | -| HTTP/3 · QUIC | HTTP/2 · plain | **25107**
                          (156 MiB / 56% CPU) | **25344**
                          (162 MiB / 56.4% CPU) | **0.94×** | **0.95×** | -| HTTP/3 · QUIC | HTTP/2 · TLS | **22107**
                          (160 MiB / 53.6% CPU) | **21619**
                          (160 MiB / 54.2% CPU) | **0.94×** | **0.92×** | -| HTTP/3 · QUIC | HTTP/3 · QUIC | **17526**
                          (150 MiB / 48.6% CPU) | **17517**
                          (151 MiB / 49.4% CPU) | **0.88×** | **0.88×** | - -## macOS — Titanium vs nginx vs YARP - -Numbers are filled by `tools/RpsLoadProbe/apply-wiki-paste.ps1` after `compare-product` on `macos-15-intel` (placeholder headers match the Linux table shape). +| HTTP/1 · plain | HTTP/1 · plain | **35578**
                          (88 MiB / 50.3% CPU) | **34914**
                          (97 MiB / 50.4% CPU) | **0.97×** | **0.95×** | +| HTTP/1 · plain | HTTP/1 · TLS | **28326**
                          (114 MiB / 49.8% CPU) | **28227**
                          (114 MiB / 49.8% CPU) | **0.99×** | **0.99×** | +| HTTP/1 · plain | HTTP/2 · plain | **46274**
                          (124 MiB / 54.5% CPU) | **44157**
                          (135 MiB / 54.7% CPU) | **1.01×** | **0.97×** | +| HTTP/1 · plain | HTTP/2 · TLS | **37133**
                          (147 MiB / 51% CPU) | **36654**
                          (146 MiB / 51.6% CPU) | **0.98×** | **0.97×** | +| HTTP/1 · plain | HTTP/3 · QUIC | **26091**
                          (142 MiB / 54.6% CPU) | **25394**
                          (145 MiB / 54.4% CPU) | **1×** | **0.97×** | +| HTTP/1 · TLS | HTTP/1 · plain | **27963**
                          (116 MiB / 50.4% CPU) | **27273**
                          (118 MiB / 50% CPU) | **1.01×** | **0.98×** | +| HTTP/1 · TLS | HTTP/1 · TLS | **24331**
                          (118 MiB / 49% CPU) | **23589**
                          (116 MiB / 48.9% CPU) | **1.02×** | **0.99×** | +| HTTP/1 · TLS | HTTP/2 · plain | **33674**
                          (144 MiB / 52.4% CPU) | **32894**
                          (144 MiB / 52% CPU) | **0.99×** | **0.97×** | +| HTTP/1 · TLS | HTTP/2 · TLS | **29341**
                          (163 MiB / 49.4% CPU) | **29024**
                          (160 MiB / 50.3% CPU) | **0.99×** | **0.98×** | +| HTTP/1 · TLS | HTTP/3 · QUIC | **21874**
                          (159 MiB / 53.2% CPU) | **21237**
                          (162 MiB / 53.3% CPU) | **0.98×** | **0.95×** | +| HTTP/2 · plain | HTTP/1 · plain | **39834**
                          (120 MiB / 54% CPU) | **38577**
                          (118 MiB / 53.7% CPU) | **0.97×** | **0.93×** | +| HTTP/2 · plain | HTTP/1 · TLS | **32663**
                          (135 MiB / 52.2% CPU) | **31085**
                          (123 MiB / 51.7% CPU) | **1×** | **0.95×** | +| HTTP/2 · plain | HTTP/2 · plain | **73656**
                          (93 MiB / 43.9% CPU) | **71161**
                          (97 MiB / 42.4% CPU) | **0.76×** | **0.73×** | +| HTTP/2 · plain | HTTP/2 · TLS | **55089**
                          (105 MiB / 41.2% CPU) | **51723**
                          (99 MiB / 40.6% CPU) | **0.81×** | **0.76×** | +| HTTP/2 · plain | HTTP/3 · QUIC | **30054**
                          (151 MiB / 51.4% CPU) | **29788**
                          (156 MiB / 51.7% CPU) | **1×** | **0.99×** | +| HTTP/2 · TLS | HTTP/1 · plain | **36903**
                          (123 MiB / 53.2% CPU) | **35802**
                          (127 MiB / 53.5% CPU) | **0.95×** | **0.93×** | +| HTTP/2 · TLS | HTTP/1 · TLS | **30528**
                          (130 MiB / 51.2% CPU) | **29484**
                          (124 MiB / 50.3% CPU) | **1.01×** | **0.97×** | +| HTTP/2 · TLS | HTTP/2 · plain | **63418**
                          (114 MiB / 43% CPU) | **60875**
                          (111 MiB / 42.8% CPU) | **0.74×** | **0.71×** | +| HTTP/2 · TLS | HTTP/2 · TLS | **52660**
                          (106 MiB / 40.8% CPU) | **49767**
                          (106 MiB / 39.5% CPU) | **0.83×** | **0.79×** | +| HTTP/2 · TLS | HTTP/3 · QUIC | **28301**
                          (166 MiB / 51.1% CPU) | **27450**
                          (154 MiB / 50.7% CPU) | **1×** | **0.97×** | +| HTTP/3 · QUIC | HTTP/1 · plain | **22951**
                          (150 MiB / 52.3% CPU) | **22840**
                          (150 MiB / 52.4% CPU) | **0.97×** | **0.96×** | +| HTTP/3 · QUIC | HTTP/1 · TLS | **18924**
                          (165 MiB / 50.9% CPU) | **18029**
                          (157 MiB / 49.7% CPU) | **0.97×** | **0.92×** | +| HTTP/3 · QUIC | HTTP/2 · plain | **30229**
                          (167 MiB / 57.6% CPU) | **29277**
                          (163 MiB / 56.6% CPU) | **0.96×** | **0.93×** | +| HTTP/3 · QUIC | HTTP/2 · TLS | **26262**
                          (163 MiB / 53.8% CPU) | **25788**
                          (168 MiB / 53.4% CPU) | **0.95×** | **0.93×** | +| HTTP/3 · QUIC | HTTP/3 · QUIC | **20948**
                          (163 MiB / 50.8% CPU) | **20826**
                          (162 MiB / 50.9% CPU) | **0.96×** | **0.95×** | + +## macOS — Titanium vs nginx vs HAProxy vs Envoy vs YARP ### Reverse -Median of **3 repeats** on `macos-15-intel` (4-core / 14 GB). Bare reverse 5×5 @ `af6feb9c` — `compare-product` [33480574506](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33480574506). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Prefer TWP÷peer ratios over absolute RPS. **RPS cells** include median RSS / CPU at the peak-RPS step as `
                          (MiB / CPU%)`. The RPS workflow installs Homebrew nginx (`http_v3_module`), Homebrew `libmsquic` (+ `DYLD_*`), and YARP. Do not publish from `macos-latest` (3-core / 7 GB). - -| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak | -|---|---|---:|---:|---:|---:|---:|---:| -| HTTP/1 · plain | HTTP/1 · plain | **15830**
                          (0 MiB / 0% CPU) | **15830**
                          (0 MiB / 0% CPU) | 🥇 **18883**
                          (0 MiB / 0% CPU) | 🥇 **18883**
                          (0 MiB / 0% CPU) | **16970**
                          (0 MiB / 0% CPU) | **16970**
                          (0 MiB / 0% CPU) | -| HTTP/1 · plain | HTTP/1 · TLS | 🥇 **13101**
                          (0 MiB / 0% CPU) | 🥇 **13101**
                          (0 MiB / 0% CPU) | *Not possible* | *Not possible* | **11276**
                          (0 MiB / 0% CPU) | **11276**
                          (0 MiB / 0% CPU) | -| HTTP/1 · plain | HTTP/2 · plain | **19797**
                          (0 MiB / 0% CPU) | **19797**
                          (0 MiB / 0% CPU) | *Not possible* | *Not possible* | 🥇 **27964**
                          (0 MiB / 0% CPU) | 🥇 **27964**
                          (0 MiB / 0% CPU) | -| HTTP/1 · plain | HTTP/2 · TLS | 🥇 **20136**
                          (0 MiB / 0% CPU) | 🥇 **20136**
                          (0 MiB / 0% CPU) | *Not possible* | *Not possible* | **17740**
                          (0 MiB / 0% CPU) | **17740**
                          (0 MiB / 0% CPU) | -| HTTP/1 · plain | HTTP/3 · QUIC | **5293**
                          (0 MiB / 0% CPU) | **5293**
                          (0 MiB / 0% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | 🥇 **5739**
                          (0 MiB / 0% CPU) | 🥇 **5739**
                          (0 MiB / 0% CPU) | -| HTTP/1 · TLS | HTTP/1 · plain | **10808**
                          (0 MiB / 0% CPU) | **10808**
                          (0 MiB / 0% CPU) | 🥇 **11563**
                          (0 MiB / 0% CPU) | 🥇 **11563**
                          (0 MiB / 0% CPU) | **9076**
                          (0 MiB / 0% CPU) | **9076**
                          (0 MiB / 0% CPU) | -| HTTP/1 · TLS | HTTP/1 · TLS | **10014**
                          (0 MiB / 0% CPU) | **10014**
                          (0 MiB / 0% CPU) | *Not possible* | *Not possible* | 🥇 **10262**
                          (0 MiB / 0% CPU) | 🥇 **10262**
                          (0 MiB / 0% CPU) | -| HTTP/1 · TLS | HTTP/2 · plain | **14438**
                          (0 MiB / 0% CPU) | **14438**
                          (0 MiB / 0% CPU) | *Not possible* | *Not possible* | 🥇 **15664**
                          (0 MiB / 0% CPU) | 🥇 **15664**
                          (0 MiB / 0% CPU) | -| HTTP/1 · TLS | HTTP/2 · TLS | **10412**
                          (0 MiB / 0% CPU) | **10412**
                          (0 MiB / 0% CPU) | *Not possible* | *Not possible* | 🥇 **17442**
                          (0 MiB / 0% CPU) | 🥇 **17442**
                          (0 MiB / 0% CPU) | -| HTTP/1 · TLS | HTTP/3 · QUIC | **3879**
                          (0 MiB / 0% CPU) | **3879**
                          (0 MiB / 0% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | 🥇 **5953**
                          (0 MiB / 0% CPU) | 🥇 **5953**
                          (0 MiB / 0% CPU) | -| HTTP/2 · plain | HTTP/1 · plain | 🥇 **21732**
                          (0 MiB / 0% CPU) | 🥇 **21732**
                          (0 MiB / 0% CPU) | *Not possible* | *Not possible* | **20311**
                          (0 MiB / 0% CPU) | **20311**
                          (0 MiB / 0% CPU) | -| HTTP/2 · plain | HTTP/1 · TLS | 🥇 **15050**
                          (0 MiB / 0% CPU) | 🥇 **15050**
                          (0 MiB / 0% CPU) | *Not possible* | *Not possible* | **14458**
                          (0 MiB / 0% CPU) | **14458**
                          (0 MiB / 0% CPU) | -| HTTP/2 · plain | HTTP/2 · plain | 🥇 **34927**
                          (0 MiB / 0% CPU) | 🥇 **34927**
                          (0 MiB / 0% CPU) | *Not possible* | *Not possible* | **28650**
                          (0 MiB / 0% CPU) | **28650**
                          (0 MiB / 0% CPU) | -| HTTP/2 · plain | HTTP/2 · TLS | 🥇 **37052**
                          (0 MiB / 0% CPU) | 🥇 **37052**
                          (0 MiB / 0% CPU) | *Not possible* | *Not possible* | **29908**
                          (0 MiB / 0% CPU) | **29908**
                          (0 MiB / 0% CPU) | -| HTTP/2 · plain | HTTP/3 · QUIC | **6330**
                          (0 MiB / 0% CPU) | **6330**
                          (0 MiB / 0% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | 🥇 **7302**
                          (0 MiB / 0% CPU) | 🥇 **7302**
                          (0 MiB / 0% CPU) | -| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **20695**
                          (0 MiB / 0% CPU) | 🥇 **20695**
                          (0 MiB / 0% CPU) | **13092**
                          (0 MiB / 0% CPU) | **13092**
                          (0 MiB / 0% CPU) | **19593**
                          (0 MiB / 0% CPU) | **19593**
                          (0 MiB / 0% CPU) | -| HTTP/2 · TLS | HTTP/1 · TLS | 🥇 **17743**
                          (0 MiB / 0% CPU) | 🥇 **17743**
                          (0 MiB / 0% CPU) | *Not possible* | *Not possible* | **13988**
                          (0 MiB / 0% CPU) | **13988**
                          (0 MiB / 0% CPU) | -| HTTP/2 · TLS | HTTP/2 · plain | 🥇 **35115**
                          (0 MiB / 0% CPU) | 🥇 **35115**
                          (0 MiB / 0% CPU) | *Not possible* | *Not possible* | **24660**
                          (0 MiB / 0% CPU) | **24660**
                          (0 MiB / 0% CPU) | -| HTTP/2 · TLS | HTTP/2 · TLS | 🥇 **30868**
                          (0 MiB / 0% CPU) | 🥇 **30868**
                          (0 MiB / 0% CPU) | *Not possible* | *Not possible* | **22331**
                          (0 MiB / 0% CPU) | **22331**
                          (0 MiB / 0% CPU) | -| HTTP/2 · TLS | HTTP/3 · QUIC | **5981**
                          (0 MiB / 0% CPU) | **5981**
                          (0 MiB / 0% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | 🥇 **7088**
                          (0 MiB / 0% CPU) | 🥇 **7088**
                          (0 MiB / 0% CPU) | -| HTTP/3 · QUIC | HTTP/1 · plain | **5429**
                          (0 MiB / 0% CPU) | **5429**
                          (0 MiB / 0% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | 🥇 **7418**
                          (0 MiB / 0% CPU) | 🥇 **7418**
                          (0 MiB / 0% CPU) | -| HTTP/3 · QUIC | HTTP/1 · TLS | **5433**
                          (0 MiB / 0% CPU) | **5433**
                          (0 MiB / 0% CPU) | *Not possible* | *Not possible* | 🥇 **5581**
                          (0 MiB / 0% CPU) | 🥇 **5581**
                          (0 MiB / 0% CPU) | -| HTTP/3 · QUIC | HTTP/2 · plain | **5336**
                          (0 MiB / 0% CPU) | **5336**
                          (0 MiB / 0% CPU) | *Not possible (no H3 to H2)* | *Not possible (no H3 to H2)* | 🥇 **9241**
                          (0 MiB / 0% CPU) | 🥇 **9241**
                          (0 MiB / 0% CPU) | -| HTTP/3 · QUIC | HTTP/2 · TLS | **5521**
                          (0 MiB / 0% CPU) | **5521**
                          (0 MiB / 0% CPU) | *Not possible (no H3 to H2)* | *Not possible (no H3 to H2)* | 🥇 **9651**
                          (0 MiB / 0% CPU) | 🥇 **9651**
                          (0 MiB / 0% CPU) | -| HTTP/3 · QUIC | HTTP/3 · QUIC | **4009**
                          (0 MiB / 0% CPU) | **4009**
                          (0 MiB / 0% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | 🥇 **5374**
                          (0 MiB / 0% CPU) | 🥇 **5374**
                          (0 MiB / 0% CPU) | +Median of **3 repeats** on `macos-15-intel` (4-core / 14 GB). Bare reverse 5×5 @ `9a2b3a1e` — `compare-product` [34441526151](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441526151). Warmup 2s / measure 8s; concurrency 8, 16, 32, 64. Prefer TWP÷peer ratios over absolute RPS. **RPS cells** include median RSS / CPU at the peak-RPS step as `
                          (MiB / CPU%)`. The RPS workflow installs Homebrew nginx (`http_v3_module`), Homebrew HAProxy with `USE_QUIC` (3.2 source fallback), Envoy (Homebrew bottle or pinned darwin-amd64 1.36.7), Homebrew `libmsquic` (+ `DYLD_*`), and YARP. Do not publish from `macos-latest` (3-core / 7 GB). Product 5×5 is **~56-byte JSON keep-alive GET**; H2/H3 same-protocol cells are mostly header work with a tiny body (Titanium best case) — see [Why this comparison is fair](#why-this-comparison-is-fair). + +| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | HAProxy sustain | HAProxy peak | Envoy sustain | Envoy peak | YARP sustain | YARP peak | +|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| HTTP/1 · plain | HTTP/1 · plain | 🥇 **12131**
                          (82 MiB / 32.8% CPU) | 🥇 **12131**
                          (82 MiB / 32.8% CPU) | **6938**
                          (52 MiB / 11.6% CPU) | **6938**
                          (52 MiB / 11.6% CPU) | **10945**
                          (48 MiB / 23.2% CPU) | **10945**
                          (48 MiB / 23.2% CPU) | **3332**
                          (72 MiB / 22.4% CPU) | **3332**
                          (72 MiB / 22.4% CPU) | **10805**
                          (105 MiB / 33.6% CPU) | **10805**
                          (105 MiB / 33.6% CPU) | +| HTTP/1 · plain | HTTP/1 · TLS | 🥇 **11064**
                          (93 MiB / 36.5% CPU) | 🥇 **11064**
                          (93 MiB / 36.5% CPU) | **5152**
                          (73 MiB / 17.4% CPU) | **5152**
                          (73 MiB / 17.4% CPU) | **7159**
                          (53 MiB / 25.9% CPU) | **7159**
                          (53 MiB / 25.9% CPU) | **0**
                          (75 MiB / 21% CPU) | **2011**
                          (75 MiB / 21% CPU) | **9146**
                          (129 MiB / 36.8% CPU) | **9146**
                          (129 MiB / 36.8% CPU) | +| HTTP/1 · plain | HTTP/2 · plain | 🥇 **19185**
                          (88 MiB / 33.8% CPU) | 🥇 **19185**
                          (88 MiB / 33.8% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **11853**
                          (50 MiB / 25.6% CPU) | **11853**
                          (50 MiB / 25.6% CPU) | **6000**
                          (73 MiB / 36.6% CPU) | **6000**
                          (73 MiB / 36.6% CPU) | **18729**
                          (111 MiB / 34% CPU) | **18729**
                          (111 MiB / 34% CPU) | +| HTTP/1 · plain | HTTP/2 · TLS | **9927**
                          (129 MiB / 32.9% CPU) | **9927**
                          (129 MiB / 32.9% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **8770**
                          (51 MiB / 26.5% CPU) | **8770**
                          (51 MiB / 26.5% CPU) | **5948**
                          (73 MiB / 34.9% CPU) | **5948**
                          (73 MiB / 34.9% CPU) | 🥇 **13674**
                          (116 MiB / 32.9% CPU) | 🥇 **13674**
                          (116 MiB / 32.9% CPU) | +| HTTP/1 · plain | HTTP/3 · QUIC | **3662**
                          (90 MiB / 45.3% CPU) | **3662**
                          (90 MiB / 45.3% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | *Not measured* | *Not measured* | **3393**
                          (75 MiB / 38.2% CPU) | **2631**
                          (75 MiB / 38.2% CPU) | 🥇 **5774**
                          (116 MiB / 33.4% CPU) | 🥇 **5774**
                          (116 MiB / 33.4% CPU) | +| HTTP/1 · TLS | HTTP/1 · plain | 🥇 **14162**
                          (94 MiB / 31.5% CPU) | 🥇 **14162**
                          (94 MiB / 31.5% CPU) | **8184**
                          (74 MiB / 18.3% CPU) | **8184**
                          (74 MiB / 18.3% CPU) | **9938**
                          (64 MiB / 25.6% CPU) | **9938**
                          (64 MiB / 25.6% CPU) | **4084**
                          (79 MiB / 37.4% CPU) | **4328**
                          (79 MiB / 37.4% CPU) | **7647**
                          (115 MiB / 32.3% CPU) | **7647**
                          (115 MiB / 32.3% CPU) | +| HTTP/1 · TLS | HTTP/1 · TLS | **6128**
                          (97 MiB / 31.7% CPU) | **6128**
                          (97 MiB / 31.7% CPU) | **4903**
                          (81 MiB / 15.5% CPU) | **4903**
                          (81 MiB / 15.5% CPU) | 🥇 **9241**
                          (67 MiB / 28% CPU) | 🥇 **9241**
                          (67 MiB / 28% CPU) | **3594**
                          (80 MiB / 38.9% CPU) | **3594**
                          (80 MiB / 38.9% CPU) | **6601**
                          (172 MiB / 35% CPU) | **6601**
                          (172 MiB / 35% CPU) | +| HTTP/1 · TLS | HTTP/2 · plain | **8317**
                          (96 MiB / 32.8% CPU) | **8317**
                          (96 MiB / 32.8% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | 🥇 **12353**
                          (66 MiB / 28% CPU) | 🥇 **12353**
                          (66 MiB / 28% CPU) | **5378**
                          (80 MiB / 20.9% CPU) | **3107**
                          (80 MiB / 20.9% CPU) | **10735**
                          (122 MiB / 32.2% CPU) | **10735**
                          (122 MiB / 32.2% CPU) | +| HTTP/1 · TLS | HTTP/2 · TLS | **8457**
                          (161 MiB / 33.8% CPU) | **8457**
                          (161 MiB / 33.8% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **8558**
                          (65 MiB / 29.3% CPU) | **8558**
                          (65 MiB / 29.3% CPU) | **4938**
                          (79 MiB / 39.8% CPU) | **5195**
                          (79 MiB / 39.8% CPU) | 🥇 **9023**
                          (120 MiB / 28.7% CPU) | 🥇 **9023**
                          (120 MiB / 28.7% CPU) | +| HTTP/1 · TLS | HTTP/3 · QUIC | **2677**
                          (112 MiB / 44.9% CPU) | **2677**
                          (112 MiB / 44.9% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | *Not measured* | *Not measured* | 🥇 **4392**
                          (81 MiB / 37.8% CPU) | 🥇 **2176**
                          (81 MiB / 37.8% CPU) | **3589**
                          (152 MiB / 31.7% CPU) | **3589**
                          (152 MiB / 31.7% CPU) | +| HTTP/2 · plain | HTTP/1 · plain | **12398**
                          (88 MiB / 35.1% CPU) | **12398**
                          (88 MiB / 35.1% CPU) | **10718**
                          (58 MiB / 15.4% CPU) | **10718**
                          (58 MiB / 15.4% CPU) | **7841**
                          (54 MiB / 18% CPU) | **7841**
                          (54 MiB / 18% CPU) | **3391**
                          (74 MiB / 20.7% CPU) | **3391**
                          (74 MiB / 20.7% CPU) | 🥇 **12934**
                          (105 MiB / 37.8% CPU) | 🥇 **12934**
                          (105 MiB / 37.8% CPU) | +| HTTP/2 · plain | HTTP/1 · TLS | **13814**
                          (94 MiB / 38.6% CPU) | **13814**
                          (94 MiB / 38.6% CPU) | **12186**
                          (85 MiB / 16% CPU) | **12186**
                          (85 MiB / 16% CPU) | **6688**
                          (56 MiB / 18.9% CPU) | **6688**
                          (56 MiB / 18.9% CPU) | **5045**
                          (77 MiB / 21.3% CPU) | **5045**
                          (77 MiB / 21.3% CPU) | 🥇 **14337**
                          (121 MiB / 42.3% CPU) | 🥇 **14337**
                          (121 MiB / 42.3% CPU) | +| HTTP/2 · plain | HTTP/2 · plain | 🥇 **30992**
                          (73 MiB / 25.8% CPU) | 🥇 **30992**
                          (73 MiB / 25.8% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **8615**
                          (54 MiB / 17.5% CPU) | **8615**
                          (54 MiB / 17.5% CPU) | **5442**
                          (72 MiB / 20% CPU) | **5442**
                          (72 MiB / 20% CPU) | **22898**
                          (109 MiB / 37.5% CPU) | **22898**
                          (109 MiB / 37.5% CPU) | +| HTTP/2 · plain | HTTP/2 · TLS | 🥇 **24573**
                          (77 MiB / 27.7% CPU) | 🥇 **24573**
                          (77 MiB / 27.7% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **11876**
                          (58 MiB / 19% CPU) | **11876**
                          (58 MiB / 19% CPU) | **6393**
                          (73 MiB / 20.2% CPU) | **6393**
                          (73 MiB / 20.2% CPU) | **18700**
                          (116 MiB / 34.6% CPU) | **18700**
                          (116 MiB / 34.6% CPU) | +| HTTP/2 · plain | HTTP/3 · QUIC | **4840**
                          (97 MiB / 49.3% CPU) | **4840**
                          (97 MiB / 49.3% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | *Not measured* | *Not measured* | **3760**
                          (74 MiB / 22.4% CPU) | **3760**
                          (74 MiB / 22.4% CPU) | 🥇 **8092**
                          (117 MiB / 33.9% CPU) | 🥇 **8092**
                          (117 MiB / 33.9% CPU) | +| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **13829**
                          (94 MiB / 39.2% CPU) | 🥇 **13829**
                          (94 MiB / 39.2% CPU) | **9070**
                          (73 MiB / 14.5% CPU) | **9070**
                          (73 MiB / 14.5% CPU) | **5237**
                          (66 MiB / 16.1% CPU) | **5237**
                          (66 MiB / 16.1% CPU) | **3516**
                          (81 MiB / 20.4% CPU) | **3516**
                          (81 MiB / 20.4% CPU) | **13572**
                          (113 MiB / 37.3% CPU) | **13572**
                          (113 MiB / 37.3% CPU) | +| HTTP/2 · TLS | HTTP/1 · TLS | 🥇 **9182**
                          (96 MiB / 37.3% CPU) | 🥇 **9182**
                          (96 MiB / 37.3% CPU) | **6812**
                          (90 MiB / 15.3% CPU) | **6812**
                          (90 MiB / 15.3% CPU) | **4920**
                          (66 MiB / 18.2% CPU) | **4920**
                          (66 MiB / 18.2% CPU) | **2505**
                          (83 MiB / 20.1% CPU) | **2505**
                          (83 MiB / 20.1% CPU) | **9080**
                          (124 MiB / 40.6% CPU) | **9080**
                          (124 MiB / 40.6% CPU) | +| HTTP/2 · TLS | HTTP/2 · plain | 🥇 **47325**
                          (81 MiB / 29% CPU) | 🥇 **47325**
                          (81 MiB / 29% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **5750**
                          (67 MiB / 5.9% CPU) | **2851**
                          (67 MiB / 5.9% CPU) | **4704**
                          (79 MiB / 19% CPU) | **4704**
                          (79 MiB / 19% CPU) | **22095**
                          (115 MiB / 37.6% CPU) | **22095**
                          (115 MiB / 37.6% CPU) | +| HTTP/2 · TLS | HTTP/2 · TLS | 🥇 **29038**
                          (83 MiB / 27.1% CPU) | 🥇 **29038**
                          (83 MiB / 27.1% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **6022**
                          (67 MiB / 18.3% CPU) | **6022**
                          (67 MiB / 18.3% CPU) | **4494**
                          (79 MiB / 18.6% CPU) | **4494**
                          (79 MiB / 18.6% CPU) | **16458**
                          (118 MiB / 35% CPU) | **16458**
                          (118 MiB / 35% CPU) | +| HTTP/2 · TLS | HTTP/3 · QUIC | **4226**
                          (108 MiB / 36% CPU) | **4226**
                          (108 MiB / 36% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | *Not measured* | *Not measured* | **1772**
                          (81 MiB / 21.6% CPU) | **1772**
                          (81 MiB / 21.6% CPU) | 🥇 **4805**
                          (125 MiB / 33.7% CPU) | 🥇 **4805**
                          (125 MiB / 33.7% CPU) | +| HTTP/3 · QUIC | HTTP/1 · plain | **5834**
                          (101 MiB / 39.5% CPU) | **5834**
                          (101 MiB / 39.5% CPU) | **0**
                          (63 MiB / 11.2% CPU) | **7464**
                          (63 MiB / 11.2% CPU) | 🥇 **10727**
                          (66 MiB / 22% CPU) | 🥇 **10727**
                          (66 MiB / 22% CPU) | **1595**
                          (85 MiB / 26.4% CPU) | **1595**
                          (85 MiB / 26.4% CPU) | **5558**
                          (187 MiB / 35% CPU) | **5558**
                          (187 MiB / 35% CPU) | +| HTTP/3 · QUIC | HTTP/1 · TLS | **3723**
                          (119 MiB / 38.2% CPU) | **3723**
                          (119 MiB / 38.2% CPU) | **0**
                          (66 MiB / 11.1% CPU) | **4316**
                          (66 MiB / 11.1% CPU) | 🥇 **5430**
                          (69 MiB / 21.8% CPU) | 🥇 **5430**
                          (69 MiB / 21.8% CPU) | *Not measured* | *Not measured* | **4786**
                          (197 MiB / 35.5% CPU) | **4786**
                          (197 MiB / 35.5% CPU) | +| HTTP/3 · QUIC | HTTP/2 · plain | **4576**
                          (97 MiB / 41.3% CPU) | **4576**
                          (97 MiB / 41.3% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | 🥇 **5954**
                          (66 MiB / 14.4% CPU) | 🥇 **5954**
                          (66 MiB / 14.4% CPU) | *Not measured* | *Not measured* | **5642**
                          (176 MiB / 34.2% CPU) | **5642**
                          (176 MiB / 34.2% CPU) | +| HTTP/3 · QUIC | HTTP/2 · TLS | **5532**
                          (106 MiB / 41.4% CPU) | **5532**
                          (106 MiB / 41.4% CPU) | *Not possible (no H2 upstream)* | *Not possible (no H2 upstream)* | **6633**
                          (68 MiB / 21.5% CPU) | **6633**
                          (68 MiB / 21.5% CPU) | *Not measured* | *Not measured* | 🥇 **7854**
                          (167 MiB / 32.5% CPU) | 🥇 **7854**
                          (167 MiB / 32.5% CPU) | +| HTTP/3 · QUIC | HTTP/3 · QUIC | **3460**
                          (96 MiB / 35% CPU) | **3460**
                          (96 MiB / 35% CPU) | *Not possible (no H3 upstream)* | *Not possible (no H3 upstream)* | *Not measured* | *Not measured* | *Not measured* | *Not measured* | 🥇 **4659**
                          (188 MiB / 34.4% CPU) | 🥇 **4659**
                          (188 MiB / 34.4% CPU) | ### MITM (TWP only) -Same Client×Origin wires with interception on (`compare-product` [33480574506](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33480574506)). **Lite** = no-op handlers (unchanged-lite finish). **Full** = append-only header mutation (harness: one probe header each way; product: generic append-only relay via `MitmCompressedRelayHelper`). nginx/YARP cannot MITM. **Lite÷Reverse** / **Full÷Reverse** vs bare reverse (same job). Completion gate: Lite and Full ≥ **0.70×** reverse sustain @ c=64 (median of 3 GHA runs). +Same Client×Origin wires with interception on (`compare-product` [34441526151](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441526151)). **Lite** = no-op handlers (unchanged-lite finish). **Full** = append-only header mutation (harness: one probe header each way; product: generic append-only relay via `MitmCompressedRelayHelper`). nginx/HAProxy/Envoy/YARP cannot MITM. **Lite÷Reverse** / **Full÷Reverse** vs bare reverse (**same job / comparison-group shard**). Completion gate: Lite ≥ **0.50×** and Full ≥ **0.50×** reverse sustain @ c=64 (median of 3 GHA runs); reverse TWP÷YARP ≥ **0.70×** (no terminate-peer gate). **v1 append-only relay (2026-08-27):** Pre-fix H2→H2 Full÷Reverse was **0.13–0.16×** ([32960766249](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32960766249)). Post-fix @ `df172718`: H2 plain→H2 plain Full **0.77–0.79×**, H3→H1 Full **0.91–0.93×**, all MITM arms ≥ **0.70×** on median of [33041445371](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33041445371), [33055267086](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33055267086), [33055272140](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33055272140). -**v2 drop-only + non-unique append (2026-08-27):** `MitmStaticRebuildHelper` rebuilds static HPACK/QPACK after 1–4 unique header drops; trailing non-unique appends stay on compressed relay. @ `af6feb9c`: all MITM arms ≥ **0.70×** on GHA median ([33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622), [33105885748](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33105885748) Linux; [33087085235](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087085235), [33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622) Windows). H2 plain→H2 plain Full **0.77–0.79×** (Win) / **0.78×** (Lin). +**v2 drop-only + non-unique append (2026-08-27):** `MitmStaticRebuildHelper` rebuilds static HPACK/QPACK after 1–4 unique header drops; trailing non-unique appends stay on compressed relay. @ `9a2b3a1e`: all MITM arms ≥ **0.70×** on GHA median ([33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622), [33105885748](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33105885748) Linux; [33087085235](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087085235), [33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466), [33087091622](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087091622) Windows). H2 plain→H2 plain Full **0.77–0.79×** (Win) / **0.78×** (Lin). | Client | Origin | Lite sustain | Full sustain | Lite÷Reverse | Full÷Reverse | |---|---|---:|---:|---:|---:| -| HTTP/1 · plain | HTTP/1 · plain | **15679**
                          (0 MiB / 0% CPU) | **19869**
                          (0 MiB / 0% CPU) | **0.99×** | **1.26×** | -| HTTP/1 · plain | HTTP/1 · TLS | **14989**
                          (0 MiB / 0% CPU) | **14498**
                          (0 MiB / 0% CPU) | **1.14×** | **1.11×** | -| HTTP/1 · plain | HTTP/2 · plain | **17333**
                          (0 MiB / 0% CPU) | **18679**
                          (0 MiB / 0% CPU) | **0.88×** | **0.94×** | -| HTTP/1 · plain | HTTP/2 · TLS | **19932**
                          (0 MiB / 0% CPU) | **13799**
                          (0 MiB / 0% CPU) | **0.99×** | **0.69×** | -| HTTP/1 · plain | HTTP/3 · QUIC | **6082**
                          (0 MiB / 0% CPU) | **5241**
                          (0 MiB / 0% CPU) | **1.15×** | **0.99×** | -| HTTP/1 · TLS | HTTP/1 · plain | **11207**
                          (0 MiB / 0% CPU) | **11444**
                          (0 MiB / 0% CPU) | **1.04×** | **1.06×** | -| HTTP/1 · TLS | HTTP/1 · TLS | **9645**
                          (0 MiB / 0% CPU) | **10483**
                          (0 MiB / 0% CPU) | **0.96×** | **1.05×** | -| HTTP/1 · TLS | HTTP/2 · plain | **14959**
                          (0 MiB / 0% CPU) | **10031**
                          (0 MiB / 0% CPU) | **1.04×** | **0.69×** | -| HTTP/1 · TLS | HTTP/2 · TLS | **12098**
                          (0 MiB / 0% CPU) | **9308**
                          (0 MiB / 0% CPU) | **1.16×** | **0.89×** | -| HTTP/1 · TLS | HTTP/3 · QUIC | **4164**
                          (0 MiB / 0% CPU) | **3717**
                          (0 MiB / 0% CPU) | **1.07×** | **0.96×** | -| HTTP/2 · plain | HTTP/1 · plain | **18182**
                          (0 MiB / 0% CPU) | **19960**
                          (0 MiB / 0% CPU) | **0.84×** | **0.92×** | -| HTTP/2 · plain | HTTP/1 · TLS | **16112**
                          (0 MiB / 0% CPU) | **16783**
                          (0 MiB / 0% CPU) | **1.07×** | **1.12×** | -| HTTP/2 · plain | HTTP/2 · plain | **27174**
                          (0 MiB / 0% CPU) | **28785**
                          (0 MiB / 0% CPU) | **0.78×** | **0.82×** | -| HTTP/2 · plain | HTTP/2 · TLS | **27651**
                          (0 MiB / 0% CPU) | **27267**
                          (0 MiB / 0% CPU) | **0.75×** | **0.74×** | -| HTTP/2 · plain | HTTP/3 · QUIC | **5291**
                          (0 MiB / 0% CPU) | **6012**
                          (0 MiB / 0% CPU) | **0.84×** | **0.95×** | -| HTTP/2 · TLS | HTTP/1 · plain | **24503**
                          (0 MiB / 0% CPU) | **20445**
                          (0 MiB / 0% CPU) | **1.18×** | **0.99×** | -| HTTP/2 · TLS | HTTP/1 · TLS | **19702**
                          (0 MiB / 0% CPU) | **16361**
                          (0 MiB / 0% CPU) | **1.11×** | **0.92×** | -| HTTP/2 · TLS | HTTP/2 · plain | **31396**
                          (0 MiB / 0% CPU) | **27465**
                          (0 MiB / 0% CPU) | **0.89×** | **0.78×** | -| HTTP/2 · TLS | HTTP/2 · TLS | **29819**
                          (0 MiB / 0% CPU) | **24511**
                          (0 MiB / 0% CPU) | **0.97×** | **0.79×** | -| HTTP/2 · TLS | HTTP/3 · QUIC | **7393**
                          (0 MiB / 0% CPU) | **5565**
                          (0 MiB / 0% CPU) | **1.24×** | **0.93×** | -| HTTP/3 · QUIC | HTTP/1 · plain | **6378**
                          (0 MiB / 0% CPU) | **5062**
                          (0 MiB / 0% CPU) | **1.17×** | **0.93×** | -| HTTP/3 · QUIC | HTTP/1 · TLS | **6126**
                          (0 MiB / 0% CPU) | **4648**
                          (0 MiB / 0% CPU) | **1.13×** | **0.86×** | -| HTTP/3 · QUIC | HTTP/2 · plain | **5622**
                          (0 MiB / 0% CPU) | **5710**
                          (0 MiB / 0% CPU) | **1.05×** | **1.07×** | -| HTTP/3 · QUIC | HTTP/2 · TLS | **6332**
                          (0 MiB / 0% CPU) | **5100**
                          (0 MiB / 0% CPU) | **1.15×** | **0.92×** | -| HTTP/3 · QUIC | HTTP/3 · QUIC | **3701**
                          (0 MiB / 0% CPU) | **3760**
                          (0 MiB / 0% CPU) | **0.92×** | **0.94×** | +| HTTP/1 · plain | HTTP/1 · plain | **11715**
                          (84 MiB / 35.6% CPU) | **11001**
                          (84 MiB / 31.8% CPU) | **0.97×** | **0.91×** | +| HTTP/1 · plain | HTTP/1 · TLS | **7193**
                          (94 MiB / 31.1% CPU) | **8678**
                          (94 MiB / 35.3% CPU) | **0.65×** | **0.78×** | +| HTTP/1 · plain | HTTP/2 · plain | **14866**
                          (89 MiB / 36.3% CPU) | **13716**
                          (90 MiB / 35.7% CPU) | **0.77×** | **0.71×** | +| HTTP/1 · plain | HTTP/2 · TLS | **11164**
                          (109 MiB / 33.4% CPU) | **9186**
                          (108 MiB / 32.6% CPU) | **1.12×** | **0.93×** | +| HTTP/1 · plain | HTTP/3 · QUIC | **3459**
                          (91 MiB / 39.6% CPU) | **3710**
                          (90 MiB / 40.3% CPU) | **0.94×** | **1.01×** | +| HTTP/1 · TLS | HTTP/1 · plain | **8052**
                          (95 MiB / 30.3% CPU) | **10123**
                          (96 MiB / 33.1% CPU) | **0.57×** | **0.71×** | +| HTTP/1 · TLS | HTTP/1 · TLS | **6914**
                          (98 MiB / 36% CPU) | **9190**
                          (98 MiB / 33.3% CPU) | **1.13×** | **1.5×** | +| HTTP/1 · TLS | HTTP/2 · plain | **8443**
                          (123 MiB / 35.7% CPU) | **8055**
                          (102 MiB / 33.1% CPU) | **1.02×** | **0.97×** | +| HTTP/1 · TLS | HTTP/2 · TLS | **8738**
                          (133 MiB / 33.2% CPU) | **7260**
                          (157 MiB / 32.6% CPU) | **1.03×** | **0.86×** | +| HTTP/1 · TLS | HTTP/3 · QUIC | **2633**
                          (105 MiB / 42.6% CPU) | **2842**
                          (102 MiB / 46.3% CPU) | **0.98×** | **1.06×** | +| HTTP/2 · plain | HTTP/1 · plain | **13742**
                          (88 MiB / 38.4% CPU) | **13895**
                          (90 MiB / 37.5% CPU) | **1.11×** | **1.12×** | +| HTTP/2 · plain | HTTP/1 · TLS | **14725**
                          (96 MiB / 40.8% CPU) | **10701**
                          (96 MiB / 35.5% CPU) | **1.07×** | **0.77×** | +| HTTP/2 · plain | HTTP/2 · plain | **24126**
                          (76 MiB / 29.9% CPU) | **24457**
                          (76 MiB / 29.7% CPU) | **0.78×** | **0.79×** | +| HTTP/2 · plain | HTTP/2 · TLS | **23664**
                          (83 MiB / 31.7% CPU) | **24893**
                          (81 MiB / 31% CPU) | **0.96×** | **1.01×** | +| HTTP/2 · plain | HTTP/3 · QUIC | **4822**
                          (96 MiB / 45% CPU) | **5035**
                          (95 MiB / 45.9% CPU) | **1×** | **1.04×** | +| HTTP/2 · TLS | HTTP/1 · plain | **13114**
                          (92 MiB / 38.3% CPU) | **12954**
                          (91 MiB / 38.1% CPU) | **0.95×** | **0.94×** | +| HTTP/2 · TLS | HTTP/1 · TLS | **9205**
                          (97 MiB / 36% CPU) | **9788**
                          (97 MiB / 39.1% CPU) | **1×** | **1.07×** | +| HTTP/2 · TLS | HTTP/2 · plain | **28081**
                          (86 MiB / 32% CPU) | **24916**
                          (87 MiB / 30.4% CPU) | **0.59×** | **0.53×** | +| HTTP/2 · TLS | HTTP/2 · TLS | **22874**
                          (87 MiB / 29.6% CPU) | **29489**
                          (89 MiB / 32% CPU) | **0.79×** | **1.02×** | +| HTTP/2 · TLS | HTTP/3 · QUIC | **4679**
                          (105 MiB / 37.9% CPU) | **4453**
                          (106 MiB / 39.3% CPU) | **1.11×** | **1.05×** | +| HTTP/3 · QUIC | HTTP/1 · plain | **4000**
                          (102 MiB / 35% CPU) | **4530**
                          (102 MiB / 38.8% CPU) | **0.69×** | **0.78×** | +| HTTP/3 · QUIC | HTTP/1 · TLS | **3931**
                          (116 MiB / 38.3% CPU) | **3992**
                          (115 MiB / 40% CPU) | **1.06×** | **1.07×** | +| HTTP/3 · QUIC | HTTP/2 · plain | **4272**
                          (98 MiB / 39% CPU) | **4718**
                          (98 MiB / 39.8% CPU) | **0.93×** | **1.03×** | +| HTTP/3 · QUIC | HTTP/2 · TLS | **5183**
                          (106 MiB / 38.5% CPU) | **4838**
                          (108 MiB / 38.9% CPU) | **0.94×** | **0.87×** | +| HTTP/3 · QUIC | HTTP/3 · QUIC | **3211**
                          (94 MiB / 37.3% CPU) | **3887**
                          (95 MiB / 37.3% CPU) | **0.93×** | **1.12×** | ## Editions (CLI / Plus / Intercept) -**Note:** `twp-reverse-http1` and other library rows use Core with **probe-tuned** settings (no logging, no Via header, probe-warmed certs). Edition rows use `titanium run -c twp.yaml` **product defaults** — prefer the ÷baseline ratio column over absolute RPS. Inspector GUI is not spawnable in the harness; session-path overhead is `twp-cli-intercept-http1` (route `RequestHeaderSet` transform). - -Gates prioritize **SLO survival under load** and **reasonable overhead vs baseline**. Pre-origin Plus middleware (CIDR/WAF/JWT/rate-limit) runs on H1 terminate-lite without `SessionEventArgs`; JWT caches successful bearer validations. Thresholds — see [PERF-GATES.md](https://github.com/justcoding121/titanium-web-proxy/blob/develop/tools/RpsLoadProbe/PERF-GATES.md). - -Median of **3** repeats @ `6d2a7c9d`. Source: Actions [33259699099](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33259699099) (`compare-editions`). Warmup 2s / measure 8s; concurrency 8–64; sustain = median peak RPS among SLO-pass steps @ **c=64**. **RPS cells** include `(MiB / CPU%)` at that step. +**Note:** `twp-reverse-http1` and other library rows use Core with **probe-tuned** settings (no logging, no Via header, probe-warmed certs). Edition rows use `titanium run -c twp.yaml` **product defaults** — prefer the ÷baseline ratio column over absolute RPS. Inspector GUI is not spawnable in the harness; session-path overhead is `twp-cli-intercept-http1` (route `RequestHeaderSet` transform). Pre-origin Plus middleware (CIDR/WAF/JWT/rate-limit) runs on H1 terminate-lite without `SessionEventArgs`; JWT caches successful bearer validations. Maintainer gate thresholds live under [Maintainer notes](#maintainer-notes). -```powershell -pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-editions -pwsh tools/RpsLoadProbe/validate-edition-gates.ps1 -CsvPath tools/RpsLoadProbe/results/rps-ramp-*.csv -``` +Median of **3** repeats @ `6d2a7c9d`. Source: Actions [33259699099](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33259699099). Warmup 2s / measure 8s; concurrency 8–64; sustain = median peak RPS among SLO-pass steps @ **c=64**. **RPS cells** include `(MiB / CPU%)` at that step. | Arm | Win sustain | Win peak | Linux sustain | Linux peak | Win÷ | Lin÷ | Gate | |---|---:|---:|---:|---:|---:|---:|---:| @@ -448,24 +417,7 @@ pwsh tools/RpsLoadProbe/validate-edition-gates.ps1 -CsvPath tools/RpsLoadProbe/r ## Cross-version (7.0 vs 6.0) -Gate 2 prerequisite before the `v7.0.0` tag: run `compare-cross-version` (reverse matrix, **routes unset**) on `develop` and compare against committed 6.0 baselines from GHA [33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466) (`tools/RpsLoadProbe/results/baseline-6.0-win.csv` / `baseline-6.0-linux.csv`). - -| Gate | Threshold | -|------|-----------| -| Peer-normalized RPS (when YARP peer exists) | `(TWP÷YARP)_7 ÷ (TWP÷YARP)_6 ≥ 0.90` **or** current `(TWP÷YARP) ≥ 0.90` | -| Absolute RPS floor (TWP arms) | `7.0 ÷ 6.0 ≥ 0.70` (runner heat — peers move with the box) | -| RSS | `7.0 ÷ 6.0 ≤ 1.20` per TWP arm @ c=64 | - -nginx/YARP absolute RPS is **not** gated. See [`validate-cross-version.ps1`](https://github.com/justcoding121/titanium-web-proxy/blob/develop/tools/RpsLoadProbe/validate-cross-version.ps1). - -```powershell -pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-cross-version -pwsh tools/RpsLoadProbe/validate-cross-version.ps1 ` - -BaselineCsv tools/RpsLoadProbe/results/baseline-6.0-win.csv ` - -CurrentCsv tools/RpsLoadProbe/results/rps-ramp-*.csv -``` - -Median of **3** @ `0ef6d4dd`. Source: Actions [33270571908](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33270571908). Sustain RPS @ **c=64** (SLO-pass median). Absolute 7.0÷6.0 ≈ **0.96–1.00×** on same-protocol arms; peer-norm ≈ **0.90–1.03×**. +Same reverse matrix measured on Titanium 7.0 versus committed 6.0 baselines ([33087088466](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33087088466)). Median of **3** @ `0ef6d4dd`. Source: Actions [33270571908](https://github.com/justcoding121/titanium-web-proxy/actions/runs/33270571908). Sustain RPS @ **c=64**. Absolute 7.0÷6.0 ≈ **0.96–1.00×** on same-protocol arms; peer-normalized ratios ≈ **0.90–1.03×**. | Arm | Win 6.0 | Win 7.0 | Win÷ | Linux 6.0 | Linux 7.0 | Lin÷ | |---|---:|---:|---:|---:|---:|---:| @@ -478,126 +430,170 @@ Median of **3** @ `0ef6d4dd`. Source: Actions [33270571908](https://github.com/j | `yarp-reverse-http1` (peer) | **27504** | **27254** | **0.99×** | **27713** | **27102** | **0.98×** | | `yarp-reverse-http2` (peer) | **35553** | **35189** | **0.99×** | **29111** | **28733** | **0.99×** | -`validate-cross-version.ps1` **passed** on both OS CSVs (RSS floor **1.20**; peer-norm also passes when current TWP÷YARP ≥ **0.90**). MITM arms live in `compare-product` / `compare-mitm`, not this reverse-only matrix. - +Both OS CSVs passed the cross-version check for this run. MITM arms are measured with the product reverse matrix, not this reverse-only comparison. ## Heavier reverse workloads -Separate from the tiny-GET matrix. Same measurement environments. Modes: `compare-bodies`, `compare-post`, `compare-lossy`, `compare-tls-cost`, `compare-arch` in [RpsLoadProbe](https://github.com/justcoding121/titanium-web-proxy/tree/develop/tools/RpsLoadProbe). Dispatch each independently via `workflow_dispatch` (no need to re-run full `compare-product`). **PUT with the same body is the same proxy work as POST; DELETE with no body matches GET** — only POST is published. Bodies/POST/lossy stay **half-duplex**. `compare-arch` is the slow-consumer / early-response / duplex set. Laptop numbers are on [Performance Local Lab](Performance-Local-Lab#architecture-sensitive); CI medians go in the tables below. +Same runners and harness as the tiny-GET tables, but with larger bodies, POST, lossy links, TLS cost, and architecture-sensitive paths (slow consumer / early response / duplex). **PUT with the same body is the same proxy work as POST; DELETE with no body matches GET** — only POST is published. Bodies/POST/lossy stay **half-duplex**. Laptop numbers are on [Performance Local Lab](Performance-Local-Lab#architecture-sensitive). How maintainers refresh these tables (modes, shards, paste scripts) is under [Maintainer notes](#maintainer-notes). **Larger-body check:** 64 / 256 KiB H2 TLS→H2 TLS is where body copy dominates headers — Titanium is **behind** YARP here (~0.69–0.76× at 64 KiB), unlike the tiny-GET H2↔H2 medals above. -Lossy link = **userspace** shim (not kernel `netem`): TCP gets per-buffer delay + occasional whole-connection stalls (honest HOL for multiplexed H2); UDP gets per-datagram delay + drops (QUIC). `compare-lossy` publishes H1/H2/H3; H3 is where the protocol design is supposed to matter. +Lossy link = **userspace** delay/drop shim (not kernel `netem`): TCP gets per-buffer delay + occasional whole-connection stalls (honest head-of-line for multiplexed HTTP/2); UDP gets per-datagram delay + drops (QUIC). Lossy tables publish HTTP/1, HTTP/2, and HTTP/3. ### Windows — heavier reverse GET (64 KiB / 256 KiB) -Median of **3** repeats on `windows-latest` @ `9d7c2966`. Source: Actions [32871900682](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32871900682) (`compare-bodies`). Warmup 2s / measure 8s. **RPS cells** include `(MiB / CPU%)` footprints. - -| Body | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak | -|---|---|---|---:|---:|---:|---:|---:|---:| -| 64 KiB | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **10,297**
                          (99 MiB / 45.5% CPU) | **10,564**
                          (99 MiB / 45.5% CPU) | **717**
                          (137 MiB / 24.6% CPU) | **739**
                          (137 MiB / 24.6% CPU) | **9,283**
                          (110 MiB / 44.7% CPU) | **9,413**
                          (110 MiB / 44.7% CPU) | -| 64 KiB | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **9,135**
                          (168 MiB / 46.2% CPU) | **9,135**
                          (168 MiB / 46.2% CPU) | **626**
                          (137 MiB / 24.8% CPU) | **637**
                          (137 MiB / 24.8% CPU) | **7,533**
                          (108 MiB / 49.7% CPU) | **7,745**
                          (108 MiB / 49.7% CPU) | -| 64 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **4,728**
                          (108 MiB / 39.8% CPU) | **4,900**
                          (108 MiB / 39.8% CPU) | *Not possible* (no QUIC) | *Not possible* (no QUIC) | **3,995**
                          (177 MiB / 49.0% CPU) | **4,035**
                          (177 MiB / 49.0% CPU) | -| 256 KiB | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **3,056**
                          (88 MiB / 42.6% CPU) | **3,271**
                          (88 MiB / 42.6% CPU) | **191**
                          (136 MiB / 24.8% CPU) | **194**
                          (136 MiB / 24.8% CPU) | **2,488**
                          (115 MiB / 45.7% CPU) | **2,839**
                          (115 MiB / 45.7% CPU) | -| 256 KiB | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **2,568**
                          (115 MiB / 38.9% CPU) | **2,639**
                          (115 MiB / 38.9% CPU) | **163**
                          (137 MiB / 24.9% CPU) | **163**
                          (137 MiB / 24.9% CPU) | **2,003**
                          (132 MiB / 41.6% CPU) | **2,003**
                          (132 MiB / 41.6% CPU) | -| 256 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **1,210**
                          (88 MiB / 39.3% CPU) | **1,254**
                          (88 MiB / 39.3% CPU) | *Not possible* (no QUIC) | *Not possible* (no QUIC) | **1,100**
                          (125 MiB / 43.9% CPU) | **1,136**
                          (125 MiB / 43.9% CPU) | +Median of **3** repeats on `windows-latest` @ `9a2b3a1e`. Source: Actions [34441570199](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441570199) (`compare-bodies`). Warmup 2s / measure 8s. **RPS cells** include `(MiB / CPU%)` footprints. + +| Body | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | HAProxy sustain | HAProxy peak | Envoy sustain | Envoy peak | YARP sustain | YARP peak | +|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| 64 KiB | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **13,191**
                          (122 MiB / 46.7% CPU) | **13,191**
                          (122 MiB / 46.7% CPU) | **924**
                          (142 MiB / 24.8% CPU) | **924**
                          (142 MiB / 24.8% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **11,473**
                          (133 MiB / 47.5% CPU) | **11,473**
                          (133 MiB / 47.5% CPU) | +| 64 KiB | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **11,769**
                          (173 MiB / 46.2% CPU) | **11,769**
                          (173 MiB / 46.2% CPU) | **898**
                          (142 MiB / 24.8% CPU) | **898**
                          (142 MiB / 24.8% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **9,560**
                          (135 MiB / 48.1% CPU) | **9,560**
                          (135 MiB / 48.1% CPU) | +| 64 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **5,958**
                          (139 MiB / 41.5% CPU) | **5,958**
                          (139 MiB / 41.5% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **5,060**
                          (185 MiB / 51.0% CPU) | **5,060**
                          (185 MiB / 51.0% CPU) | +| 256 KiB | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **3,693**
                          (136 MiB / 42.1% CPU) | **3,693**
                          (136 MiB / 42.1% CPU) | **241**
                          (142 MiB / 24.9% CPU) | **241**
                          (142 MiB / 24.9% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **3,325**
                          (130 MiB / 47.2% CPU) | **3,325**
                          (130 MiB / 47.2% CPU) | +| 256 KiB | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **3,784**
                          (149 MiB / 36.2% CPU) | **3,784**
                          (149 MiB / 36.2% CPU) | **230**
                          (142 MiB / 24.8% CPU) | **230**
                          (142 MiB / 24.8% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **2,625**
                          (135 MiB / 45.2% CPU) | **2,625**
                          (135 MiB / 45.2% CPU) | +| 256 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **1,535**
                          (111 MiB / 40.4% CPU) | **1,535**
                          (111 MiB / 40.4% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **1,385**
                          (169 MiB / 44.6% CPU) | **1,385**
                          (169 MiB / 44.6% CPU) | +| 64 KiB | HTTP/2 · plain | HTTP/1 · plain | 🥇 **14,010**
                          (174 MiB / 40.5% CPU) | **14,010**
                          (174 MiB / 40.5% CPU) | **5,865**
                          (127 MiB / 24.0% CPU) | **5,865**
                          (127 MiB / 24.0% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **12,443**
                          (111 MiB / 39.6% CPU) | **12,443**
                          (111 MiB / 39.6% CPU) | +| 64 KiB | HTTP/2 · TLS | HTTP/2 · plain | **6,319**
                          (79 MiB / 38.1% CPU) | **6,319**
                          (79 MiB / 38.1% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | 🥇 **9,493**
                          (131 MiB / 50.3% CPU) | **9,493**
                          (131 MiB / 50.3% CPU) | +| 64 KiB | HTTP/2 · TLS | HTTP/2 · TLS | **5,660**
                          (80 MiB / 36.4% CPU) | **5,660**
                          (80 MiB / 36.4% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | 🥇 **8,201**
                          (141 MiB / 47.6% CPU) | **8,201**
                          (141 MiB / 47.6% CPU) | +| 64 KiB | HTTP/3 · QUIC | HTTP/2 · TLS | **4,657**
                          (129 MiB / 46.1% CPU) | **4,657**
                          (129 MiB / 46.1% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | 🥇 **5,021**
                          (195 MiB / 47.6% CPU) | **5,021**
                          (195 MiB / 47.6% CPU) | +| 64 KiB | HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **6,149**
                          (148 MiB / 42.2% CPU) | **6,149**
                          (148 MiB / 42.2% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **4,496**
                          (191 MiB / 49.0% CPU) | **4,496**
                          (191 MiB / 49.0% CPU) | +| 256 KiB | HTTP/2 · plain | HTTP/1 · plain | 🥇 **4,457**
                          (144 MiB / 31.8% CPU) | **4,457**
                          (144 MiB / 31.8% CPU) | **1,759**
                          (127 MiB / 23.6% CPU) | **1,759**
                          (127 MiB / 23.6% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **3,719**
                          (123 MiB / 37.8% CPU) | **3,719**
                          (123 MiB / 37.8% CPU) | +| 256 KiB | HTTP/2 · TLS | HTTP/2 · plain | **1,976**
                          (84 MiB / 29.9% CPU) | **1,976**
                          (84 MiB / 29.9% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | 🥇 **2,325**
                          (169 MiB / 46.5% CPU) | **2,325**
                          (169 MiB / 46.5% CPU) | +| 256 KiB | HTTP/2 · TLS | HTTP/2 · TLS | **1,758**
                          (87 MiB / 29.7% CPU) | **1,758**
                          (87 MiB / 29.7% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | 🥇 **2,109**
                          (153 MiB / 44.9% CPU) | **2,109**
                          (153 MiB / 44.9% CPU) | +| 256 KiB | HTTP/3 · QUIC | HTTP/2 · TLS | **1,164**
                          (153 MiB / 43.0% CPU) | **1,164**
                          (153 MiB / 43.0% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | 🥇 **1,319**
                          (186 MiB / 44.8% CPU) | **1,319**
                          (186 MiB / 44.8% CPU) | +| 256 KiB | HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **1,404**
                          (146 MiB / 41.0% CPU) | **1,404**
                          (146 MiB / 41.0% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **1,269**
                          (196 MiB / 44.8% CPU) | **1,269**
                          (196 MiB / 44.8% CPU) | nginx/Windows collapses on large reverse bodies in this harness; treat as same-OS only. H1 TLS **64 KiB** ≈ **1.11×** YARP; **256 KiB** ≈ **1.23×**. H2→H1 64 KiB ≈ **1.21×**; H3→H1 64 KiB ≈ **1.18×**. ### Linux — heavier reverse GET (64 KiB / 256 KiB) -Median of **3** repeats @ `9d7c2966`. Source: Actions [32871900682](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32871900682) (`compare-bodies`). Warmup 2s / measure 8s. - -| Body | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak | -|---|---|---|---:|---:|---:|---:|---:|---:| -| 64 KiB | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **7,902**
                          (170 MiB / 44.4% CPU) | **7,902**
                          (170 MiB / 44.4% CPU) | **5,518**
                          (96 MiB / 52.5% CPU) | **5,518**
                          (96 MiB / 52.5% CPU) | **6,420**
                          (158 MiB / 48.8% CPU) | **6,420**
                          (158 MiB / 48.8% CPU) | -| 64 KiB | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **5,831**
                          (218 MiB / 38.9% CPU) | **5,831**
                          (218 MiB / 38.9% CPU) | **1,790**
                          (95 MiB / 23.6% CPU) | **1,925**
                          (95 MiB / 23.6% CPU) | **4,739**
                          (161 MiB / 45.9% CPU) | **4,739**
                          (161 MiB / 45.9% CPU) | -| 64 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **5,384**
                          (177 MiB / 44.5% CPU) | **5,384**
                          (177 MiB / 44.5% CPU) | **1,614**
                          (113 MiB / 22.0% CPU) | **1,718**
                          (113 MiB / 22.0% CPU) | **4,236**
                          (219 MiB / 51.1% CPU) | **4,236**
                          (219 MiB / 51.1% CPU) | -| 256 KiB | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **2,742**
                          (121 MiB / 37.2% CPU) | **2,742**
                          (121 MiB / 37.2% CPU) | **1,732**
                          (95 MiB / 53.3% CPU) | **1,732**
                          (95 MiB / 53.3% CPU) | **2,142**
                          (171 MiB / 45.9% CPU) | **2,142**
                          (171 MiB / 45.9% CPU) | -| 256 KiB | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **1,535**
                          (206 MiB / 32.7% CPU) | **1,535**
                          (206 MiB / 32.7% CPU) | **538**
                          (96 MiB / 18.6% CPU) | **538**
                          (96 MiB / 18.6% CPU) | **1,319**
                          (164 MiB / 42.7% CPU) | **1,330**
                          (164 MiB / 42.7% CPU) | -| 256 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **1,432**
                          (148 MiB / 43.1% CPU) | **1,432**
                          (148 MiB / 43.1% CPU) | **448**
                          (107 MiB / 23.3% CPU) | **448**
                          (107 MiB / 23.3% CPU) | **1,270**
                          (218 MiB / 47.4% CPU) | **1,270**
                          (218 MiB / 47.4% CPU) | +Median of **3** repeats @ `9a2b3a1e`. Source: Actions [34441570199](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441570199) (`compare-bodies`). Warmup 2s / measure 8s. + +| Body | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | HAProxy sustain | HAProxy peak | Envoy sustain | Envoy peak | YARP sustain | YARP peak | +|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| 64 KiB | HTTP/1 · TLS | HTTP/1 · plain | **8,010**
                          (176 MiB / 45.3% CPU) | **8,010**
                          (176 MiB / 45.3% CPU) | **5,555**
                          (100 MiB / 52.4% CPU) | **5,555**
                          (100 MiB / 52.4% CPU) | 🥇 **9,012**
                          (84 MiB / 40.6% CPU) | **9,012**
                          (84 MiB / 40.6% CPU) | **7,524**
                          (131 MiB / 45.8% CPU) | **7,524**
                          (131 MiB / 45.8% CPU) | **6,504**
                          (169 MiB / 48.4% CPU) | **6,504**
                          (169 MiB / 48.4% CPU) | +| 64 KiB | HTTP/2 · TLS | HTTP/1 · plain | **9,850**
                          (231 MiB / 39.4% CPU) | **9,850**
                          (231 MiB / 39.4% CPU) | **3,314**
                          (103 MiB / 10.9% CPU) | **3,314**
                          (103 MiB / 10.9% CPU) | 🥇 **10,145**
                          (87 MiB / 24.0% CPU) | **10,145**
                          (87 MiB / 24.0% CPU) | **8,389**
                          (141 MiB / 23.7% CPU) | **8,389**
                          (141 MiB / 23.7% CPU) | **8,231**
                          (164 MiB / 46.6% CPU) | **8,231**
                          (164 MiB / 46.6% CPU) | +| 64 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **5,646**
                          (184 MiB / 44.2% CPU) | **5,646**
                          (184 MiB / 44.2% CPU) | **0**
                          (129 MiB / 22.4% CPU) | **1,678**
                          (129 MiB / 22.4% CPU) | *Not measured* | *Not measured* | *Not measured* | *Not measured* | **4,421**
                          (231 MiB / 51.5% CPU) | **4,421**
                          (231 MiB / 51.5% CPU) | +| 256 KiB | HTTP/1 · TLS | HTTP/1 · plain | **2,766**
                          (128 MiB / 37.7% CPU) | **2,766**
                          (128 MiB / 37.7% CPU) | **1,735**
                          (100 MiB / 53.6% CPU) | **1,735**
                          (100 MiB / 53.6% CPU) | 🥇 **2,974**
                          (83 MiB / 34.0% CPU) | **2,974**
                          (83 MiB / 34.0% CPU) | **2,701**
                          (145 MiB / 33.9% CPU) | **2,701**
                          (145 MiB / 33.9% CPU) | **2,164**
                          (166 MiB / 45.8% CPU) | **2,164**
                          (166 MiB / 45.8% CPU) | +| 256 KiB | HTTP/2 · TLS | HTTP/1 · plain | **2,550**
                          (209 MiB / 32.3% CPU) | **2,550**
                          (209 MiB / 32.3% CPU) | **1,728**
                          (103 MiB / 19.0% CPU) | **1,728**
                          (103 MiB / 19.0% CPU) | 🥇 **3,276**
                          (86 MiB / 19.5% CPU) | **3,276**
                          (86 MiB / 19.5% CPU) | **3,128**
                          (168 MiB / 20.2% CPU) | **3,128**
                          (168 MiB / 20.2% CPU) | **2,201**
                          (163 MiB / 43.1% CPU) | **2,201**
                          (163 MiB / 43.1% CPU) | +| 256 KiB | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **1,458**
                          (159 MiB / 43.3% CPU) | **1,458**
                          (159 MiB / 43.3% CPU) | **0**
                          (120 MiB / 20.0% CPU) | **24**
                          (120 MiB / 20.0% CPU) | *Not measured* | *Not measured* | *Not measured* | *Not measured* | **1,278**
                          (218 MiB / 48.1% CPU) | **1,278**
                          (218 MiB / 48.1% CPU) | +| 64 KiB | HTTP/2 · plain | HTTP/1 · plain | 🥇 **13,722**
                          (251 MiB / 41.5% CPU) | **13,722**
                          (251 MiB / 41.5% CPU) | **4,929**
                          (80 MiB / 10.4% CPU) | **4,929**
                          (80 MiB / 10.4% CPU) | **13,114**
                          (72 MiB / 23.9% CPU) | **13,114**
                          (72 MiB / 23.9% CPU) | **11,084**
                          (133 MiB / 23.5% CPU) | **11,084**
                          (133 MiB / 23.5% CPU) | **13,042**
                          (143 MiB / 45.4% CPU) | **13,042**
                          (143 MiB / 45.4% CPU) | +| 64 KiB | HTTP/2 · TLS | HTTP/2 · plain | **3,419**
                          (101 MiB / 40.6% CPU) | **3,419**
                          (101 MiB / 40.6% CPU) | *Not possible* | *Not possible* | **0**
                          (83 MiB / 19.9% CPU) | **0**
                          (83 MiB / 19.9% CPU) | **0**
                          (126 MiB / 22.4% CPU) | **0**
                          (126 MiB / 22.4% CPU) | 🥇 **4,578**
                          (185 MiB / 46.3% CPU) | **4,578**
                          (185 MiB / 46.3% CPU) | +| 64 KiB | HTTP/2 · TLS | HTTP/2 · TLS | **4,682**
                          (104 MiB / 37.3% CPU) | **4,682**
                          (104 MiB / 37.3% CPU) | *Not possible* | *Not possible* | **3,969**
                          (83 MiB / 24.5% CPU) | **3,969**
                          (83 MiB / 24.5% CPU) | 🥇 **6,266**
                          (154 MiB / 23.0% CPU) | **6,266**
                          (154 MiB / 23.0% CPU) | **6,174**
                          (184 MiB / 46.0% CPU) | **6,174**
                          (184 MiB / 46.0% CPU) | +| 64 KiB | HTTP/3 · QUIC | HTTP/2 · TLS | **2,941**
                          (168 MiB / 52.7% CPU) | **2,941**
                          (168 MiB / 52.7% CPU) | *Not possible* | *Not possible* | *Not measured* | *Not measured* | *Not measured* | *Not measured* | 🥇 **3,040**
                          (234 MiB / 48.9% CPU) | **3,040**
                          (234 MiB / 48.9% CPU) | +| 64 KiB | HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **6,442**
                          (214 MiB / 41.6% CPU) | **6,442**
                          (214 MiB / 41.6% CPU) | **0**
                          (145 MiB / 17.0% CPU) | **2,278**
                          (145 MiB / 17.0% CPU) | *Not measured* | *Not measured* | *Not measured* | *Not measured* | **4,761**
                          (248 MiB / 49.0% CPU) | **4,761**
                          (248 MiB / 49.0% CPU) | +| 256 KiB | HTTP/2 · plain | HTTP/1 · plain | **3,918**
                          (201 MiB / 33.0% CPU) | **3,918**
                          (201 MiB / 33.0% CPU) | **1,946**
                          (80 MiB / 14.2% CPU) | **1,946**
                          (80 MiB / 14.2% CPU) | **4,300**
                          (72 MiB / 18.6% CPU) | **4,300**
                          (72 MiB / 18.6% CPU) | 🥇 **4,479**
                          (153 MiB / 21.0% CPU) | **4,479**
                          (153 MiB / 21.0% CPU) | **3,945**
                          (157 MiB / 38.1% CPU) | **3,945**
                          (157 MiB / 38.1% CPU) | +| 256 KiB | HTTP/2 · TLS | HTTP/2 · plain | **1,139**
                          (111 MiB / 33.5% CPU) | **1,139**
                          (111 MiB / 33.5% CPU) | *Not possible* | *Not possible* | **0**
                          (81 MiB / 19.8% CPU) | **0**
                          (81 MiB / 19.8% CPU) | **0**
                          (126 MiB / 22.3% CPU) | **0**
                          (126 MiB / 22.3% CPU) | 🥇 **1,293**
                          (186 MiB / 42.8% CPU) | **1,293**
                          (186 MiB / 42.8% CPU) | +| 256 KiB | HTTP/2 · TLS | HTTP/2 · TLS | **1,436**
                          (114 MiB / 28.9% CPU) | **1,436**
                          (114 MiB / 28.9% CPU) | *Not possible* | *Not possible* | **1,114**
                          (89 MiB / 24.3% CPU) | **1,114**
                          (89 MiB / 24.3% CPU) | 🥇 **1,826**
                          (160 MiB / 22.8% CPU) | **1,826**
                          (160 MiB / 22.8% CPU) | **1,734**
                          (185 MiB / 42.8% CPU) | **1,734**
                          (185 MiB / 42.8% CPU) | +| 256 KiB | HTTP/3 · QUIC | HTTP/2 · TLS | **837**
                          (187 MiB / 51.5% CPU) | **837**
                          (187 MiB / 51.5% CPU) | *Not possible* | *Not possible* | *Not measured* | *Not measured* | *Not measured* | *Not measured* | 🥇 **915**
                          (246 MiB / 46.9% CPU) | **915**
                          (246 MiB / 46.9% CPU) | +| 256 KiB | HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **1,745**
                          (189 MiB / 42.2% CPU) | **1,745**
                          (189 MiB / 42.2% CPU) | **0**
                          (143 MiB / 18.7% CPU) | **619**
                          (143 MiB / 18.7% CPU) | *Not measured* | *Not measured* | *Not measured* | *Not measured* | **1,487**
                          (249 MiB / 47.5% CPU) | **1,487**
                          (249 MiB / 47.5% CPU) | On this GHA pass TWP÷YARP H1 TLS ≈ **1.23×** (64 KiB) / **1.28×** (256 KiB); H2→H1 ≈ **1.23×** / **1.16×**; H3→H1 ≈ **1.27×** / **1.13×**. TWP÷nginx H1 TLS ≈ **1.43** / **1.58**. Absolute RPS swings by VM; prefer ratios. ### Windows — POST 64 KiB request + 64 KiB response -Median of **3** repeats on `windows-latest` @ `9d7c2966`. Source: Actions [32866714851](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32866714851) (`compare-post`). +Median of **3** repeats on `windows-latest` @ `9a2b3a1e`. Source: Actions [34441591377](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441591377) (`compare-post`). -| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak | -|---|---|---:|---:|---:|---:|---:|---:| -| HTTP/1 · TLS | HTTP/1 · plain | 🥇 **8,612**
                          (84 MiB / 42.0% CPU) | **9,133**
                          (84 MiB / 42.0% CPU) | **497**
                          (138 MiB / 24.8% CPU) | **538**
                          (138 MiB / 24.8% CPU) | **5,837**
                          (96 MiB / 56.0% CPU) | **6,283**
                          (96 MiB / 56.0% CPU) | -| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **6,241**
                          (196 MiB / 49.7% CPU) | **6,242**
                          (196 MiB / 49.7% CPU) | **486**
                          (139 MiB / 24.9% CPU) | **493**
                          (139 MiB / 24.9% CPU) | **5,106**
                          (118 MiB / 50.9% CPU) | **5,147**
                          (118 MiB / 50.9% CPU) | -| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **2,691**
                          (118 MiB / 43.7% CPU) | **2,937**
                          (118 MiB / 43.7% CPU) | *Not possible* (no QUIC) | *Not possible* (no QUIC) | **2,549**
                          (132 MiB / 49.5% CPU) | **2,747**
                          (132 MiB / 49.5% CPU) | +| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | HAProxy sustain | HAProxy peak | Envoy sustain | Envoy peak | YARP sustain | YARP peak | +|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| HTTP/1 · TLS | HTTP/1 · plain | 🥇 **5,883**
                          (94 MiB / 46.6% CPU) | **5,883**
                          (94 MiB / 46.6% CPU) | **352**
                          (142 MiB / 24.7% CPU) | **352**
                          (142 MiB / 24.7% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **4,093**
                          (137 MiB / 55.7% CPU) | **4,093**
                          (137 MiB / 55.7% CPU) | +| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **3,997**
                          (184 MiB / 49.3% CPU) | **3,997**
                          (184 MiB / 49.3% CPU) | **352**
                          (143 MiB / 24.7% CPU) | **352**
                          (143 MiB / 24.7% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **3,494**
                          (134 MiB / 52.1% CPU) | **3,494**
                          (134 MiB / 52.1% CPU) | +| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **2,039**
                          (162 MiB / 40.4% CPU) | **2,039**
                          (162 MiB / 40.4% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **1,893**
                          (203 MiB / 48.6% CPU) | **1,893**
                          (203 MiB / 48.6% CPU) | +| HTTP/2 · plain | HTTP/1 · plain | 🥇 **6,237**
                          (182 MiB / 46.1% CPU) | **6,237**
                          (182 MiB / 46.1% CPU) | **1,622**
                          (130 MiB / 24.6% CPU) | **1,622**
                          (130 MiB / 24.6% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **5,889**
                          (125 MiB / 53.5% CPU) | **5,889**
                          (125 MiB / 53.5% CPU) | +| HTTP/2 · TLS | HTTP/2 · plain | **8**
                          (83 MiB / 0.2% CPU) | **8**
                          (83 MiB / 0.2% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | 🥇 **3,602**
                          (143 MiB / 49.3% CPU) | **3,602**
                          (143 MiB / 49.3% CPU) | +| HTTP/2 · TLS | HTTP/2 · TLS | **8**
                          (88 MiB / 0.1% CPU) | **8**
                          (88 MiB / 0.1% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | 🥇 **2,837**
                          (142 MiB / 46.8% CPU) | **2,837**
                          (142 MiB / 46.8% CPU) | +| HTTP/3 · QUIC | HTTP/2 · TLS | **1,769**
                          (178 MiB / 44.3% CPU) | **1,769**
                          (178 MiB / 44.3% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | 🥇 **1,857**
                          (194 MiB / 47.4% CPU) | **1,857**
                          (194 MiB / 47.4% CPU) | +| HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **1,935**
                          (175 MiB / 42.5% CPU) | **1,935**
                          (175 MiB / 42.5% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **1,768**
                          (213 MiB / 47.8% CPU) | **1,768**
                          (213 MiB / 47.8% CPU) | TWP leads H1 POST (~**1.5×** YARP), H2 POST (~**1.2×** YARP), and H3 POST (~**1.1×** YARP). ### Linux — POST 64 KiB request + 64 KiB response -Median of **3** repeats @ `9d7c2966`. Source: Actions [32866714851](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32866714851) (`compare-post`). +Median of **3** repeats @ `9a2b3a1e`. Source: Actions [34441591377](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441591377) (`compare-post`). -| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak | -|---|---|---:|---:|---:|---:|---:|---:| -| HTTP/1 · TLS | HTTP/1 · plain | 🥇 **6,746**
                          (127 MiB / 41.8% CPU) | **6,746**
                          (127 MiB / 41.8% CPU) | **5,443**
                          (98 MiB / 44.8% CPU) | **5,443**
                          (98 MiB / 44.8% CPU) | **4,410**
                          (176 MiB / 53.8% CPU) | **4,410**
                          (176 MiB / 53.8% CPU) | -| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **4,720**
                          (220 MiB / 47.0% CPU) | **4,720**
                          (220 MiB / 47.0% CPU) | **2,362**
                          (106 MiB / 21.8% CPU) | **2,475**
                          (106 MiB / 21.8% CPU) | **3,616**
                          (144 MiB / 48.3% CPU) | **3,626**
                          (144 MiB / 48.3% CPU) | -| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **3,306**
                          (223 MiB / 43.7% CPU) | **3,306**
                          (223 MiB / 43.7% CPU) | **835**
                          (111 MiB / 24.6% CPU) | **835**
                          (111 MiB / 24.6% CPU) | **2,959**
                          (260 MiB / 51.5% CPU) | **2,959**
                          (260 MiB / 51.5% CPU) | +| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | HAProxy sustain | HAProxy peak | Envoy sustain | Envoy peak | YARP sustain | YARP peak | +|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| HTTP/1 · TLS | HTTP/1 · plain | **5,113**
                          (133 MiB / 43.7% CPU) | **5,113**
                          (133 MiB / 43.7% CPU) | **4,029**
                          (100 MiB / 48.2% CPU) | **4,029**
                          (100 MiB / 48.2% CPU) | **5,273**
                          (86 MiB / 40.5% CPU) | **5,273**
                          (86 MiB / 40.5% CPU) | 🥇 **5,337**
                          (131 MiB / 40.4% CPU) | **5,337**
                          (131 MiB / 40.4% CPU) | **3,410**
                          (176 MiB / 54.5% CPU) | **3,410**
                          (176 MiB / 54.5% CPU) | +| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **3,356**
                          (220 MiB / 47.1% CPU) | **3,356**
                          (220 MiB / 47.1% CPU) | **1,563**
                          (116 MiB / 21.2% CPU) | **1,563**
                          (116 MiB / 21.2% CPU) | **2,012**
                          (83 MiB / 24.0% CPU) | **2,012**
                          (83 MiB / 24.0% CPU) | **0**
                          (146 MiB / 20.5% CPU) | **2,914**
                          (146 MiB / 20.5% CPU) | **2,767**
                          (171 MiB / 47.9% CPU) | **2,767**
                          (171 MiB / 47.9% CPU) | +| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **3,052**
                          (226 MiB / 43.7% CPU) | **3,052**
                          (226 MiB / 43.7% CPU) | **557**
                          (110 MiB / 25.0% CPU) | **557**
                          (110 MiB / 25.0% CPU) | *Not measured* | *Not measured* | *Not measured* | *Not measured* | **2,778**
                          (240 MiB / 49.2% CPU) | **2,778**
                          (240 MiB / 49.2% CPU) | +| HTTP/2 · plain | HTTP/1 · plain | 🥇 **5,779**
                          (234 MiB / 42.8% CPU) | **5,779**
                          (234 MiB / 42.8% CPU) | **2,488**
                          (94 MiB / 22.3% CPU) | **2,488**
                          (94 MiB / 22.3% CPU) | **3,011**
                          (70 MiB / 23.1% CPU) | **3,011**
                          (70 MiB / 23.1% CPU) | **0**
                          (134 MiB / 22.7% CPU) | **5,195**
                          (134 MiB / 22.7% CPU) | **4,169**
                          (161 MiB / 41.4% CPU) | **4,169**
                          (161 MiB / 41.4% CPU) | +| HTTP/2 · TLS | HTTP/2 · plain | **6**
                          (118 MiB / 0.2% CPU) | **6**
                          (118 MiB / 0.2% CPU) | *Not possible* | *Not possible* | **0**
                          (90 MiB / 23.7% CPU) | **0**
                          (90 MiB / 23.7% CPU) | **0**
                          (153 MiB / 22.1% CPU) | **0**
                          (153 MiB / 22.1% CPU) | 🥇 **2,620**
                          (179 MiB / 46.6% CPU) | **2,620**
                          (179 MiB / 46.6% CPU) | +| HTTP/2 · TLS | HTTP/2 · TLS | **8**
                          (115 MiB / 0.3% CPU) | **8**
                          (115 MiB / 0.3% CPU) | *Not possible* | *Not possible* | **1,216**
                          (88 MiB / 24.6% CPU) | **1,216**
                          (88 MiB / 24.6% CPU) | 🥇 **2,222**
                          (147 MiB / 23.3% CPU) | **2,222**
                          (147 MiB / 23.3% CPU) | **2,085**
                          (181 MiB / 46.1% CPU) | **2,085**
                          (181 MiB / 46.1% CPU) | +| HTTP/3 · QUIC | HTTP/2 · TLS | 🥇 **1,999**
                          (236 MiB / 49.4% CPU) | **1,999**
                          (236 MiB / 49.4% CPU) | *Not possible* | *Not possible* | *Not measured* | *Not measured* | *Not measured* | *Not measured* | **1,950**
                          (245 MiB / 46.9% CPU) | **1,950**
                          (245 MiB / 46.9% CPU) | +| HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **2,301**
                          (253 MiB / 44.2% CPU) | **2,301**
                          (253 MiB / 44.2% CPU) | **471**
                          (120 MiB / 25.1% CPU) | **471**
                          (120 MiB / 25.1% CPU) | *Not measured* | *Not measured* | *Not measured* | *Not measured* | **2,138**
                          (274 MiB / 47.8% CPU) | **2,138**
                          (274 MiB / 47.8% CPU) | Linux nginx H1/H2/H3 POST completed (nginx.org mainline). TWP÷YARP H1 ≈ **1.5×**; H2 ≈ **1.3×**; H3 ≈ **1.1×**. TWP÷nginx H3 ≈ **4×**. ### Windows — lossy / high-RTT (H2 HOL / H3 loss) -Userspace **5 ms** one-way delay + **1%** TCP connection stall (H1/H2) or UDP datagram drop (H3); **64 KiB** GET. H1/H2: median of **3** repeats on `windows-latest` @ `9d7c2966` — [32866717729](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32866717729) (`compare-lossy`). **H3:** GHA Windows userspace UDP shim collapses (sustain **0**); published H3 row is the laptop `quic-http3` remasure under the same delay/loss workload ([Performance Local Lab](Performance-Local-Lab#lossy--high-rtt-h2-hol--h3-packet-loss), `windows-20260822-lossy-h3-quic/). +Userspace **5 ms** one-way delay + **1%** TCP connection stall (H1/H2) or UDP datagram drop (H3); **64 KiB** GET. Median of **3** repeats on `windows-latest` @ `9a2b3a1e` — [34441595456](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441595456) (`compare-lossy`). +| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | HAProxy sustain | HAProxy peak | Envoy sustain | Envoy peak | YARP sustain | YARP peak | +|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| HTTP/1 · TLS | HTTP/1 · plain | 🥇 **663**
                          (112 MiB / 3.0% CPU) | **663**
                          (112 MiB / 3.0% CPU) | **652**
                          (143 MiB / 16.4% CPU) | **652**
                          (143 MiB / 16.4% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **662**
                          (121 MiB / 4.5% CPU) | **662**
                          (121 MiB / 4.5% CPU) | +| HTTP/2 · TLS | HTTP/1 · plain | **0**
                          (120 MiB / 1.4% CPU) | **86**
                          (120 MiB / 1.4% CPU) | **0**
                          (142 MiB / 0.6% CPU) | **17**
                          (142 MiB / 0.6% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **0**
                          (99 MiB / 0.8% CPU) | **16**
                          (99 MiB / 0.8% CPU) | +| HTTP/3 · QUIC | HTTP/1 · plain | **0**
                          (67 MiB / 0.1% CPU) | **0**
                          (67 MiB / 0.1% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **0**
                          (80 MiB / 0.0% CPU) | **0**
                          (80 MiB / 0.0% CPU) | +| HTTP/2 · plain | HTTP/1 · plain | **0**
                          (130 MiB / 1.5% CPU) | **89**
                          (130 MiB / 1.5% CPU) | **0**
                          (128 MiB / 0.1% CPU) | **17**
                          (128 MiB / 0.1% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **0**
                          (91 MiB / 0.7% CPU) | **16**
                          (91 MiB / 0.7% CPU) | +| HTTP/2 · TLS | HTTP/2 · plain | **0**
                          (68 MiB / 0.6% CPU) | **8**
                          (68 MiB / 0.6% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **0**
                          (100 MiB / 0.7% CPU) | **16**
                          (100 MiB / 0.7% CPU) | +| HTTP/2 · TLS | HTTP/2 · TLS | **0**
                          (71 MiB / 0.2% CPU) | **8**
                          (71 MiB / 0.2% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **0**
                          (99 MiB / 0.8% CPU) | **16**
                          (99 MiB / 0.8% CPU) | +| HTTP/3 · QUIC | HTTP/2 · TLS | **0**
                          (70 MiB / 0.1% CPU) | **0**
                          (70 MiB / 0.1% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **0**
                          (80 MiB / 0.0% CPU) | **0**
                          (80 MiB / 0.0% CPU) | +| HTTP/3 · QUIC | HTTP/1 · TLS | **0**
                          (69 MiB / 0.0% CPU) | **0**
                          (69 MiB / 0.0% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **0**
                          (78 MiB / 0.0% CPU) | **0**
                          (78 MiB / 0.0% CPU) | -| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak | -|---|---|---:|---:|---:|---:|---:|---:| -| HTTP/1 · TLS | HTTP/1 · plain | 🥇 **663**
                          (98 MiB / 4.8% CPU) | **663**
                          (98 MiB / 4.8% CPU) | **634**
                          (137 MiB / 19.6% CPU) | **634**
                          (137 MiB / 19.6% CPU) | **662**
                          (113 MiB / 5.7% CPU) | **662**
                          (113 MiB / 5.7% CPU) | -| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **59**
                          (122 MiB / 1.7% CPU) | **88**
                          (122 MiB / 1.7% CPU) | **18**
                          (137 MiB / 0.5% CPU) | **18**
                          (137 MiB / 0.5% CPU) | **18**
                          (84 MiB / 1.1% CPU) | **18**
                          (84 MiB / 1.1% CPU) | -| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **1,572** | **1,572** | *Not possible* (no QUIC) | *Not possible* | **0** | **50** | - -TWP H2 HOL leads (~**3.31×** YARP). H3 is the protocol-shape win vs H2 HOL on the same lossy session; Win H3 GHA remains 0 (laptop remasure kept above). +TWP H2 HOL leads (~**3.31×** YARP). H3 is the protocol-shape win vs H2 HOL on the same lossy session; Win H3 GHA remains 0 (laptop remeasure kept above). ### Linux — lossy / high-RTT (H2 HOL / H3 loss) -Median of **3** repeats @ `9d7c2966`. Source: [32866717729](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32866717729) (`compare-lossy`; lossy H3 uses `quic-http3`). +Median of **3** repeats @ `9a2b3a1e`. Source: [34441595456](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441595456) (`compare-lossy`; lossy H3 uses `quic-http3`). -| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak | -|---|---|---:|---:|---:|---:|---:|---:| -| HTTP/1 · TLS | HTTP/1 · plain | **1,201**
                          (142 MiB / 12.9% CPU) | **1,201**
                          (142 MiB / 12.9% CPU) | 🥇 **1,206**
                          (97 MiB / 11.8% CPU) | **1,206**
                          (97 MiB / 11.8% CPU) | **1,197**
                          (144 MiB / 16.6% CPU) | **1,197**
                          (144 MiB / 16.6% CPU) | -| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **309**
                          (191 MiB / 6.5% CPU) | **309**
                          (191 MiB / 6.5% CPU) | **40**
                          (95 MiB / 0.3% CPU) | **42**
                          (95 MiB / 0.3% CPU) | **40**
                          (118 MiB / 1.4% CPU) | **44**
                          (118 MiB / 1.4% CPU) | -| HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **332**
                          (149 MiB / 13.3% CPU) | **332**
                          (149 MiB / 13.3% CPU) | **88**
                          (105 MiB / 2.7% CPU) | **88**
                          (105 MiB / 2.7% CPU) | **330**
                          (176 MiB / 17.9% CPU) | **330**
                          (176 MiB / 17.9% CPU) | +| Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | HAProxy sustain | HAProxy peak | Envoy sustain | Envoy peak | YARP sustain | YARP peak | +|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| HTTP/1 · TLS | HTTP/1 · plain | **1,195**
                          (142 MiB / 13.7% CPU) | **1,195**
                          (142 MiB / 13.7% CPU) | **1,205**
                          (100 MiB / 12.6% CPU) | **1,205**
                          (100 MiB / 12.6% CPU) | 🥇 **1,207**
                          (83 MiB / 7.8% CPU) | **1,207**
                          (83 MiB / 7.8% CPU) | **1,193**
                          (128 MiB / 9.3% CPU) | **1,193**
                          (128 MiB / 9.3% CPU) | **1,194**
                          (150 MiB / 17.5% CPU) | **1,194**
                          (150 MiB / 17.5% CPU) | +| HTTP/2 · TLS | HTTP/1 · plain | 🥇 **314**
                          (182 MiB / 7.0% CPU) | **314**
                          (182 MiB / 7.0% CPU) | **40**
                          (99 MiB / 0.3% CPU) | **40**
                          (99 MiB / 0.3% CPU) | **40**
                          (86 MiB / 0.3% CPU) | **40**
                          (86 MiB / 0.3% CPU) | **40**
                          (135 MiB / 0.4% CPU) | **40**
                          (135 MiB / 0.4% CPU) | **40**
                          (127 MiB / 1.5% CPU) | **40**
                          (127 MiB / 1.5% CPU) | +| HTTP/3 · QUIC | HTTP/1 · plain | **319**
                          (151 MiB / 13.9% CPU) | **319**
                          (151 MiB / 13.9% CPU) | **92**
                          (109 MiB / 2.8% CPU) | **92**
                          (109 MiB / 2.8% CPU) | *Not measured* | *Not measured* | *Not measured* | *Not measured* | 🥇 **338**
                          (177 MiB / 22.4% CPU) | **338**
                          (177 MiB / 22.4% CPU) | +| HTTP/2 · plain | HTTP/1 · plain | 🥇 **344**
                          (174 MiB / 7.2% CPU) | **344**
                          (174 MiB / 7.2% CPU) | **40**
                          (78 MiB / 0.2% CPU) | **40**
                          (78 MiB / 0.2% CPU) | **40**
                          (69 MiB / 0.2% CPU) | **40**
                          (69 MiB / 0.2% CPU) | **40**
                          (129 MiB / 0.4% CPU) | **40**
                          (129 MiB / 0.4% CPU) | **41**
                          (121 MiB / 1.2% CPU) | **41**
                          (121 MiB / 1.2% CPU) | +| HTTP/2 · TLS | HTTP/2 · plain | **0**
                          (93 MiB / 0.6% CPU) | **13**
                          (93 MiB / 0.6% CPU) | *Not possible* | *Not possible* | **0**
                          (83 MiB / 2.7% CPU) | **0**
                          (83 MiB / 2.7% CPU) | **0**
                          (127 MiB / 6.3% CPU) | **0**
                          (127 MiB / 6.3% CPU) | 🥇 **40**
                          (129 MiB / 1.6% CPU) | **40**
                          (129 MiB / 1.6% CPU) | +| HTTP/2 · TLS | HTTP/2 · TLS | **0**
                          (95 MiB / 0.7% CPU) | **11**
                          (95 MiB / 0.7% CPU) | *Not possible* | *Not possible* | 🥇 **42**
                          (88 MiB / 0.6% CPU) | **42**
                          (88 MiB / 0.6% CPU) | **40**
                          (138 MiB / 0.5% CPU) | **40**
                          (138 MiB / 0.5% CPU) | **40**
                          (129 MiB / 1.6% CPU) | **40**
                          (129 MiB / 1.6% CPU) | +| HTTP/3 · QUIC | HTTP/2 · TLS | **322**
                          (140 MiB / 24.2% CPU) | **322**
                          (140 MiB / 24.2% CPU) | *Not possible* | *Not possible* | *Not measured* | *Not measured* | *Not measured* | *Not measured* | 🥇 **333**
                          (183 MiB / 24.3% CPU) | **333**
                          (183 MiB / 24.3% CPU) | +| HTTP/3 · QUIC | HTTP/1 · TLS | 🥇 **341**
                          (161 MiB / 15.8% CPU) | **341**
                          (161 MiB / 15.8% CPU) | **96**
                          (113 MiB / 3.0% CPU) | **96**
                          (113 MiB / 3.0% CPU) | *Not measured* | *Not measured* | *Not measured* | *Not measured* | **340**
                          (185 MiB / 23.1% CPU) | **340**
                          (185 MiB / 23.1% CPU) | TWP H2 HOL ≫ YARP (~**7.7×**). H3 TWP÷YARP ≈ **1×**. ### Architecture-sensitive -`compare-arch` isolates slow app readers, origin-early response, H2 duplex, and WebSocket echo. See [TWP vs YARP IO model](Performance-Profiling#twp-vs-yarp-io-model). Laptop 1-rep numbers are on [Performance Local Lab](Performance-Local-Lab#architecture-sensitive). +These runs isolate slow app readers, origin-early response, HTTP/2 duplex, and WebSocket echo. See [TWP vs YARP IO model](Performance-Profiling#twp-vs-yarp-io-model). Laptop 1-rep numbers are on [Performance Local Lab](Performance-Local-Lab#architecture-sensitive). -Median of **3** repeats on matched 4 vCPU / 16 GiB runners @ `9d7c2966` ([32866720742](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32866720742)) (`compare-arch`). Slow consumer = 256 KiB GET, 16 KiB read + 8 ms sleep. Early response = 64 KiB POST, origin writes after 8 KiB. Duplex H2 = overlapping 64 KiB POST on H2 TLS↔H2 TLS. WebSocket = echo round-trips/sec. +Median of **3** repeats on matched 4 vCPU / 16 GiB runners @ `9a2b3a1e` ([34441578556](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441578556)). Slow consumer = 256 KiB GET, 16 KiB read + 8 ms sleep. Early response = 64 KiB POST, origin writes after 8 KiB. Duplex HTTP/2 = overlapping 64 KiB POST on H2 TLS↔H2 TLS. WebSocket = echo round-trips/sec. -`compare-lossy` (slow **network**) is already published above; it is not a slow **app** reader. +Lossy-link runs (slow **network**) are already published above; they are not a slow **app** reader. #### Windows -| Scenario | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak | -|---|---|---|---:|---:|---:|---:|---:|---:| -| Slow consumer (256 KiB GET, throttled client read) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **248**
                          (91 MiB / 3.8% CPU) | **248**
                          (91 MiB / 3.8% CPU) | **209**
                          (140 MiB / 24.7% CPU) | **209**
                          (140 MiB / 24.7% CPU) | **248**
                          (110 MiB / 4.9% CPU) | **248**
                          (110 MiB / 4.9% CPU) | -| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · TLS | HTTP/1 · plain | **248**
                          (125 MiB / 3.8% CPU) | **248**
                          (125 MiB / 3.8% CPU) | **166**
                          (137 MiB / 24.6% CPU) | **166**
                          (137 MiB / 24.6% CPU) | 🥇 **249**
                          (112 MiB / 7.4% CPU) | **249**
                          (112 MiB / 7.4% CPU) | -| Slow consumer (256 KiB GET, throttled client read) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **264**
                          (99 MiB / 17.1% CPU) | **264**
                          (99 MiB / 17.1% CPU) | *Not possible* (no QUIC) | *Not possible* (no QUIC) | **264**
                          (160 MiB / 21.3% CPU) | **264**
                          (160 MiB / 21.3% CPU) | -| Early response (origin writes after first request chunk) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **6,507**
                          (84 MiB / 43.0% CPU) | **6,881**
                          (84 MiB / 43.0% CPU) | **347**
                          (137 MiB / 24.9% CPU) | **419**
                          (137 MiB / 24.9% CPU) | **3,256**
                          (117 MiB / 39.5% CPU) | **3,606**
                          (117 MiB / 39.5% CPU) | -| Early response (origin writes after first request chunk) | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **5,011**
                          (153 MiB / 48.6% CPU) | **5,011**
                          (153 MiB / 48.6% CPU) | **0**
                          (139 MiB / 24.8% CPU) | **383**
                          (139 MiB / 24.8% CPU) | **2,967**
                          (97 MiB / 39.6% CPU) | **3,184**
                          (97 MiB / 39.6% CPU) | -| Early response (origin writes after first request chunk) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **2,153**
                          (125 MiB / 42.8% CPU) | **2,256**
                          (125 MiB / 42.8% CPU) | *Not possible* (no QUIC) | *Not possible* (no QUIC) | **1,874**
                          (154 MiB / 53.3% CPU) | **2,034**
                          (154 MiB / 53.3% CPU) | -| Duplex (both directions live) | HTTP/2 · TLS | HTTP/2 · TLS | 🥇 **784**
                          (124 MiB / 13.2% CPU) | **1,270**
                          (124 MiB / 13.2% CPU) | *Not possible* | *Not possible* | **18**
                          (120 MiB / 30.7% CPU) | **2,135**
                          (120 MiB / 30.7% CPU) | -| Duplex (WebSocket / extended CONNECT) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **32,785**
                          (97 MiB / 40.8% CPU) | **32,785**
                          (97 MiB / 40.8% CPU) | **19,252**
                          (138 MiB / 24.9% CPU) | **19,803**
                          (138 MiB / 24.9% CPU) | **30,820**
                          (85 MiB / 43.6% CPU) | **30,820**
                          (85 MiB / 43.6% CPU) | +| Scenario | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | HAProxy sustain | HAProxy peak | Envoy sustain | Envoy peak | YARP sustain | YARP peak | +|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| Slow consumer (256 KiB GET, throttled client read) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **243**
                          (101 MiB / 5.9% CPU) | **243**
                          (101 MiB / 5.9% CPU) | **204**
                          (143 MiB / 24.6% CPU) | **204**
                          (143 MiB / 24.6% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **241**
                          (109 MiB / 4.8% CPU) | **241**
                          (109 MiB / 4.8% CPU) | +| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · TLS | HTTP/1 · plain | **256**
                          (131 MiB / 3.7% CPU) | **256**
                          (131 MiB / 3.7% CPU) | **230**
                          (142 MiB / 24.4% CPU) | **230**
                          (142 MiB / 24.4% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | 🥇 **256**
                          (111 MiB / 5.4% CPU) | **256**
                          (111 MiB / 5.4% CPU) | +| Slow consumer (256 KiB GET, throttled client read) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **264**
                          (101 MiB / 17.4% CPU) | **264**
                          (101 MiB / 17.4% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **255**
                          (158 MiB / 20.4% CPU) | **255**
                          (158 MiB / 20.4% CPU) | +| Early response (origin writes after first request chunk) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **5,611**
                          (99 MiB / 45.5% CPU) | **5,611**
                          (99 MiB / 45.5% CPU) | **363**
                          (143 MiB / 24.7% CPU) | **363**
                          (143 MiB / 24.7% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **3,919**
                          (138 MiB / 53.9% CPU) | **3,919**
                          (138 MiB / 53.9% CPU) | +| Early response (origin writes after first request chunk) | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **3,934**
                          (169 MiB / 48.8% CPU) | **3,934**
                          (169 MiB / 48.8% CPU) | **0**
                          (144 MiB / 24.8% CPU) | **318**
                          (144 MiB / 24.8% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **3,200**
                          (135 MiB / 53.4% CPU) | **3,200**
                          (135 MiB / 53.4% CPU) | +| Early response (origin writes after first request chunk) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **3,012**
                          (152 MiB / 42.0% CPU) | **3,012**
                          (152 MiB / 42.0% CPU) | *Not possible (no QUIC)* | *Not possible (no QUIC)* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **2,602**
                          (203 MiB / 51.0% CPU) | **2,602**
                          (203 MiB / 51.0% CPU) | +| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · plain | HTTP/1 · plain | **248**
                          (112 MiB / 3.6% CPU) | **248**
                          (112 MiB / 3.6% CPU) | **248**
                          (127 MiB / 9.4% CPU) | **248**
                          (127 MiB / 9.4% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | 🥇 **256**
                          (104 MiB / 6.0% CPU) | **256**
                          (104 MiB / 6.0% CPU) | +| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · TLS | HTTP/2 · TLS | **0**
                          (70 MiB / 1.0% CPU) | **8**
                          (70 MiB / 1.0% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | 🥇 **256**
                          (136 MiB / 9.3% CPU) | **256**
                          (136 MiB / 9.3% CPU) | +| Early response (origin writes after first request chunk) | HTTP/2 · TLS | HTTP/2 · TLS | **0**
                          (92 MiB / 0.1% CPU) | **0**
                          (92 MiB / 0.1% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **0**
                          (110 MiB / 0.1% CPU) | **0**
                          (110 MiB / 0.1% CPU) | +| Duplex (both directions live) | HTTP/2 · TLS | HTTP/2 · TLS | **0**
                          (91 MiB / 0.1% CPU) | **0**
                          (91 MiB / 0.1% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **0**
                          (111 MiB / 0.1% CPU) | **0**
                          (111 MiB / 0.1% CPU) | +| Duplex (WebSocket / extended CONNECT) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **24,498**
                          (97 MiB / 43.0% CPU) | **24,498**
                          (97 MiB / 43.0% CPU) | **12,337**
                          (143 MiB / 24.6% CPU) | **12,337**
                          (143 MiB / 24.6% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **23,100**
                          (89 MiB / 44.6% CPU) | **23,100**
                          (89 MiB / 44.6% CPU) | #### Linux -| Scenario | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak | -|---|---|---|---:|---:|---:|---:|---:|---:| -| Slow consumer (256 KiB GET, throttled client read) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **468**
                          (115 MiB / 9.5% CPU) | **468**
                          (115 MiB / 9.5% CPU) | **416**
                          (96 MiB / 9.6% CPU) | **416**
                          (96 MiB / 9.6% CPU) | **418**
                          (138 MiB / 13.6% CPU) | **418**
                          (138 MiB / 13.6% CPU) | -| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · TLS | HTTP/1 · plain | **473**
                          (131 MiB / 17.8% CPU) | **473**
                          (131 MiB / 17.8% CPU) | **462**
                          (96 MiB / 17.3% CPU) | **462**
                          (96 MiB / 17.3% CPU) | 🥇 **474**
                          (146 MiB / 23.5% CPU) | **474**
                          (146 MiB / 23.5% CPU) | -| Slow consumer (256 KiB GET, throttled client read) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **474**
                          (125 MiB / 34.3% CPU) | **474**
                          (125 MiB / 34.3% CPU) | **119**
                          (100 MiB / 11.4% CPU) | **238**
                          (100 MiB / 11.4% CPU) | **472**
                          (193 MiB / 40.0% CPU) | **472**
                          (193 MiB / 40.0% CPU) | -| Early response (origin writes after first request chunk) | HTTP/1 · TLS | HTTP/1 · plain | 🥇 **5,039**
                          (149 MiB / 45.3% CPU) | **5,039**
                          (149 MiB / 45.3% CPU) | **3,904**
                          (98 MiB / 49.4% CPU) | **3,904**
                          (98 MiB / 49.4% CPU) | **3,425**
                          (175 MiB / 55.3% CPU) | **3,425**
                          (175 MiB / 55.3% CPU) | -| Early response (origin writes after first request chunk) | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **3,661**
                          (233 MiB / 44.1% CPU) | **3,661**
                          (233 MiB / 44.1% CPU) | **0**
                          (103 MiB / 24.6% CPU) | **1,803**
                          (103 MiB / 24.6% CPU) | **2,370**
                          (148 MiB / 47.6% CPU) | **2,570**
                          (148 MiB / 47.6% CPU) | -| Early response (origin writes after first request chunk) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **3,018**
                          (193 MiB / 43.5% CPU) | **3,018**
                          (193 MiB / 43.5% CPU) | **0**
                          (114 MiB / 24.7% CPU) | **545**
                          (114 MiB / 24.7% CPU) | **2,232**
                          (262 MiB / 46.7% CPU) | **2,232**
                          (262 MiB / 46.7% CPU) | -| Duplex (both directions live) | HTTP/2 · TLS | HTTP/2 · TLS | 🥇 **185**
                          (124 MiB / 9.0% CPU) | **282**
                          (124 MiB / 9.0% CPU) | *Not possible* | *Not possible* | **138**
                          (139 MiB / 44.6% CPU) | **1,882**
                          (139 MiB / 44.6% CPU) | -| Duplex (WebSocket / extended CONNECT) | HTTP/1 · TLS | HTTP/1 · plain | **35,088**
                          (121 MiB / 44.5% CPU) | **35,088**
                          (121 MiB / 44.5% CPU) | 🥇 **39,303**
                          (97 MiB / 37.1% CPU) | **39,303**
                          (97 MiB / 37.1% CPU) | **32,013**
                          (122 MiB / 44.9% CPU) | **32,013**
                          (122 MiB / 44.9% CPU) | +| Scenario | Client | Origin | TWP sustain | TWP peak | nginx sustain | nginx peak | HAProxy sustain | HAProxy peak | Envoy sustain | Envoy peak | YARP sustain | YARP peak | +|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| Slow consumer (256 KiB GET, throttled client read) | HTTP/1 · TLS | HTTP/1 · plain | **469**
                          (122 MiB / 9.7% CPU) | **469**
                          (122 MiB / 9.7% CPU) | **411**
                          (100 MiB / 9.9% CPU) | **411**
                          (100 MiB / 9.9% CPU) | **466**
                          (83 MiB / 6.0% CPU) | **466**
                          (83 MiB / 6.0% CPU) | 🥇 **470**
                          (140 MiB / 6.2% CPU) | **470**
                          (140 MiB / 6.2% CPU) | **411**
                          (146 MiB / 14.2% CPU) | **411**
                          (146 MiB / 14.2% CPU) | +| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **480**
                          (149 MiB / 13.1% CPU) | **480**
                          (149 MiB / 13.1% CPU) | **374**
                          (102 MiB / 5.0% CPU) | **374**
                          (102 MiB / 5.0% CPU) | **475**
                          (85 MiB / 4.3% CPU) | **475**
                          (85 MiB / 4.3% CPU) | **477**
                          (149 MiB / 4.5% CPU) | **477**
                          (149 MiB / 4.5% CPU) | **477**
                          (146 MiB / 15.0% CPU) | **477**
                          (146 MiB / 15.0% CPU) | +| Slow consumer (256 KiB GET, throttled client read) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **473**
                          (131 MiB / 34.1% CPU) | **473**
                          (131 MiB / 34.1% CPU) | **0**
                          (119 MiB / 21.9% CPU) | **122**
                          (119 MiB / 21.9% CPU) | *Not measured* | *Not measured* | *Not measured* | *Not measured* | **468**
                          (198 MiB / 39.8% CPU) | **468**
                          (198 MiB / 39.8% CPU) | +| Early response (origin writes after first request chunk) | HTTP/1 · TLS | HTTP/1 · plain | **4,671**
                          (132 MiB / 47.4% CPU) | **4,671**
                          (132 MiB / 47.4% CPU) | **0**
                          (100 MiB / 49.6% CPU) | **3,397**
                          (100 MiB / 49.6% CPU) | 🥇 **4,886**
                          (84 MiB / 43.3% CPU) | **4,886**
                          (84 MiB / 43.3% CPU) | **0**
                          (129 MiB / 25.6% CPU) | **2,584**
                          (129 MiB / 25.6% CPU) | **3,159**
                          (176 MiB / 56.3% CPU) | **3,159**
                          (176 MiB / 56.3% CPU) | +| Early response (origin writes after first request chunk) | HTTP/2 · TLS | HTTP/1 · plain | 🥇 **3,266**
                          (242 MiB / 48.3% CPU) | **3,266**
                          (242 MiB / 48.3% CPU) | **0**
                          (113 MiB / 24.7% CPU) | **1,365**
                          (113 MiB / 24.7% CPU) | **2,480**
                          (85 MiB / 24.6% CPU) | **2,480**
                          (85 MiB / 24.6% CPU) | **0**
                          (148 MiB / 23.9% CPU) | **2,653**
                          (148 MiB / 23.9% CPU) | **2,198**
                          (169 MiB / 48.3% CPU) | **2,198**
                          (169 MiB / 48.3% CPU) | +| Early response (origin writes after first request chunk) | HTTP/3 · QUIC | HTTP/1 · plain | 🥇 **4,504**
                          (218 MiB / 43.2% CPU) | **4,504**
                          (218 MiB / 43.2% CPU) | **0**
                          (125 MiB / 22.7% CPU) | **1,090**
                          (125 MiB / 22.7% CPU) | *Not measured* | *Not measured* | *Not measured* | *Not measured* | **3,217**
                          (274 MiB / 48.7% CPU) | **3,217**
                          (274 MiB / 48.7% CPU) | +| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · plain | HTTP/1 · plain | 🥇 **478**
                          (136 MiB / 15.5% CPU) | **478**
                          (136 MiB / 15.5% CPU) | **360**
                          (79 MiB / 8.6% CPU) | **360**
                          (79 MiB / 8.6% CPU) | **473**
                          (69 MiB / 6.4% CPU) | **473**
                          (69 MiB / 6.4% CPU) | **473**
                          (147 MiB / 4.7% CPU) | **473**
                          (147 MiB / 4.7% CPU) | **475**
                          (150 MiB / 16.2% CPU) | **475**
                          (150 MiB / 16.2% CPU) | +| Slow consumer (256 KiB GET, throttled client read) | HTTP/2 · TLS | HTTP/2 · TLS | **8**
                          (97 MiB / 2.5% CPU) | **8**
                          (97 MiB / 2.5% CPU) | *Not possible* | *Not possible* | **455**
                          (89 MiB / 22.6% CPU) | **455**
                          (89 MiB / 22.6% CPU) | **466**
                          (152 MiB / 12.9% CPU) | **466**
                          (152 MiB / 12.9% CPU) | 🥇 **466**
                          (168 MiB / 31.1% CPU) | **466**
                          (168 MiB / 31.1% CPU) | +| Early response (origin writes after first request chunk) | HTTP/2 · TLS | HTTP/2 · TLS | **0**
                          (112 MiB / 0.1% CPU) | **0**
                          (112 MiB / 0.1% CPU) | *Not possible* | *Not possible* | *Not measured* | *Not measured* | **0**
                          (156 MiB / 19.5% CPU) | **3,680**
                          (156 MiB / 19.5% CPU) | **0**
                          (146 MiB / 0.2% CPU) | **0**
                          (146 MiB / 0.2% CPU) | +| Duplex (both directions live) | HTTP/2 · TLS | HTTP/2 · TLS | **0**
                          (112 MiB / 0.1% CPU) | **0**
                          (112 MiB / 0.1% CPU) | *Not possible* | *Not possible* | **0**
                          (91 MiB / 0.1% CPU) | **0**
                          (91 MiB / 0.1% CPU) | **0**
                          (151 MiB / 19.4% CPU) | **3,724**
                          (151 MiB / 19.4% CPU) | **0**
                          (151 MiB / 0.1% CPU) | **0**
                          (151 MiB / 0.1% CPU) | +| Duplex (WebSocket / extended CONNECT) | HTTP/1 · TLS | HTTP/1 · plain | **28,567**
                          (125 MiB / 44.0% CPU) | **28,567**
                          (125 MiB / 44.0% CPU) | 🥇 **33,775**
                          (100 MiB / 35.5% CPU) | **33,775**
                          (100 MiB / 35.5% CPU) | **31,870**
                          (84 MiB / 39.3% CPU) | **31,870**
                          (84 MiB / 39.3% CPU) | **31,525**
                          (127 MiB / 39.4% CPU) | **31,525**
                          (127 MiB / 39.4% CPU) | **27,109**
                          (125 MiB / 44.1% CPU) | **27,109**
                          (125 MiB / 44.1% CPU) | Slow consumer is sleep-bound; H1/H2/H3 sit in the same band. Early-response H1/H2/H3: TWP leads (H1 early ≈ **2.00×** / **1.47×** YARP Win/Linux). **Duplex H2**: YARP leads by design — Win ≈ **0.59×** (1,270 / 2,135), Linux ≈ **0.15×** (282 / 1,882); irreducible concurrent-copier cell (see [IO model](Performance-Profiling#twp-vs-yarp-io-model)). WebSocket: TWP÷YARP Windows ≈ **1.06×**; Linux nginx leads. @@ -607,26 +603,36 @@ Isolates keep-alive tiny GET vs **new connection per request** (handshake-domina #### Windows -Median of **3** repeats on `windows-latest` @ `9d7c2966`. Source: Actions [32866723562](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32866723562) (`compare-tls-cost`). Absolute RPS on GHA swings hard; prefer **TWP÷YARP**. +Median of **3** repeats on `windows-latest` @ `9a2b3a1e`. Source: Actions [34441599658](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441599658). Absolute RPS on GHA swings hard; prefer **TWP÷YARP**. -| Workload | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak | -|---|---:|---:|---:|---:|---:|---:| -| Keep-alive · tiny GET | 🥇 **20,709**
                          (82 MiB / 48.3% CPU) | **20,709**
                          (82 MiB / 48.3% CPU) | **9,096**
                          (137 MiB / 24.8% CPU) | **9,158**
                          (137 MiB / 24.8% CPU) | **17,934**
                          (103 MiB / 48.1% CPU) | **17,934**
                          (103 MiB / 48.1% CPU) | -| New-connection · tiny GET | 🥇 **739**
                          (65 MiB / 10.4% CPU) | **745**
                          (65 MiB / 10.4% CPU) | **252**
                          (136 MiB / 24.2% CPU) | **256**
                          (136 MiB / 24.2% CPU) | **591**
                          (93 MiB / 10.1% CPU) | **607**
                          (93 MiB / 10.1% CPU) | -| Keep-alive · 256 KiB GET | 🥇 **2,698**
                          (87 MiB / 46.2% CPU) | **2,971**
                          (87 MiB / 46.2% CPU) | **172**
                          (137 MiB / 24.7% CPU) | **174**
                          (137 MiB / 24.7% CPU) | **2,569**
                          (134 MiB / 44.0% CPU) | **2,569**
                          (134 MiB / 44.0% CPU) | +| Workload | TWP sustain | TWP peak | nginx sustain | nginx peak | HAProxy sustain | HAProxy peak | Envoy sustain | Envoy peak | YARP sustain | YARP peak | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| Keep-alive · tiny GET | 🥇 **20,612**
                          (87 MiB / 47.9% CPU) | **20,612**
                          (87 MiB / 47.9% CPU) | **8,972**
                          (142 MiB / 24.8% CPU) | **8,972**
                          (142 MiB / 24.8% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **18,392**
                          (105 MiB / 50.9% CPU) | **18,392**
                          (105 MiB / 50.9% CPU) | +| New-connection · tiny GET | 🥇 **732**
                          (88 MiB / 9.5% CPU) | **732**
                          (88 MiB / 9.5% CPU) | **0**
                          (140 MiB / 24.4% CPU) | **248**
                          (140 MiB / 24.4% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **725**
                          (113 MiB / 10.7% CPU) | **725**
                          (113 MiB / 10.7% CPU) | +| Keep-alive · 256 KiB GET | 🥇 **2,741**
                          (130 MiB / 47.0% CPU) | **2,741**
                          (130 MiB / 47.0% CPU) | **0**
                          (142 MiB / 24.6% CPU) | **162**
                          (142 MiB / 24.6% CPU) | *Not possible* | *Not possible* | *Not possible* | *Not possible* | **2,699**
                          (136 MiB / 49.4% CPU) | **2,699**
                          (136 MiB / 49.4% CPU) | #### Linux -Median of **3** repeats @ `9d7c2966`. Source: Actions [32866723562](https://github.com/justcoding121/titanium-web-proxy/actions/runs/32866723562) (`compare-tls-cost`). +Median of **3** repeats @ `9a2b3a1e`. Source: Actions [34441599658](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441599658). -| Workload | TWP sustain | TWP peak | nginx sustain | nginx peak | YARP sustain | YARP peak | -|---|---:|---:|---:|---:|---:|---:| -| Keep-alive · tiny GET | **23,687**
                          (104 MiB / 49.1% CPU) | **23,687**
                          (104 MiB / 49.1% CPU) | 🥇 **25,897**
                          (95 MiB / 41.3% CPU) | **26,177**
                          (95 MiB / 41.3% CPU) | **20,716**
                          (133 MiB / 50.5% CPU) | **20,716**
                          (133 MiB / 50.5% CPU) | -| New-connection · tiny GET | **962**
                          (124 MiB / 47.5% CPU) | **973**
                          (124 MiB / 47.5% CPU) | 🥇 **1,005**
                          (96 MiB / 44.2% CPU) | **1,008**
                          (96 MiB / 44.2% CPU) | **941**
                          (148 MiB / 45.8% CPU) | **941**
                          (148 MiB / 45.8% CPU) | -| Keep-alive · 256 KiB GET | 🥇 **2,690**
                          (126 MiB / 36.5% CPU) | **2,690**
                          (126 MiB / 36.5% CPU) | **1,749**
                          (96 MiB / 53.6% CPU) | **1,749**
                          (96 MiB / 53.6% CPU) | **2,146**
                          (172 MiB / 45.9% CPU) | **2,146**
                          (172 MiB / 45.9% CPU) | +| Workload | TWP sustain | TWP peak | nginx sustain | nginx peak | HAProxy sustain | HAProxy peak | Envoy sustain | Envoy peak | YARP sustain | YARP peak | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| Keep-alive · tiny GET | **36,114**
                          (108 MiB / 49.4% CPU) | **36,114**
                          (108 MiB / 49.4% CPU) | 🥇 **44,372**
                          (102 MiB / 40.8% CPU) | **44,372**
                          (102 MiB / 40.8% CPU) | **43,256**
                          (84 MiB / 43.3% CPU) | **43,256**
                          (84 MiB / 43.3% CPU) | **27,636**
                          (128 MiB / 57.3% CPU) | **27,636**
                          (128 MiB / 57.3% CPU) | **32,712**
                          (135 MiB / 49.8% CPU) | **32,712**
                          (135 MiB / 49.8% CPU) | +| New-connection · tiny GET | **1,549**
                          (128 MiB / 40.0% CPU) | **1,549**
                          (128 MiB / 40.0% CPU) | 🥇 **1,598**
                          (103 MiB / 36.2% CPU) | **1,598**
                          (103 MiB / 36.2% CPU) | **1,454**
                          (85 MiB / 37.7% CPU) | **1,454**
                          (85 MiB / 37.7% CPU) | **1,379**
                          (129 MiB / 44.5% CPU) | **1,379**
                          (129 MiB / 44.5% CPU) | **1,525**
                          (149 MiB / 38.9% CPU) | **1,525**
                          (149 MiB / 38.9% CPU) | +| Keep-alive · 256 KiB GET | **3,790**
                          (126 MiB / 31.8% CPU) | **3,790**
                          (126 MiB / 31.8% CPU) | **2,421**
                          (101 MiB / 48.7% CPU) | **2,421**
                          (101 MiB / 48.7% CPU) | 🥇 **3,940**
                          (84 MiB / 28.4% CPU) | **3,940**
                          (84 MiB / 28.4% CPU) | **3,544**
                          (145 MiB / 32.3% CPU) | **3,544**
                          (145 MiB / 32.3% CPU) | **3,031**
                          (160 MiB / 42.1% CPU) | **3,031**
                          (160 MiB / 42.1% CPU) | All three workloads are **>1.00×** YARP on both OS. nginx leads Linux keep-alive tiny and Linux new-connection; TWP is second, YARP third. +## Unary gRPC (H2 TLS) + +Unary Echo **RPC/s** @ c=64 over H2 TLS→H2 TLS for Titanium, YARP, nginx (`grpc_pass`), HAProxy, and Envoy (OS-possible peers only). Not folded into the architecture-sensitive tables. Median of **3** repeats @ `9a2b3a1e` — [34441548073](https://github.com/justcoding121/titanium-web-proxy/actions/runs/34441548073). + +| OS | Titanium | YARP | nginx | HAProxy | Envoy | +|---|---:|---:|---:|---:|---:| +| Windows | **53,762**
                          (88 MiB / 23.6% CPU) | **30,754**
                          (123 MiB / 46.9% CPU) | *Not measured* | *Not possible* | *Not possible* | +| Linux | **42,675**
                          (120 MiB / 30.3% CPU) | **24,232**
                          (159 MiB / 41.6% CPU) | *Not measured* | **8,683**
                          (84 MiB / 24.6% CPU) | **16,110**
                          (128 MiB / 20.6% CPU) | +| macOS | **14,924**
                          (94 MiB / 20.1% CPU) | **8,581**
                          (128 MiB / 28.4% CPU) | *Not measured* | *Not measured* | *Not measured* | + ## Other measurements | What | Result | @@ -682,3 +688,68 @@ ep.BeforeSslAuthenticate += (_, a) => return Task.CompletedTask; }; ``` + +## Maintainer notes + +CI cadence, shards, paste scripts, and gate floors for people refreshing these tables. Consumers can skip this section. + +### Tiered cadence + +| Tier | Mode | When | +|------|------|------| +| Daily / per-PR | `compare-spot` | minutes | +| Milestone | `compare-terminate` / `compare-matrix` | ~1–2h | +| Editions | `compare-editions` | ~60 min (CLI / Plus / Intercept stress arms) | +| Cross-version (Gate 2) | `compare-cross-version` | ~1–2h vs committed 6.0 baselines | +| Pre-wiki smoke | `compare-product-smoke` (Linux 2 shards, `repeats=1`) | ~30–60 min; required before full product | +| Release / wiki | `compare-product` (**3** comparison-group shards × Win/Linux/mac) | ~2–2½h wall (Free account queues beyond 20 jobs) | +| Unary gRPC | `compare-grpc` | Win/Linux/mac; RPC/s @ c=64 | +| Heavier tables | `compare-bodies` (**2** shards) / `post` / `lossy` / `arch` (**3** shards) / `tls-cost` | dispatch independently | + +See [PERF-GATES.md](https://github.com/justcoding121/titanium-web-proxy/blob/develop/tools/RpsLoadProbe/PERF-GATES.md). + +### Arm shards (comparison groups) + +`--arm-shard i/n` (workflow input `arm_shard`) partitions **wiki rows** (Client×Origin + heavier/arch workload suffix), not individual proxy arms. Paste unions shard CSVs (`paste-compare-product-wiki.ps1 -RunIds …`; heavier `RUNS` lists). Table “Source: Actions” may list several run URLs for one table — **do not mix SHAs**. Free GitHub accounts allow **20** concurrent jobs (~34 for a full suite); extras **queue**, they do not fail. Dispatch product (and cross-version) first when wall clock matters. + +One wiki row = one GHA job’s Client×Origin cell set: TWP + YARP + nginx + HAProxy + Envoy (when the OS can run them) + TWP Lite + Full stay on the **same VM**. Shards split **rows**, not individual proxies — so **TWP÷YARP** and **Lite÷Reverse** remain same-job ratios. Do not compare **absolute** RPS across shards (different VMs). + +### Gate thresholds + +- Reverse product signal: **TWP÷YARP ≥ 0.70** (nginx / HAProxy / Envoy are wiki and chart peers only — no CI gate). +- MITM overhead: **Lite÷Reverse ≥ 0.50** and **Full÷Reverse ≥ 0.50** (median of 3 GHA runs @ c=64). Absolute RPS moves with runner heat; ratios are the claim. +- Editions: see [PERF-GATES.md](https://github.com/justcoding121/titanium-web-proxy/blob/develop/tools/RpsLoadProbe/PERF-GATES.md) and `validate-edition-gates.ps1`. +- Cross-version (Gate 2) before a major tag: run `compare-cross-version` (reverse matrix, routes unset) on `develop` vs committed 6.0 baselines (`baseline-6.0-win.csv` / `baseline-6.0-linux.csv`). + +| Gate | Threshold | +|------|-----------| +| Peer-normalized RPS (when YARP peer exists) | `(TWP÷YARP)_7 ÷ (TWP÷YARP)_6 ≥ 0.90` **or** current `(TWP÷YARP) ≥ 0.90` | +| Absolute RPS floor (TWP arms) | `7.0 ÷ 6.0 ≥ 0.70` (runner heat — peers move with the box) | +| RSS | `7.0 ÷ 6.0 ≤ 1.20` per TWP arm @ c=64 | + +nginx/YARP absolute RPS is **not** gated. See [`validate-cross-version.ps1`](https://github.com/justcoding121/titanium-web-proxy/blob/develop/tools/RpsLoadProbe/validate-cross-version.ps1). + +```powershell +pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-cross-version +pwsh tools/RpsLoadProbe/validate-cross-version.ps1 ` + -BaselineCsv tools/RpsLoadProbe/results/baseline-6.0-win.csv ` + -CurrentCsv tools/RpsLoadProbe/results/rps-ramp-*.csv +``` + +### Paste / regenerate + +Product refresh and heavier tables: + +```powershell +pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-product +pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-editions +pwsh tools/RpsLoadProbe/validate-edition-gates.ps1 -CsvPath tools/RpsLoadProbe/results/rps-ramp-*.csv +pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-bodies +pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-post +pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-lossy +pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-tls-cost +pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-arch +pwsh tools/RpsLoadProbe/run-rps.ps1 -Mode compare-saturation +``` + +Harness details: [RpsLoadProbe](https://github.com/justcoding121/titanium-web-proxy/tree/develop/tools/RpsLoadProbe). The harness does not disable TWP safety that peers also skip, and it does not retune to pass gates ([PERF-GATES.md](https://github.com/justcoding121/titanium-web-proxy/blob/develop/tools/RpsLoadProbe/PERF-GATES.md)). MITM leaf keys are a shared RSA/ECDSA pair per `CertificateManager` (intentional RPS tradeoff). diff --git a/wiki/Protocol-Support.md b/wiki/Protocol-Support.md index 004109c7c..c47524430 100644 --- a/wiki/Protocol-Support.md +++ b/wiki/Protocol-Support.md @@ -1,3 +1,7 @@ +# Protocol feature support + +> For operators and .NET library users evaluating what Titanium can speak end-to-end. + A feature-by-feature snapshot of what Titanium Web Proxy actually implements for HTTP/1.0, HTTP/1.1, HTTP/2, and HTTP/3, so you can tell at a glance whether something you depend on is fully supported, relayed best-effort, or not implemented yet. "Yes" means the proxy actively parses/enforces the feature diff --git a/wiki/Security-Considerations.md b/wiki/Security-Considerations.md index 8d129f7e2..9c2134c02 100644 --- a/wiki/Security-Considerations.md +++ b/wiki/Security-Considerations.md @@ -1,5 +1,7 @@ # Security considerations +> For operators and .NET library users deploying Titanium — what protections apply, and when they do not. + The 5.0 hardening pass (see the [migration guide](Migration-4.x-to-5.0) for the full list of behavior changes) closes a lot of gaps, but none of it should be read as a blanket guarantee. This page calls out where a protection is **conditional** — it only applies under a specific configuration or code diff --git a/wiki/Streaming-Bodies.md b/wiki/Streaming-Bodies.md index d50690fdb..fa6e1869f 100644 --- a/wiki/Streaming-Bodies.md +++ b/wiki/Streaming-Bodies.md @@ -1,5 +1,7 @@ # Streaming Bodies +> For .NET library users who need to inspect, transform, or generate request/response bodies without buffering them in memory. + By default Titanium relays request and response bodies as they flow, and only buffers a body in memory when you explicitly read it (`GetRequestBody()` / `GetResponseBody()`). For large downloads/uploads or endless streams (e.g. chunked server-sent events), buffering is undesirable or impossible. This page covers three related capabilities that avoid buffering: diff --git a/wiki/Synthetic-Responses.md b/wiki/Synthetic-Responses.md index 6ea5decbf..27e72a16d 100644 --- a/wiki/Synthetic-Responses.md +++ b/wiki/Synthetic-Responses.md @@ -1,5 +1,7 @@ # Synthetic Responses +> For .NET library users answering the client from a handler without contacting the origin (block, redirect, mock, or stream a local body). + Answer the client directly from a handler without contacting the origin, or replace an origin response entirely. Use **`e.Respond(ProxyResults.*)`** as the entry point for synthetic (locally generated) responses. This page covers: diff --git a/wiki/images/rps-practical-heavier-linux.png b/wiki/images/rps-practical-heavier-linux.png new file mode 100644 index 000000000..8a0e5576b Binary files /dev/null and b/wiki/images/rps-practical-heavier-linux.png differ diff --git a/wiki/images/rps-practical-heavier-windows.png b/wiki/images/rps-practical-heavier-windows.png new file mode 100644 index 000000000..817772390 Binary files /dev/null and b/wiki/images/rps-practical-heavier-windows.png differ diff --git a/wiki/images/rps-practical-linux.png b/wiki/images/rps-practical-linux.png new file mode 100644 index 000000000..73218d626 Binary files /dev/null and b/wiki/images/rps-practical-linux.png differ diff --git a/wiki/images/rps-practical-macos.png b/wiki/images/rps-practical-macos.png new file mode 100644 index 000000000..556900768 Binary files /dev/null and b/wiki/images/rps-practical-macos.png differ diff --git a/wiki/images/rps-practical-windows.png b/wiki/images/rps-practical-windows.png new file mode 100644 index 000000000..7b18a7dea Binary files /dev/null and b/wiki/images/rps-practical-windows.png differ