diff --git a/.codespellrc b/.codespellrc new file mode 100644 index 0000000000..15e29a7bfc --- /dev/null +++ b/.codespellrc @@ -0,0 +1,3 @@ +[codespell] +check-hidden = +skip = .git,docs/*/*,docs,*/go.mod,*/go.sum,./internal/phpheaders/phpheaders.go diff --git a/.github/scripts/docker-compute-fingerprints.sh b/.github/scripts/docker-compute-fingerprints.sh new file mode 100755 index 0000000000..709b45e55a --- /dev/null +++ b/.github/scripts/docker-compute-fingerprints.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +write_output() { + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "$1" >>"${GITHUB_OUTPUT}" + else + echo "$1" + fi +} + +get_php_version() { + local version="$1" + skopeo inspect "docker://docker.io/library/php:${version}" \ + --override-os linux \ + --override-arch amd64 | + jq -r '.Env[] | select(test("^PHP_VERSION=")) | sub("^PHP_VERSION="; "")' +} + +PHP_82_LATEST="$(get_php_version 8.2)" +PHP_83_LATEST="$(get_php_version 8.3)" +PHP_84_LATEST="$(get_php_version 8.4)" +PHP_85_LATEST="$(get_php_version 8.5)" + +PHP_VERSION="${PHP_82_LATEST},${PHP_83_LATEST},${PHP_84_LATEST},${PHP_85_LATEST}" +write_output "php_version=${PHP_VERSION}" +write_output "php82_version=${PHP_82_LATEST//./-}" +write_output "php83_version=${PHP_83_LATEST//./-}" +write_output "php84_version=${PHP_84_LATEST//./-}" +write_output "php85_version=${PHP_85_LATEST//./-}" + +if [[ "${GITHUB_EVENT_NAME:-}" == "schedule" ]]; then + FRANKENPHP_LATEST_TAG="$(gh release view --repo php/frankenphp --json tagName --jq '.tagName')" + git checkout "${FRANKENPHP_LATEST_TAG}" +fi + +METADATA="$(PHP_VERSION="${PHP_VERSION}" docker buildx bake --print | jq -c)" + +BASE_IMAGES=() +while IFS= read -r image; do + BASE_IMAGES+=("${image}") +done < <(jq -r ' + .target[]?.contexts? | to_entries[]? + | select(.value | startswith("docker-image://")) + | .value + | sub("^docker-image://"; "") +' <<<"${METADATA}" | sort -u) + +BASE_IMAGE_DIGESTS=() +for image in "${BASE_IMAGES[@]}"; do + if [[ "${image}" == */* ]]; then + ref="docker://docker.io/${image}" + else + ref="docker://docker.io/library/${image}" + fi + digest="$(skopeo inspect "${ref}" \ + --override-os linux \ + --override-arch amd64 \ + --format '{{.Digest}}')" + BASE_IMAGE_DIGESTS+=("${image}@${digest}") +done + +BASE_FINGERPRINT="$(printf '%s\n' "${BASE_IMAGE_DIGESTS[@]}" | sort | sha256sum | awk '{print $1}')" +write_output "base_fingerprint=${BASE_FINGERPRINT}" + +if [[ "${GITHUB_EVENT_NAME:-}" != "schedule" ]]; then + write_output "skip=false" + exit 0 +fi + +FRANKENPHP_LATEST_TAG_NO_PREFIX="${FRANKENPHP_LATEST_TAG#v}" +EXISTING_FINGERPRINT=$( + skopeo inspect "docker://docker.io/dunglas/frankenphp:${FRANKENPHP_LATEST_TAG_NO_PREFIX}" \ + --override-os linux \ + --override-arch amd64 | + jq -r '.Labels["dev.frankenphp.base.fingerprint"] // empty' +) + +if [[ -n "${EXISTING_FINGERPRINT}" ]] && [[ "${EXISTING_FINGERPRINT}" == "${BASE_FINGERPRINT}" ]]; then + write_output "skip=true" + exit 0 +fi + +write_output "ref=${FRANKENPHP_LATEST_TAG}" +write_output "skip=false" diff --git a/.github/scripts/docker-verify-fingerprints.sh b/.github/scripts/docker-verify-fingerprints.sh new file mode 100755 index 0000000000..2d50ff7175 --- /dev/null +++ b/.github/scripts/docker-verify-fingerprints.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +PHP_VERSION="${PHP_VERSION:-}" +GO_VERSION="${GO_VERSION:-}" +USE_LATEST_PHP="${USE_LATEST_PHP:-0}" + +if [[ -z "${GO_VERSION}" ]]; then + GO_VERSION="$(awk -F'"' '/variable "GO_VERSION"/ {f=1} f && /default/ {print $2; exit}' docker-bake.hcl)" + GO_VERSION="${GO_VERSION:-1.26}" +fi + +if [[ -z "${PHP_VERSION}" ]]; then + PHP_VERSION="$(awk -F'"' '/variable "PHP_VERSION"/ {f=1} f && /default/ {print $2; exit}' docker-bake.hcl)" + PHP_VERSION="${PHP_VERSION:-8.2,8.3,8.4,8.5}" +fi + +if [[ "${USE_LATEST_PHP}" == "1" ]]; then + PHP_82_LATEST=$(skopeo inspect docker://docker.io/library/php:8.2 --override-os linux --override-arch amd64 | jq -r '.Env[] | select(test("^PHP_VERSION=")) | sub("^PHP_VERSION="; "")') + PHP_83_LATEST=$(skopeo inspect docker://docker.io/library/php:8.3 --override-os linux --override-arch amd64 | jq -r '.Env[] | select(test("^PHP_VERSION=")) | sub("^PHP_VERSION="; "")') + PHP_84_LATEST=$(skopeo inspect docker://docker.io/library/php:8.4 --override-os linux --override-arch amd64 | jq -r '.Env[] | select(test("^PHP_VERSION=")) | sub("^PHP_VERSION="; "")') + PHP_85_LATEST=$(skopeo inspect docker://docker.io/library/php:8.5 --override-os linux --override-arch amd64 | jq -r '.Env[] | select(test("^PHP_VERSION=")) | sub("^PHP_VERSION="; "")') + PHP_VERSION="${PHP_82_LATEST},${PHP_83_LATEST},${PHP_84_LATEST},${PHP_85_LATEST}" +fi + +OS_LIST=() +while IFS= read -r os; do + OS_LIST+=("${os}") +done < <( + python3 - <<'PY' +import re + +with open("docker-bake.hcl", "r", encoding="utf-8") as f: + data = f.read() + +# Find the first "os = [ ... ]" block and extract quoted values +m = re.search(r'os\s*=\s*\[(.*?)\]', data, re.S) +if not m: + raise SystemExit(1) + +vals = re.findall(r'"([^"]+)"', m.group(1)) +for v in vals: + print(v) +PY +) + +IFS=',' read -r -a PHP_VERSIONS <<<"${PHP_VERSION}" + +BASE_IMAGES=() +for os in "${OS_LIST[@]}"; do + BASE_IMAGES+=("golang:${GO_VERSION}-${os}") + for pv in "${PHP_VERSIONS[@]}"; do + BASE_IMAGES+=("php:${pv}-zts-${os}") + done +done + +mapfile -t BASE_IMAGES < <(printf '%s\n' "${BASE_IMAGES[@]}" | sort -u) + +BASE_IMAGE_DIGESTS=() +for image in "${BASE_IMAGES[@]}"; do + if [[ "${image}" == */* ]]; then + ref="docker://docker.io/${image}" + else + ref="docker://docker.io/library/${image}" + fi + digest="$(skopeo inspect "${ref}" --override-os linux --override-arch amd64 --format '{{.Digest}}')" + BASE_IMAGE_DIGESTS+=("${image}@${digest}") +done + +hash_cmd="sha256sum" +if ! command -v "${hash_cmd}" >/dev/null 2>&1; then + hash_cmd="shasum -a 256" +fi + +fingerprint="$(printf '%s\n' "${BASE_IMAGE_DIGESTS[@]}" | sort | ${hash_cmd} | awk '{print $1}')" + +echo "PHP_VERSION=${PHP_VERSION}" +echo "GO_VERSION=${GO_VERSION}" +echo "OS_LIST=${OS_LIST[*]}" +echo "Base images:" +printf ' %s\n' "${BASE_IMAGES[@]}" +echo "Fingerprint: ${fingerprint}" diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 73d212c6c7..cbd4778033 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -50,54 +50,19 @@ jobs: php85_version: ${{ steps.check.outputs.php85_version }} skip: ${{ steps.check.outputs.skip }} ref: ${{ steps.check.outputs.ref || (github.event_name == 'workflow_dispatch' && inputs.version) || '' }} + base_fingerprint: ${{ steps.check.outputs.base_fingerprint }} steps: - - name: Check PHP versions - id: check - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - PHP_82_LATEST=$(skopeo inspect docker://docker.io/library/php:8.2 --override-os linux --override-arch amd64 | jq -r '.Env[] | select(test("^PHP_VERSION=")) | sub("^PHP_VERSION="; "")') - PHP_83_LATEST=$(skopeo inspect docker://docker.io/library/php:8.3 --override-os linux --override-arch amd64 | jq -r '.Env[] | select(test("^PHP_VERSION=")) | sub("^PHP_VERSION="; "")') - PHP_84_LATEST=$(skopeo inspect docker://docker.io/library/php:8.4 --override-os linux --override-arch amd64 | jq -r '.Env[] | select(test("^PHP_VERSION=")) | sub("^PHP_VERSION="; "")') - PHP_85_LATEST=$(skopeo inspect docker://docker.io/library/php:8.5 --override-os linux --override-arch amd64 | jq -r '.Env[] | select(test("^PHP_VERSION=")) | sub("^PHP_VERSION="; "")') - { - echo php_version="${PHP_82_LATEST},${PHP_83_LATEST},${PHP_84_LATEST},${PHP_85_LATEST}" - echo php82_version="${PHP_82_LATEST//./-}" - echo php83_version="${PHP_83_LATEST//./-}" - echo php84_version="${PHP_84_LATEST//./-}" - echo php85_version="${PHP_85_LATEST//./-}" - } >> "${GITHUB_OUTPUT}" - - # Check if the Docker images must be rebuilt - if [[ "${GITHUB_EVENT_NAME}" != "schedule" ]]; then - echo skip=false >> "${GITHUB_OUTPUT}" - exit 0 - fi - - FRANKENPHP_LATEST_TAG=$(gh release view --repo php/frankenphp --json tagName --jq '.tagName') - FRANKENPHP_LATEST_TAG_NO_PREFIX="${FRANKENPHP_LATEST_TAG#v}" - FRANKENPHP_82_LATEST=$(skopeo inspect docker://docker.io/dunglas/frankenphp:"${FRANKENPHP_LATEST_TAG_NO_PREFIX}"-php8.2 --override-os linux --override-arch amd64 | jq -r '.Env[] | select(test("^PHP_VERSION=")) | sub("^PHP_VERSION="; "")') - FRANKENPHP_83_LATEST=$(skopeo inspect docker://docker.io/dunglas/frankenphp:"${FRANKENPHP_LATEST_TAG_NO_PREFIX}"-php8.3 --override-os linux --override-arch amd64 | jq -r '.Env[] | select(test("^PHP_VERSION=")) | sub("^PHP_VERSION="; "")') - FRANKENPHP_84_LATEST=$(skopeo inspect docker://docker.io/dunglas/frankenphp:"${FRANKENPHP_LATEST_TAG_NO_PREFIX}"-php8.4 --override-os linux --override-arch amd64 | jq -r '.Env[] | select(test("^PHP_VERSION=")) | sub("^PHP_VERSION="; "")') - FRANKENPHP_85_LATEST=$(skopeo inspect docker://docker.io/dunglas/frankenphp:"${FRANKENPHP_LATEST_TAG_NO_PREFIX}"-php8.5 --override-os linux --override-arch amd64 | jq -r '.Env[] | select(test("^PHP_VERSION=")) | sub("^PHP_VERSION="; "")') - - if [[ "${FRANKENPHP_82_LATEST}" == "${PHP_82_LATEST}" ]] && [[ "${FRANKENPHP_83_LATEST}" == "${PHP_83_LATEST}" ]] && [[ "${FRANKENPHP_84_LATEST}" == "${PHP_84_LATEST}" ]] && [[ "${FRANKENPHP_85_LATEST}" == "${PHP_85_LATEST}" ]]; then - echo skip=true >> "${GITHUB_OUTPUT}" - exit 0 - fi - - { - echo ref="${FRANKENPHP_LATEST_TAG}" - echo skip=false - } >> "${GITHUB_OUTPUT}" - uses: actions/checkout@v6 - if: ${{ !fromJson(steps.check.outputs.skip) }} with: - ref: ${{ steps.check.outputs.ref }} + fetch-depth: 0 persist-credentials: false - name: Set up Docker Buildx - if: ${{ !fromJson(steps.check.outputs.skip) }} uses: docker/setup-buildx-action@v3 + - name: Check PHP versions and base image fingerprint + id: check + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./.github/scripts/docker-compute-fingerprints.sh - name: Create variants matrix if: ${{ !fromJson(steps.check.outputs.skip) }} id: matrix @@ -181,17 +146,18 @@ jobs: ${{ (github.event_name == 'pull_request') && '*.args.NO_COMPRESS=1' || '' }} *.tags= *.platform=${{ matrix.platform }} - builder-${{ matrix.variant }}.cache-from=type=gha,scope=builder-${{ matrix.variant }}-${{ needs.prepare.outputs.ref || github.ref }}-${{ matrix.platform }} - builder-${{ matrix.variant }}.cache-from=type=gha,scope=refs/heads/main-builder-${{ matrix.variant }}-${{ matrix.platform }} - builder-${{ matrix.variant }}.cache-to=type=gha,scope=builder-${{ matrix.variant }}-${{ needs.prepare.outputs.ref || github.ref }}-${{ matrix.platform }},ignore-error=true - runner-${{ matrix.variant }}.cache-from=type=gha,scope=runner-${{ matrix.variant }}-${{ needs.prepare.outputs.ref || github.ref }}-${{ matrix.platform }} - runner-${{ matrix.variant }}.cache-from=type=gha,scope=refs/heads/main-runner-${{ matrix.variant }}-${{ matrix.platform }} - runner-${{ matrix.variant }}.cache-to=type=gha,scope=runner-${{ matrix.variant }}-${{ needs.prepare.outputs.ref || github.ref }}-${{ matrix.platform }},ignore-error=true + ${{ fromJson(needs.prepare.outputs.push) && '' || format('builder-{0}.cache-from=type=gha,scope=builder-{0}-{1}-{2}', matrix.variant, needs.prepare.outputs.ref || github.ref, matrix.platform) }} + ${{ fromJson(needs.prepare.outputs.push) && '' || format('builder-{0}.cache-from=type=gha,scope=refs/heads/main-builder-{0}-{1}', matrix.variant, matrix.platform) }} + ${{ fromJson(needs.prepare.outputs.push) && '' || format('builder-{0}.cache-to=type=gha,scope=builder-{0}-{1}-{2},ignore-error=true', matrix.variant, needs.prepare.outputs.ref || github.ref, matrix.platform) }} + ${{ fromJson(needs.prepare.outputs.push) && '' || format('runner-{0}.cache-from=type=gha,scope=runner-{0}-{1}-{2}', matrix.variant, needs.prepare.outputs.ref || github.ref, matrix.platform) }} + ${{ fromJson(needs.prepare.outputs.push) && '' || format('runner-{0}.cache-from=type=gha,scope=refs/heads/main-runner-{0}-{1}', matrix.variant, matrix.platform) }} + ${{ fromJson(needs.prepare.outputs.push) && '' || format('runner-{0}.cache-to=type=gha,scope=runner-{0}-{1}-{2},ignore-error=true', matrix.variant, needs.prepare.outputs.ref || github.ref, matrix.platform) }} ${{ fromJson(needs.prepare.outputs.push) && format('*.output=type=image,name={0},push-by-digest=true,name-canonical=true,push=true', env.IMAGE_NAME) || '' }} env: SHA: ${{ github.sha }} VERSION: ${{ (github.ref_type == 'tag' && github.ref_name) || needs.prepare.outputs.ref || 'dev' }} PHP_VERSION: ${{ needs.prepare.outputs.php_version }} + BASE_FINGERPRINT: ${{ needs.prepare.outputs.base_fingerprint }} - # Workaround for https://github.com/actions/runner/pull/2477#issuecomment-1501003600 name: Export metadata if: fromJson(needs.prepare.outputs.push) @@ -208,7 +174,7 @@ jobs: VARIANT: ${{ matrix.variant }} - name: Upload builder metadata if: fromJson(needs.prepare.outputs.push) - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: metadata-builder-${{ matrix.variant }}-${{ steps.prepare.outputs.sanitized_platform }} path: /tmp/metadata/builder/* @@ -216,7 +182,7 @@ jobs: retention-days: 1 - name: Upload runner metadata if: fromJson(needs.prepare.outputs.push) - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: metadata-runner-${{ matrix.variant }}-${{ steps.prepare.outputs.sanitized_platform }} path: /tmp/metadata/runner/* @@ -225,8 +191,10 @@ jobs: - name: Run tests if: ${{ !fromJson(needs.prepare.outputs.push) }} run: | + # TODO: remove "containerimage.config.digest" fallback once all runners use buildx v0.18+ + # which replaced it with "containerimage.digest" and "containerimage.descriptor" docker run --platform="${PLATFORM}" --rm \ - "$(jq -r ".\"builder-${VARIANT}\".\"containerimage.config.digest\"" <<< "${METADATA}")" \ + "$(jq -r ".\"builder-${VARIANT}\" | .\"containerimage.config.digest\" // .\"containerimage.digest\"" <<< "${METADATA}")" \ sh -c "./go.sh test ${RACE} -v $(./go.sh list ./... | grep -v github.com/dunglas/frankenphp/internal/testext | grep -v github.com/dunglas/frankenphp/internal/extgen | tr '\n' ' ') && cd caddy && ../go.sh test ${RACE} -v ./..." env: METADATA: ${{ steps.build.outputs.metadata }} @@ -248,7 +216,7 @@ jobs: target: ["builder", "runner"] steps: - name: Download metadata - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v7 with: pattern: metadata-${{ matrix.target }}-${{ matrix.variant }}-* path: /tmp/metadata diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index f465e3a155..28eea80c33 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -47,5 +47,5 @@ jobs: VALIDATE_BIOME_LINT: false # Conflicts with MARKDOWN VALIDATE_MARKDOWN_PRETTIER: false - # To re-enable when https://github.com/super-linter/super-linter/issues/7244 will be closed - VALIDATE_EDITORCONFIG: false + # To re-enable when https://github.com/super-linter/super-linter/issues/7466 will be closed + VALIDATE_SPELL_CODESPELL: false diff --git a/.github/workflows/sanitizers.yaml b/.github/workflows/sanitizers.yaml index 49815aaca0..fe2f2d2518 100644 --- a/.github/workflows/sanitizers.yaml +++ b/.github/workflows/sanitizers.yaml @@ -35,7 +35,7 @@ jobs: USE_ZEND_ALLOC: 0 LIBRARY_PATH: ${{ github.workspace }}/php/target/lib:${{ github.workspace }}/watcher/target/lib LD_LIBRARY_PATH: ${{ github.workspace }}/php/target/lib - # PHP doesn't free some memory on purpose, we have to disable leaks detection: https://go.dev/doc/go1.25#go-command + # PHP doesn't free some memory on purpose, we have to disable leaks detection: https://go.dev/doc/go1.26#go-command ASAN_OPTIONS: detect_leaks=0 steps: - name: Remove local PHP @@ -45,7 +45,7 @@ jobs: persist-credentials: false - uses: actions/setup-go@v6 with: - go-version: "1.25" + go-version: "1.26" cache-dependency-path: | go.sum caddy/go.sum @@ -57,7 +57,7 @@ jobs: echo archive="$(jq -r '.[] .source[] | select(.filename |endswith(".xz")) | "https://www.php.net/distributions/" + .filename' version.json)" >> "$GITHUB_OUTPUT" - name: Cache PHP id: cache-php - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: php/target key: php-sanitizers-${{ matrix.sanitizer }}-${{ runner.arch }}-${{ steps.determine-php-version.outputs.version }} diff --git a/.github/workflows/static.yaml b/.github/workflows/static.yaml index cb97a302be..2feedbad62 100644 --- a/.github/workflows/static.yaml +++ b/.github/workflows/static.yaml @@ -150,9 +150,9 @@ jobs: ${{ (github.event_name == 'pull_request' || matrix.platform == 'linux/arm64') && 'static-builder-musl.args.NO_COMPRESS=1' || '' }} *.tags= *.platform=${{ matrix.platform }} - *.cache-from=type=gha,scope=${{ needs.prepare.outputs.ref || github.ref }}-static-builder-musl${{ matrix.debug && '-debug' || '' }}${{ matrix.mimalloc && '-mimalloc' || '' }} - *.cache-from=type=gha,scope=refs/heads/main-static-builder-musl${{ matrix.debug && '-debug' || '' }}${{ matrix.mimalloc && '-mimalloc' || '' }} - *.cache-to=type=gha,scope=${{ needs.prepare.outputs.ref || github.ref }}-static-builder-musl${{ matrix.debug && '-debug' || '' }}${{ matrix.mimalloc && '-mimalloc' || '' }},ignore-error=true + ${{ fromJson(needs.prepare.outputs.push) && '' || format('*.cache-from=type=gha,scope={0}-static-builder-musl{1}{2}', needs.prepare.outputs.ref || github.ref, matrix.debug && '-debug' || '', matrix.mimalloc && '-mimalloc' || '') }} + ${{ fromJson(needs.prepare.outputs.push) && '' || format('*.cache-from=type=gha,scope=refs/heads/main-static-builder-musl{0}{1}', matrix.debug && '-debug' || '', matrix.mimalloc && '-mimalloc' || '') }} + ${{ fromJson(needs.prepare.outputs.push) && '' || format('*.cache-to=type=gha,scope={0}-static-builder-musl{1}{2},ignore-error=true', needs.prepare.outputs.ref || github.ref, matrix.debug && '-debug' || '', matrix.mimalloc && '-mimalloc' || '') }} ${{ (fromJson(needs.prepare.outputs.push) && !matrix.debug && !matrix.mimalloc) && format('*.output=type=image,name={0},push-by-digest=true,name-canonical=true,push=true', env.IMAGE_NAME) || '' }} env: SHA: ${{ github.sha }} @@ -170,7 +170,7 @@ jobs: METADATA: ${{ steps.build.outputs.metadata }} - name: Upload metadata if: fromJson(needs.prepare.outputs.push) && !matrix.debug && !matrix.mimalloc - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: metadata-static-builder-musl-${{ steps.prepare.outputs.sanitized_platform }} path: /tmp/metadata/* @@ -179,7 +179,9 @@ jobs: - name: Copy binary run: | # shellcheck disable=SC2034 - digest=$(jq -r '."static-builder-musl"."${{ (fromJson(needs.prepare.outputs.push) && !matrix.debug && !matrix.mimalloc) && 'containerimage.digest' || 'containerimage.config.digest' }}"' <<< "${METADATA}") + # TODO: remove "containerimage.config.digest" fallback once all runners use buildx v0.18+ + # which replaced it with "containerimage.digest" and "containerimage.descriptor" + digest=$(jq -r '."static-builder-musl" | ${{ (fromJson(needs.prepare.outputs.push) && !matrix.debug && !matrix.mimalloc) && '."containerimage.digest"' || '(."containerimage.config.digest" // ."containerimage.digest")' }}' <<< "${METADATA}") docker create --platform="${PLATFORM}" --name static-builder-musl "${{ (fromJson(needs.prepare.outputs.push) && !matrix.debug && !matrix.mimalloc) && '${IMAGE_NAME}@${digest}' || '${digest}' }}" docker cp "static-builder-musl:/go/src/app/dist/${BINARY}" "${BINARY}${{ matrix.debug && '-debug' || '' }}${{ matrix.mimalloc && '-mimalloc' || '' }}" env: @@ -188,10 +190,11 @@ jobs: PLATFORM: ${{ matrix.platform }} - name: Upload artifact if: ${{ !fromJson(needs.prepare.outputs.push) }} - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: frankenphp-linux-${{ matrix.platform == 'linux/amd64' && 'x86_64' || 'aarch64' }}${{ matrix.debug && '-debug' || '' }}${{ matrix.mimalloc && '-mimalloc' || '' }} path: frankenphp-linux-${{ matrix.platform == 'linux/amd64' && 'x86_64' || 'aarch64' }}${{ matrix.debug && '-debug' || '' }}${{ matrix.mimalloc && '-mimalloc' || '' }} + compression-level: 0 - name: Upload assets if: fromJson(needs.prepare.outputs.push) && (needs.prepare.outputs.ref || github.ref_type == 'tag') run: gh release upload "${REF}" frankenphp-linux-${{ matrix.platform == 'linux/amd64' && 'x86_64' || 'aarch64' }}${{ matrix.debug && '-debug' || '' }}${{ matrix.mimalloc && '-mimalloc' || '' }} --repo dunglas/frankenphp --clobber @@ -300,9 +303,9 @@ jobs: ${{ (github.event_name == 'pull_request' || matrix.platform == 'linux/arm64') && 'static-builder-gnu.args.NO_COMPRESS=1' || '' }} *.tags= *.platform=${{ matrix.platform }} - *.cache-from=type=gha,scope=${{ needs.prepare.outputs.ref || github.ref }}-static-builder-gnu - *.cache-from=type=gha,scope=refs/heads/main-static-builder-gnu - *.cache-to=type=gha,scope=${{ needs.prepare.outputs.ref || github.ref }}-static-builder-gnu,ignore-error=true + ${{ fromJson(needs.prepare.outputs.push) && '' || format('*.cache-from=type=gha,scope={0}-static-builder-gnu', needs.prepare.outputs.ref || github.ref) }} + ${{ fromJson(needs.prepare.outputs.push) && '' || '*.cache-from=type=gha,scope=refs/heads/main-static-builder-gnu' }} + ${{ fromJson(needs.prepare.outputs.push) && '' || format('*.cache-to=type=gha,scope={0}-static-builder-gnu,ignore-error=true', needs.prepare.outputs.ref || github.ref) }} ${{ fromJson(needs.prepare.outputs.push) && format('*.output=type=image,name={0},push-by-digest=true,name-canonical=true,push=true', env.IMAGE_NAME) || '' }} env: SHA: ${{ github.sha }} @@ -320,7 +323,7 @@ jobs: METADATA: ${{ steps.build.outputs.metadata }} - name: Upload metadata if: fromJson(needs.prepare.outputs.push) - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: metadata-static-builder-gnu-${{ steps.prepare.outputs.sanitized_platform }} path: /tmp/metadata-gnu/* @@ -329,7 +332,9 @@ jobs: - name: Copy all frankenphp* files run: | # shellcheck disable=SC2034 - digest=$(jq -r '."static-builder-gnu"."${{ fromJson(needs.prepare.outputs.push) && 'containerimage.digest' || 'containerimage.config.digest' }}"' <<< "${METADATA}") + # TODO: remove "containerimage.config.digest" fallback once all runners use buildx v0.18+ + # which replaced it with "containerimage.digest" and "containerimage.descriptor" + digest=$(jq -r '."static-builder-gnu" | ${{ fromJson(needs.prepare.outputs.push) && '."containerimage.digest"' || '(."containerimage.config.digest" // ."containerimage.digest")' }}' <<< "${METADATA}") container_id=$(docker create --platform="${PLATFORM}" "${{ fromJson(needs.prepare.outputs.push) && '${IMAGE_NAME}@${digest}' || '${digest}' }}") mkdir -p gh-output cd gh-output @@ -344,7 +349,7 @@ jobs: PLATFORM: ${{ matrix.platform }} - name: Upload artifact if: ${{ !fromJson(needs.prepare.outputs.push) }} - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: frankenphp-linux-${{ matrix.platform == 'linux/amd64' && 'x86_64' || 'aarch64' }}-gnu-files path: gh-output/* @@ -380,13 +385,13 @@ jobs: if: fromJson(needs.prepare.outputs.push) steps: - name: Download metadata - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v7 with: pattern: metadata-static-builder-musl-* path: /tmp/metadata merge-multiple: true - name: Download GNU metadata - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v7 with: pattern: metadata-static-builder-gnu-* path: /tmp/metadata-gnu @@ -449,7 +454,7 @@ jobs: persist-credentials: false - uses: actions/setup-go@v6 with: - go-version: "1.25" + go-version: "1.26" cache-dependency-path: | go.sum caddy/go.sum @@ -475,7 +480,7 @@ jobs: NO_COMPRESS: ${{ github.event_name == 'pull_request' && '1' || '' }} - name: Upload logs if: ${{ failure() }} - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: path: dist/static-php-cli/log name: static-php-cli-log-${{ matrix.platform }}-${{ github.sha }} @@ -485,10 +490,11 @@ jobs: subject-path: ${{ github.workspace }}/dist/frankenphp-mac-* - name: Upload artifact if: github.ref_type == 'branch' - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: frankenphp-mac-${{ matrix.platform }} path: dist/frankenphp-mac-${{ matrix.platform }} + compression-level: 0 - name: Run sanity checks run: | "${BINARY}" version diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 1d48784abc..656b8bedad 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -41,7 +41,7 @@ jobs: persist-credentials: false - uses: actions/setup-go@v6 with: - go-version: "1.25" + go-version: "1.26" cache-dependency-path: | go.sum caddy/go.sum @@ -106,7 +106,7 @@ jobs: persist-credentials: false - uses: actions/setup-go@v6 with: - go-version: "1.25" + go-version: "1.26" cache-dependency-path: | go.sum caddy/go.sum @@ -147,7 +147,7 @@ jobs: persist-credentials: false - uses: actions/setup-go@v6 with: - go-version: "1.25" + go-version: "1.26" cache-dependency-path: | go.sum caddy/go.sum diff --git a/.github/workflows/translate.yaml b/.github/workflows/translate.yaml new file mode 100644 index 0000000000..94f7826730 --- /dev/null +++ b/.github/workflows/translate.yaml @@ -0,0 +1,69 @@ +name: Translate Docs +concurrency: + cancel-in-progress: true + group: ${{ github.workflow }}-${{ github.ref }} +on: + push: + branches: + - main + paths: + - "docs/*" +permissions: + contents: write + pull-requests: write +jobs: + build: + name: Translate Docs + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + - id: md_files + run: | + FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'docs/*.md') + FILES=$(echo "$FILES" | xargs -n1 basename | tr '\n' ' ') + [ -z "$FILES" ] && echo "found=false" >> "$GITHUB_OUTPUT" || echo "found=true" >> "$GITHUB_OUTPUT" + echo "files=$FILES" >> "$GITHUB_OUTPUT" + - name: Set up PHP + if: steps.md_files.outputs.found == 'true' + uses: shivammathur/setup-php@v2 + with: + php-version: "8.5" + - name: run translation script + if: steps.md_files.outputs.found == 'true' + env: + GEMINI_API_KEY: "${{ secrets.GEMINI_API_KEY }}" + MD_FILES: "${{ steps.md_files.outputs.files }}" + run: | + php ./docs/translate.php "$MD_FILES" + - name: Run Linter + if: steps.md_files.outputs.found == 'true' + continue-on-error: true + uses: super-linter/super-linter/slim@v8 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + LINTER_RULES_PATH: / + MARKDOWN_CONFIG_FILE: .markdown-lint.yaml + FIX_NATURAL_LANGUAGE: true + FIX_MARKDOWN: true + - name: Create Pull Request + if: steps.md_files.outputs.found == 'true' + uses: peter-evans/create-pull-request@v8 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + title: "docs: update translations" + commit-message: "docs: update translations" + committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> + author: ${{ github.actor }} <${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com> + branch: translations/${{ github.run_id }} + delete-branch: true + body: | + Translation updates for: ${{ steps.md_files.outputs.files }}. + labels: | + translations + bot + draft: false diff --git a/.gitignore b/.gitignore index 21bd3b59b4..f8fb0452fb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ /internal/testserver/testserver /internal/testcli/testcli /dist +/github_conf +/super-linter-output .DS_Store .idea/ .vscode/ diff --git a/.gitleaksignore b/.gitleaksignore index 0f25d7c4c9..c36609fe58 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -1 +1,2 @@ /github/workspace/docs/mercure.md:jwt:88 +/github/workspace/docs/mercure.md:jwt:90 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87750875b6..73d8b19ab8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,8 @@ If your Docker version is lower than 23.0, the build will fail due to dockerigno ## Running the test suite ```console -go test -tags watcher -race -v ./... +export CGO_CFLAGS=$(php-config --includes) CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" +go test -race -v ./... ``` ## Caddy module @@ -42,7 +43,7 @@ Build Caddy with the FrankenPHP Caddy module: ```console cd caddy/frankenphp/ -go build -tags watcher,brotli,nobadger,nomysql,nopgx +go build -tags nobadger,nomysql,nopgx cd ../../ ``` @@ -175,7 +176,7 @@ docker buildx bake -f docker-bake.hcl --pull --no-cache --push 8. In the container, you can use GDB and the like: ```console - go test -tags watcher -c -ldflags=-w + go test -tags -c -ldflags=-w gdb --args frankenphp.test -test.run ^MyTest$ ``` diff --git a/README.md b/README.md index a7553fd4d4..4668b076f5 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,9 @@ sudo pie-zts install asgrim/example-pie-extension Our maintainers offer deb packages for all systems using `apt`. To install, run: ```console -sudo curl -fsSL https://key.henderkes.com/static-php.gpg -o /usr/share/keyrings/static-php.gpg && \ -echo "deb [signed-by=/usr/share/keyrings/static-php.gpg] https://deb.henderkes.com/ stable main" | sudo tee /etc/apt/sources.list.d/static-php.list && \ +VERSION=85 # 82-85 available +sudo curl https://pkg.henderkes.com/api/packages/${VERSION}/debian/repository.key -o /etc/apt/keyrings/static-php${VERSION}.asc +echo "deb [signed-by=/etc/apt/keyrings/static-php${VERSION}.asc] https://pkg.henderkes.com/api/packages/${VERSION}/debian php-zts main" | sudo tee -a /etc/apt/sources.list.d/static-php${VERSION}.list sudo apt update sudo apt install frankenphp ``` @@ -75,6 +76,28 @@ sudo apt install pie-zts sudo pie-zts install asgrim/example-pie-extension ``` +### apk Packages + +Our maintainers offer apk packages for all systems using `apk`. To install, run: + +```console +VERSION=85 # 82-85 available +echo "https://pkg.henderkes.com/api/packages/${VERSION}/alpine/main/php-zts" | sudo tee -a /etc/apk/repositories +KEYFILE=$(curl -sJOw '%{filename_effective}' https://pkg.henderkes.com/api/packages/${VERSION}/alpine/key) +sudo mv ${KEYFILE} /etc/apk/keys/ && +sudo apk update && +sudo apk add frankenphp +``` + +**Installing extensions:** `sudo apk add php-zts-` + +For extensions not available by default, use [PIE](https://github.com/php/pie): + +```console +sudo apk add pie-zts +sudo pie-zts install asgrim/example-pie-extension +``` + ### Homebrew FrankenPHP is also available as a [Homebrew](https://brew.sh) package for macOS and Linux. @@ -128,6 +151,8 @@ Go to `https://localhost`, and enjoy! - [Worker mode](https://frankenphp.dev/docs/worker/) - [Early Hints support (103 HTTP status code)](https://frankenphp.dev/docs/early-hints/) - [Real-time](https://frankenphp.dev/docs/mercure/) +- [Logging](https://frankenphp.dev/docs/logging/) +- [Hot reloading](https://frankenphp.dev/docs/hot-reload/) - [Efficiently Serving Large Static Files](https://frankenphp.dev/docs/x-sendfile/) - [Configuration](https://frankenphp.dev/docs/config/) - [Writing PHP Extensions in Go](https://frankenphp.dev/docs/extensions/) @@ -138,6 +163,7 @@ Go to `https://localhost`, and enjoy! - [Create static binaries](https://frankenphp.dev/docs/static/) - [Compile from sources](https://frankenphp.dev/docs/compile/) - [Monitoring FrankenPHP](https://frankenphp.dev/docs/metrics/) +- [WordPress integration](https://frankenphp.dev/docs/wordpress/) - [Laravel integration](https://frankenphp.dev/docs/laravel/) - [Known issues](https://frankenphp.dev/docs/known-issues/) - [Demo app (Symfony) and benchmarks](https://github.com/dunglas/frankenphp-demo) diff --git a/build-static.sh b/build-static.sh index 3f3efc3354..a507e722b9 100755 --- a/build-static.sh +++ b/build-static.sh @@ -72,11 +72,11 @@ if [ -z "${PHP_VERSION}" ]; then fi } - PHP_VERSION="$(get_latest_php_version "8.4")" + PHP_VERSION="$(get_latest_php_version "8.5")" export PHP_VERSION fi # default extension set -defaultExtensions="amqp,apcu,ast,bcmath,brotli,bz2,calendar,ctype,curl,dba,dom,exif,fileinfo,filter,ftp,gd,gmp,gettext,iconv,igbinary,imagick,intl,ldap,lz4,mbregex,mbstring,memcache,memcached,mysqli,mysqlnd,opcache,openssl,password-argon2,parallel,pcntl,pdo,pdo_mysql,pdo_pgsql,pdo_sqlite,pdo_sqlsrv,pgsql,phar,posix,protobuf,readline,redis,session,shmop,simplexml,soap,sockets,sodium,sqlite3,ssh2,sysvmsg,sysvsem,sysvshm,tidy,tokenizer,xlswriter,xml,xmlreader,xmlwriter,xsl,xz,zip,zlib,yaml,zstd" +defaultExtensions="amqp,apcu,ast,bcmath,brotli,bz2,calendar,ctype,curl,dba,dom,exif,fileinfo,filter,ftp,gd,gmp,gettext,iconv,igbinary,imagick,intl,ldap,lz4,mbregex,mbstring,memcached,mysqli,mysqlnd,opcache,openssl,password-argon2,parallel,pcntl,pdo,pdo_mysql,pdo_pgsql,pdo_sqlite,pgsql,phar,posix,protobuf,readline,redis,session,shmop,simplexml,soap,sockets,sodium,sqlite3,ssh2,sysvmsg,sysvsem,sysvshm,tidy,tokenizer,xlswriter,xml,xmlreader,xmlwriter,xsl,xz,zip,zlib,yaml,zstd" defaultExtensionLibs="libavif,nghttp2,nghttp3,ngtcp2,watcher" if [ -z "${FRANKENPHP_VERSION}" ]; then @@ -147,10 +147,17 @@ else spcCommand="./bin/spc" fi +# turn potentially relative EMBED path into absolute path +if [ -n "${EMBED}" ]; then + if [[ "${EMBED}" != /* ]]; then + EMBED="${CURRENT_DIR}/${EMBED}" + fi +fi + # Extensions to build if [ -z "${PHP_EXTENSIONS}" ]; then # enable EMBED mode, first check if project has dumped extensions - if [ -n "${EMBED}" ] && [ -f "${EMBED}/composer.json" ] && [ -f "${EMBED}/composer.lock" ] && [ -f "${EMBED}/vendor/installed.json" ]; then + if [ -n "${EMBED}" ] && [ -f "${EMBED}/composer.json" ] && [ -f "${EMBED}/composer.lock" ] && [ -f "${EMBED}/vendor/composer/installed.json" ]; then cd "${EMBED}" # read the extensions using spc dump-extensions PHP_EXTENSIONS=$(${spcCommand} dump-extensions "${EMBED}" --format=text --no-dev --no-ext-output="${defaultExtensions}") @@ -178,7 +185,8 @@ fi # Embed PHP app, if any if [ -n "${EMBED}" ] && [ -d "${EMBED}" ]; then - SPC_OPT_BUILD_ARGS="${SPC_OPT_BUILD_ARGS} --with-frankenphp-app=${EMBED}" + # shellcheck disable=SC2089 + SPC_OPT_BUILD_ARGS="${SPC_OPT_BUILD_ARGS} --with-frankenphp-app='${EMBED}'" fi SPC_OPT_INSTALL_ARGS="go-xcaddy" @@ -194,7 +202,9 @@ else SPC_CMD_VAR_PHP_MAKE_EXTRA_CFLAGS="${SPC_CMD_VAR_PHP_MAKE_EXTRA_CFLAGS} -fPIE -fstack-protector-strong -O2 -w -s" fi export SPC_CMD_VAR_PHP_MAKE_EXTRA_CFLAGS -export SPC_CMD_VAR_FRANKENPHP_XCADDY_MODULES="--with github.com/dunglas/mercure/caddy --with github.com/dunglas/vulcain/caddy --with github.com/dunglas/caddy-cbrotli" +if [ -z "$SPC_CMD_VAR_FRANKENPHP_XCADDY_MODULES" ]; then + export SPC_CMD_VAR_FRANKENPHP_XCADDY_MODULES="--with github.com/dunglas/mercure/caddy --with github.com/dunglas/vulcain/caddy --with github.com/dunglas/caddy-cbrotli" +fi # Build FrankenPHP ${spcCommand} doctor --auto-fix @@ -204,7 +214,7 @@ done # shellcheck disable=SC2086 ${spcCommand} download --with-php="${PHP_VERSION}" --for-extensions="${PHP_EXTENSIONS}" --for-libs="${PHP_EXTENSION_LIBS}" ${SPC_OPT_DOWNLOAD_ARGS} export FRANKENPHP_SOURCE_PATH="${CURRENT_DIR}" -# shellcheck disable=SC2086 +# shellcheck disable=SC2086,SC2090 ${spcCommand} build --enable-zts --build-embed --build-frankenphp ${SPC_OPT_BUILD_ARGS} "${PHP_EXTENSIONS}" --with-libs="${PHP_EXTENSION_LIBS}" if [ -n "$CI" ]; then diff --git a/caddy/admin.go b/caddy/admin.go index ec6d7d7c51..8515f11326 100644 --- a/caddy/admin.go +++ b/caddy/admin.go @@ -3,12 +3,14 @@ package caddy import ( "encoding/json" "fmt" + "net/http" + "github.com/caddyserver/caddy/v2" "github.com/dunglas/frankenphp" - "net/http" ) -type FrankenPHPAdmin struct{} +type FrankenPHPAdmin struct { +} // if the id starts with "admin.api" the module will register AdminRoutes via module.Routes() func (FrankenPHPAdmin) CaddyModule() caddy.ModuleInfo { diff --git a/caddy/admin_test.go b/caddy/admin_test.go index ad0b5a8e1b..09576d3f84 100644 --- a/caddy/admin_test.go +++ b/caddy/admin_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strings" "sync" "testing" @@ -249,7 +250,7 @@ func TestAddModuleWorkerViaAdminApi(t *testing.T) { initialDebugState := getDebugState(t, tester) initialWorkerCount := 0 for _, thread := range initialDebugState.ThreadDebugStates { - if thread.Name != "" && thread.Name != "ready" { + if strings.HasPrefix(thread.Name, "Worker PHP Thread") { initialWorkerCount++ } } @@ -286,7 +287,7 @@ func TestAddModuleWorkerViaAdminApi(t *testing.T) { workerFound := false filename, _ := fastabs.FastAbs("../testdata/worker-with-counter.php") for _, thread := range updatedDebugState.ThreadDebugStates { - if thread.Name != "" && thread.Name != "ready" { + if strings.HasPrefix(thread.Name, "Worker PHP Thread") { updatedWorkerCount++ if thread.Name == "Worker PHP Thread - "+filename { workerFound = true diff --git a/caddy/caddy_test.go b/caddy/caddy_test.go index e359e3d4bc..a9fef03efd 100644 --- a/caddy/caddy_test.go +++ b/caddy/caddy_test.go @@ -11,6 +11,7 @@ import ( "sync" "sync/atomic" "testing" + "time" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddytest" @@ -19,6 +20,26 @@ import ( "github.com/stretchr/testify/require" ) +// waitForServerReady polls the server with retries until it responds to HTTP requests. +// This handles a race condition during Caddy config reload on macOS where SO_REUSEPORT +// can briefly route connections to the old listener being shut down, +// resulting in "connection reset by peer". +func waitForServerReady(t *testing.T, url string) { + t.Helper() + + client := &http.Client{Timeout: 1 * time.Second} + for range 10 { + resp, err := client.Get(url) + if err == nil { + require.NoError(t, resp.Body.Close()) + + return + } + + time.Sleep(100 * time.Millisecond) + } +} + var testPort = "9080" func TestPHP(t *testing.T) { @@ -406,6 +427,7 @@ func TestPHPServerDirective(t *testing.T) { } `, "caddyfile") + waitForServerReady(t, "http://localhost:"+testPort) tester.AssertGetResponse("http://localhost:"+testPort, http.StatusOK, "I am by birth a Genevese (i not set)") tester.AssertGetResponse("http://localhost:"+testPort+"/hello.txt", http.StatusOK, "Hello\n") tester.AssertGetResponse("http://localhost:"+testPort+"/not-found.txt", http.StatusOK, "I am by birth a Genevese (i not set)") @@ -431,6 +453,7 @@ func TestPHPServerDirectiveDisableFileServer(t *testing.T) { } `, "caddyfile") + waitForServerReady(t, "http://localhost:"+testPort) tester.AssertGetResponse("http://localhost:"+testPort, http.StatusOK, "I am by birth a Genevese (i not set)") tester.AssertGetResponse("http://localhost:"+testPort+"/not-found.txt", http.StatusOK, "I am by birth a Genevese (i not set)") } @@ -489,7 +512,9 @@ func TestMetrics(t *testing.T) { // Fetch metrics resp, err := http.Get("http://localhost:2999/metrics") require.NoError(t, err, "failed to fetch metrics") - defer resp.Body.Close() + t.Cleanup(func() { + require.NoError(t, resp.Body.Close()) + }) // Read and parse metrics metrics := new(bytes.Buffer) @@ -562,7 +587,9 @@ func TestWorkerMetrics(t *testing.T) { // Fetch metrics resp, err := http.Get("http://localhost:2999/metrics") require.NoError(t, err, "failed to fetch metrics") - defer resp.Body.Close() + t.Cleanup(func() { + require.NoError(t, resp.Body.Close()) + }) // Read and parse metrics metrics := new(bytes.Buffer) @@ -654,7 +681,9 @@ func TestNamedWorkerMetrics(t *testing.T) { // Fetch metrics resp, err := http.Get("http://localhost:2999/metrics") require.NoError(t, err, "failed to fetch metrics") - defer resp.Body.Close() + t.Cleanup(func() { + require.NoError(t, resp.Body.Close()) + }) // Read and parse metrics metrics := new(bytes.Buffer) @@ -745,7 +774,9 @@ func TestAutoWorkerConfig(t *testing.T) { // Fetch metrics resp, err := http.Get("http://localhost:2999/metrics") require.NoError(t, err, "failed to fetch metrics") - defer resp.Body.Close() + t.Cleanup(func() { + require.NoError(t, resp.Body.Close()) + }) // Read and parse metrics metrics := new(bytes.Buffer) @@ -904,8 +935,9 @@ func testSingleIniConfiguration(tester *caddytest.Tester, key string, value stri } func TestOsEnv(t *testing.T) { - os.Setenv("ENV1", "value1") - os.Setenv("ENV2", "value2") + require.NoError(t, os.Setenv("ENV1", "value1")) + require.NoError(t, os.Setenv("ENV2", "value2")) + tester := caddytest.NewTester(t) tester.InitServer(` { @@ -1050,9 +1082,11 @@ func TestMaxWaitTimeWorker(t *testing.T) { func getStatusCode(url string, t *testing.T) int { req, err := http.NewRequest("GET", url, nil) require.NoError(t, err) + resp, err := http.DefaultClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + require.NoError(t, resp.Body.Close()) + return resp.StatusCode } @@ -1111,7 +1145,9 @@ func TestMultiWorkersMetrics(t *testing.T) { // Fetch metrics resp, err := http.Get("http://localhost:2999/metrics") require.NoError(t, err, "failed to fetch metrics") - defer resp.Body.Close() + t.Cleanup(func() { + require.NoError(t, resp.Body.Close()) + }) // Read and parse metrics metrics := new(bytes.Buffer) @@ -1217,7 +1253,9 @@ func TestDisabledMetrics(t *testing.T) { // Fetch metrics resp, err := http.Get("http://localhost:2999/metrics") require.NoError(t, err, "failed to fetch metrics") - defer resp.Body.Close() + t.Cleanup(func() { + require.NoError(t, resp.Body.Close()) + }) // Read and parse metrics metrics := new(bytes.Buffer) @@ -1276,7 +1314,9 @@ func TestWorkerRestart(t *testing.T) { resp, err := http.Get("http://localhost:2999/metrics") require.NoError(t, err, "failed to fetch metrics") - defer resp.Body.Close() + t.Cleanup(func() { + require.NoError(t, resp.Body.Close()) + }) // Read and parse metrics metrics := new(bytes.Buffer) @@ -1500,3 +1540,253 @@ func TestLog(t *testing.T) { "", ) } + +// TestSymlinkWorkerPaths tests different ways to reference worker scripts in symlinked directories +func TestSymlinkWorkerPaths(t *testing.T) { + cwd, _ := os.Getwd() + publicDir := filepath.Join(cwd, "..", "testdata", "symlinks", "public") + + t.Run("NeighboringWorkerScript", func(t *testing.T) { + // Scenario: neighboring worker script + // Given frankenphp located in the test folder + // When I execute `frankenphp php-server --listen localhost:8080 -w index.php` from `public` + // Then I expect to see the worker script executed successfully + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + + frankenphp { + worker `+publicDir+`/index.php 1 + } + } + + localhost:`+testPort+` { + route { + php { + root `+publicDir+` + resolve_root_symlink true + } + } + } + `, "caddyfile") + + tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, "Request: 0\n") + }) + + t.Run("NestedWorkerScript", func(t *testing.T) { + // Scenario: nested worker script + // Given frankenphp located in the test folder + // When I execute `frankenphp --listen localhost:8080 -w nested/index.php` from `public` + // Then I expect to see the worker script executed successfully + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + + frankenphp { + worker `+publicDir+`/nested/index.php 1 + } + } + + localhost:`+testPort+` { + route { + php { + root `+publicDir+` + resolve_root_symlink true + } + } + } + `, "caddyfile") + + tester.AssertGetResponse("http://localhost:"+testPort+"/nested/index.php", http.StatusOK, "Nested request: 0\n") + }) + + t.Run("OutsideSymlinkedFolder", func(t *testing.T) { + // Scenario: outside the symlinked folder + // Given frankenphp located in the root folder + // When I execute `frankenphp --listen localhost:8080 -w public/index.php` from the root folder + // Then I expect to see the worker script executed successfully + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + + frankenphp { + worker { + name outside_worker + file `+publicDir+`/index.php + num 1 + } + } + } + + localhost:`+testPort+` { + route { + php { + root `+publicDir+` + resolve_root_symlink true + } + } + } + `, "caddyfile") + + tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, "Request: 0\n") + }) + + t.Run("SpecifiedRootDirectory", func(t *testing.T) { + // Scenario: specified root directory + // Given frankenphp located in the root folder + // When I execute `frankenphp --listen localhost:8080 -w public/index.php -r public` from the root folder + // Then I expect to see the worker script executed successfully + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + + frankenphp { + worker { + name specified_root_worker + file `+publicDir+`/index.php + num 1 + } + } + } + + localhost:`+testPort+` { + route { + php { + root `+publicDir+` + resolve_root_symlink true + } + } + } + `, "caddyfile") + + tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, "Request: 0\n") + }) +} + +// TestSymlinkResolveRoot tests the resolve_root_symlink directive behavior +func TestSymlinkResolveRoot(t *testing.T) { + cwd, _ := os.Getwd() + testDir := filepath.Join(cwd, "..", "testdata", "symlinks", "test") + publicDir := filepath.Join(cwd, "..", "testdata", "symlinks", "public") + + t.Run("ResolveRootSymlink", func(t *testing.T) { + // Tests that resolve_root_symlink directive works correctly + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + + frankenphp { + worker `+publicDir+`/document-root.php 1 + } + } + + localhost:`+testPort+` { + route { + php { + root `+publicDir+` + resolve_root_symlink true + } + } + } + `, "caddyfile") + + // DOCUMENT_ROOT should be the resolved path (testDir) + tester.AssertGetResponse("http://localhost:"+testPort+"/document-root.php", http.StatusOK, "DOCUMENT_ROOT="+testDir+"\n") + }) + + t.Run("NoResolveRootSymlink", func(t *testing.T) { + // Tests that symlinks are preserved when resolve_root_symlink is false (non-worker mode) + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + } + + localhost:`+testPort+` { + route { + php { + root `+publicDir+` + resolve_root_symlink false + } + } + } + `, "caddyfile") + + // DOCUMENT_ROOT should be the symlink path (publicDir) when resolve_root_symlink is false + tester.AssertGetResponse("http://localhost:"+testPort+"/document-root.php", http.StatusOK, "DOCUMENT_ROOT="+publicDir+"\n") + }) +} + +// TestSymlinkWorkerBehavior tests worker behavior with symlinked directories +func TestSymlinkWorkerBehavior(t *testing.T) { + cwd, _ := os.Getwd() + publicDir := filepath.Join(cwd, "..", "testdata", "symlinks", "public") + + t.Run("WorkerScriptFailsWithoutWorkerMode", func(t *testing.T) { + // Tests that accessing a worker-only script without configuring it as a worker actually results in an error + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + } + + localhost:`+testPort+` { + route { + php { + root `+publicDir+` + } + } + } + `, "caddyfile") + + // Accessing the worker script without worker configuration MUST fail + // The script checks $_SERVER['FRANKENPHP_WORKER'] and dies if not set + tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, "Error: This script must be run in worker mode (FRANKENPHP_WORKER not set to '1')\n") + }) + + t.Run("MultipleRequests", func(t *testing.T) { + // Tests that symlinked workers handle multiple requests correctly + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port `+testPort+` + } + + localhost:`+testPort+` { + route { + php { + root `+publicDir+` + resolve_root_symlink true + worker index.php 1 + } + } + } + `, "caddyfile") + + // Make multiple requests - each should increment the counter + for i := 0; i < 5; i++ { + tester.AssertGetResponse("http://localhost:"+testPort+"/index.php", http.StatusOK, fmt.Sprintf("Request: %d\n", i)) + } + }) +} diff --git a/caddy/go.mod b/caddy/go.mod index ae29d7565c..bd6d7b8125 100644 --- a/caddy/go.mod +++ b/caddy/go.mod @@ -1,18 +1,18 @@ module github.com/dunglas/frankenphp/caddy -go 1.25.4 +go 1.26.0 replace github.com/dunglas/frankenphp => ../ retract v1.0.0-rc.1 // Human error require ( - github.com/caddyserver/caddy/v2 v2.10.2 - github.com/caddyserver/certmagic v0.25.0 + github.com/caddyserver/caddy/v2 v2.11.1 + github.com/caddyserver/certmagic v0.25.2 github.com/dunglas/caddy-cbrotli v1.0.1 - github.com/dunglas/frankenphp v1.11.1 - github.com/dunglas/mercure v0.21.4 - github.com/dunglas/mercure/caddy v0.21.4 + github.com/dunglas/frankenphp v1.11.2 + github.com/dunglas/mercure v0.21.8 + github.com/dunglas/mercure/caddy v0.21.8 github.com/dunglas/vulcain/caddy v1.2.1 github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.2 @@ -23,33 +23,35 @@ require github.com/smallstep/go-attestation v0.4.4-0.20241119153605-2306d5b464ca require ( cel.dev/expr v0.25.1 // indirect - cloud.google.com/go/auth v0.18.0 // indirect + cloud.google.com/go/auth v0.18.2 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect dario.cat/mergo v1.0.2 // indirect - filippo.io/edwards25519 v1.1.0 // indirect + filippo.io/bigmod v0.1.0 // indirect + filippo.io/edwards25519 v1.2.0 // indirect github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect - github.com/BurntSushi/toml v1.5.0 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/DeRuina/timberjack v1.3.9 // indirect github.com/KimMachineGun/automemlimit v0.7.5 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 // indirect github.com/MicahParks/jwkset v0.11.0 // indirect - github.com/MicahParks/keyfunc/v3 v3.7.0 // indirect + github.com/MicahParks/keyfunc/v3 v3.8.0 // indirect github.com/RoaringBitmap/roaring/v2 v2.14.4 // indirect - github.com/alecthomas/chroma/v2 v2.21.0 // indirect + github.com/alecthomas/chroma/v2 v2.23.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect - github.com/caddyserver/zerossl v0.1.3 // indirect + github.com/caddyserver/zerossl v0.1.5 // indirect github.com/ccoveille/go-safecast/v2 v2.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chzyer/readline v1.5.1 // indirect - github.com/cloudflare/circl v1.6.1 // indirect + github.com/cloudflare/circl v1.6.3 // indirect github.com/coreos/go-oidc/v3 v3.17.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -62,12 +64,12 @@ require ( github.com/dunglas/skipfilter v1.0.0 // indirect github.com/dunglas/vulcain v1.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146 // indirect + github.com/e-dant/watcher/watcher-go v0.0.0-20260104182512-c28e9078050a // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/getkin/kin-openapi v0.133.0 // indirect - github.com/go-chi/chi/v5 v5.2.3 // indirect + github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-jose/go-jose/v3 v3.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -75,32 +77,32 @@ require ( github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/swag/jsonname v0.25.4 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/gofrs/uuid/v5 v5.4.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/brotli/go/cbrotli v1.1.0 // indirect - github.com/google/cel-go v0.26.1 // indirect + github.com/google/cel-go v0.27.0 // indirect github.com/google/certificate-transparency-go v1.3.2 // indirect - github.com/google/go-tpm v0.9.7 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/google/go-tspi v0.3.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect - github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.7.6 // indirect + github.com/jackc/pgx/v5 v5.8.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/klauspost/compress v1.18.2 // indirect + github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/libdns/libdns v1.1.1 // indirect @@ -108,10 +110,10 @@ require ( github.com/manifoldco/promptui v0.9.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/maypok86/otter/v2 v2.2.1 // indirect + github.com/maypok86/otter/v2 v2.3.0 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect - github.com/mholt/acmez/v3 v3.1.4 // indirect - github.com/miekg/dns v1.1.69 // indirect + github.com/mholt/acmez/v3 v3.1.6 // indirect + github.com/miekg/dns v1.1.72 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect @@ -123,23 +125,24 @@ require ( github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/pires/go-proxyproto v0.8.1 // indirect + github.com/pires/go-proxyproto v0.11.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.57.1 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect github.com/rs/cors v1.11.1 // indirect github.com/rs/xid v1.6.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect - github.com/sirupsen/logrus v1.9.3 // indirect - github.com/slackhq/nebula v1.9.7 // indirect - github.com/smallstep/certificates v0.29.0 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/slackhq/nebula v1.10.3 // indirect + github.com/smallstep/certificates v0.30.0-rc2.0.20260211214201-20608299c29c // indirect github.com/smallstep/cli-utils v0.12.2 // indirect github.com/smallstep/linkedca v0.25.0 // indirect github.com/smallstep/nosql v0.7.0 // indirect @@ -150,7 +153,6 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect - github.com/stoewer/go-strcase v1.3.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect github.com/tailscale/tscert v0.0.0-20251216020129-aea342f6d747 // indirect @@ -163,50 +165,63 @@ require ( github.com/woodsbury/decimal128 v1.4.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - github.com/yuin/goldmark v1.7.13 // indirect + github.com/yuin/goldmark v1.7.16 // indirect github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect github.com/zeebo/blake3 v0.2.4 // indirect go.etcd.io/bbolt v1.4.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect - go.opentelemetry.io/contrib/propagators/autoprop v0.64.0 // indirect - go.opentelemetry.io/contrib/propagators/aws v1.39.0 // indirect - go.opentelemetry.io/contrib/propagators/b3 v1.39.0 // indirect - go.opentelemetry.io/contrib/propagators/jaeger v1.39.0 // indirect - go.opentelemetry.io/contrib/propagators/ot v1.39.0 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/sdk v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.opentelemetry.io/contrib/bridges/prometheus v0.65.0 // indirect + go.opentelemetry.io/contrib/exporters/autoexport v0.65.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect + go.opentelemetry.io/contrib/propagators/autoprop v0.65.0 // indirect + go.opentelemetry.io/contrib/propagators/aws v1.40.0 // indirect + go.opentelemetry.io/contrib/propagators/b3 v1.40.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.40.0 // indirect + go.opentelemetry.io/contrib/propagators/ot v1.40.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.62.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 // indirect + go.opentelemetry.io/otel/log v0.16.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.16.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect - go.step.sm/crypto v0.75.0 // indirect + go.step.sm/crypto v0.76.2 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.uber.org/zap/exp v0.3.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/crypto/x509roots/fallback v0.0.0-20251210140736-7dacc380ba00 // indirect - golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 // indirect - golang.org/x/mod v0.31.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541 // indirect + golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.38.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.40.0 // indirect - google.golang.org/api v0.257.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect - google.golang.org/grpc v1.77.0 // indirect - google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.0 // indirect + golang.org/x/tools v0.42.0 // indirect + google.golang.org/api v0.267.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/grpc v1.79.1 // indirect + google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect howett.net/plist v1.0.1 // indirect ) diff --git a/caddy/go.sum b/caddy/go.sum index f190ab0fde..fc0fc23ba6 100644 --- a/caddy/go.sum +++ b/caddy/go.sum @@ -1,28 +1,35 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= -cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= -cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= -cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= -cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= -cloud.google.com/go/kms v1.23.2 h1:4IYDQL5hG4L+HzJBhzejUySoUOheh3Lk5YT4PCyyW6k= -cloud.google.com/go/kms v1.23.2/go.mod h1:rZ5kK0I7Kn9W4erhYVoIRPtpizjunlrfU4fUkumUp8g= -cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E= -cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/kms v1.25.0 h1:gVqvGGUmz0nYCmtoxWmdc1wli2L1apgP8U4fghPGSbQ= +cloud.google.com/go/kms v1.25.0/go.mod h1:XIdHkzfj0bUO3E+LvwPg+oc7s58/Ns8Nd8Sdtljihbk= +cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= +code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/bigmod v0.1.0 h1:UNzDk7y9ADKST+axd9skUpBQeW7fG2KrTZyOE4uGQy8= +filippo.io/bigmod v0.1.0/go.mod h1:OjOXDNlClLblvXdwgFFOQFJEocLhhtai8vGLy0JCZlI= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M= github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DeRuina/timberjack v1.3.9 h1:6UXZ1I7ExPGTX/1UNYawR58LlOJUHKBPiYC7WQ91eBo= +github.com/DeRuina/timberjack v1.3.9/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE= github.com/KimMachineGun/automemlimit v0.7.5 h1:RkbaC0MwhjL1ZuBKunGDjE/ggwAX43DwZrJqVwyveTk= github.com/KimMachineGun/automemlimit v0.7.5/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -35,8 +42,8 @@ github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145 h1:1yw6O62BReQ github.com/MauriceGit/skiplist v0.0.0-20211105230623-77f5c8d3e145/go.mod h1:877WBceefKn14QwVVn4xRFUsHsZb9clICgdeTj4XsUg= github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ= github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= -github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM= -github.com/MicahParks/keyfunc/v3 v3.7.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= +github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds= +github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/RoaringBitmap/roaring/v2 v2.14.4 h1:4aKySrrg9G/5oRtJ3TrZLObVqxgQ9f1znCRBwEwjuVw= @@ -44,8 +51,8 @@ github.com/RoaringBitmap/roaring/v2 v2.14.4/go.mod h1:oMvV6omPWr+2ifRdeZvVJyaz+a github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs= -github.com/alecthomas/chroma/v2 v2.21.0 h1:YVW9qQAFnQm2OFPPFQg6G/TpMxKSsUr/KUPDi/BEqtY= -github.com/alecthomas/chroma/v2 v2.21.0/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= @@ -54,46 +61,46 @@ github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmO github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw= github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A= -github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= -github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= -github.com/aws/aws-sdk-go-v2/config v1.32.1 h1:iODUDLgk3q8/flEC7ymhmxjfoAnBDwEEYEVyKZ9mzjU= -github.com/aws/aws-sdk-go-v2/config v1.32.1/go.mod h1:xoAgo17AGrPpJBSLg81W+ikM0cpOZG8ad04T2r+d5P0= -github.com/aws/aws-sdk-go-v2/credentials v1.19.1 h1:JeW+EwmtTE0yXFK8SmklrFh/cGTTXsQJumgMZNlbxfM= -github.com/aws/aws-sdk-go-v2/credentials v1.19.1/go.mod h1:BOoXiStwTF+fT2XufhO0Efssbi1CNIO/ZXpZu87N0pw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= +github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= +github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= +github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY= +github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E= -github.com/aws/aws-sdk-go-v2/service/kms v1.48.0 h1:pQgVxqqNOacqb19+xaoih/wNLil4d8tgi+FxtBi/qQY= -github.com/aws/aws-sdk-go-v2/service/kms v1.48.0/go.mod h1:VJcNH6BLr+3VJwinRKdotLOMglHO8mIKlD3ea5c7hbw= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.1 h1:BDgIUYGEo5TkayOWv/oBLPphWwNm/A91AebUjAu5L5g= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.1/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.4 h1:U//SlnkE1wOQiIImxzdY5PXat4Wq+8rlfVEw4Y7J8as= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.4/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9 h1:LU8S9W/mPDAU9q0FjCLi0TrCheLMGwzbRpvUMwYspcA= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 h1:GdGmKtG+/Krag7VfyOXV17xjTCz0i9NT+JnqLTOI5nA= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.1/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso= -github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= -github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= +github.com/aws/aws-sdk-go-v2/service/kms v1.49.5 h1:DKibav4XF66XSeaXcrn9GlWGHos6D/vJ4r7jsK7z5CE= +github.com/aws/aws-sdk-go-v2/service/kms v1.49.5/go.mod h1:1SdcmEGUEQE1mrU2sIgeHtcMSxHuybhPvuEPANzIDfI= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= -github.com/caddyserver/caddy/v2 v2.10.2 h1:g/gTYjGMD0dec+UgMw8SnfmJ3I9+M2TdvoRL/Ovu6U8= -github.com/caddyserver/caddy/v2 v2.10.2/go.mod h1:TXLQHx+ev4HDpkO6PnVVHUbL6OXt6Dfe7VcIBdQnPL0= -github.com/caddyserver/certmagic v0.25.0 h1:VMleO/XA48gEWes5l+Fh6tRWo9bHkhwAEhx63i+F5ic= -github.com/caddyserver/certmagic v0.25.0/go.mod h1:m9yB7Mud24OQbPHOiipAoyKPn9pKHhpSJxXR1jydBxA= -github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= -github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= +github.com/caddyserver/caddy/v2 v2.11.1 h1:C7sQpsFOC5CH+31KqJc7EoOf8mXrOEkFyYd6GpIqm/s= +github.com/caddyserver/caddy/v2 v2.11.1/go.mod h1:EOKnXuSSGlq2SuItwQuEVIsY5bRRi7tPJNHDm99XQXo= +github.com/caddyserver/certmagic v0.25.2 h1:D7xcS7ggX/WEY54x0czj7ioTkmDWKIgxtIi2OcQclUc= +github.com/caddyserver/certmagic v0.25.2/go.mod h1:llW/CvsNmza8S6hmsuggsZeiX+uS27dkqY27wDIuBWg= +github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= +github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/ccoveille/go-safecast/v2 v2.0.0 h1:+5eyITXAUj3wMjad6cRVJKGnC7vDS55zk0INzJagub0= github.com/ccoveille/go-safecast/v2 v2.0.0/go.mod h1:JIYA4CAR33blIDuE6fSwCp2sz1oOBahXnvmdBhOAABs= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -111,8 +118,8 @@ github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObk github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= -github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= -github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= @@ -145,10 +152,10 @@ github.com/dunglas/caddy-cbrotli v1.0.1 h1:mkg7EB1GmoyfBt3kY3mq4o/0bfnBeq7ZLQjmV github.com/dunglas/caddy-cbrotli v1.0.1/go.mod h1:uXABy3tjy1FABF+3JWKVh1ajFvIO/kfpwHaeZGSBaAY= github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= -github.com/dunglas/mercure v0.21.4 h1:mXPXHfB+4cYfFFCRRDY198mfef5+MQcdCpUnAHBUW2M= -github.com/dunglas/mercure v0.21.4/go.mod h1:l/dglCjp/OQx8/quRyceRPx2hqZQ3CNviwoLMRQiJ/k= -github.com/dunglas/mercure/caddy v0.21.4 h1:7o+6rDqfwj1EOmXOgfBFsayvJvOUP37xujQHaxuX4ps= -github.com/dunglas/mercure/caddy v0.21.4/go.mod h1:EM2s+OVGExbSXObUdAPDwPsbQw4t/icLtQv9CFylDvY= +github.com/dunglas/mercure v0.21.8 h1:D+SxSq0VqdB29lfMXrsvDkFvq/cTL94aKCC0R4heKV0= +github.com/dunglas/mercure v0.21.8/go.mod h1:kt4RJpixJOcPN+x9Z53VBhpJYSdyEEzuu9/99vJIocQ= +github.com/dunglas/mercure/caddy v0.21.8 h1:jfWSRUoialL3iH1AlmrVAIoU8EbGrLLGd4r+nhbBalg= +github.com/dunglas/mercure/caddy v0.21.8/go.mod h1:rU3iqkU44FASio9Fqqmwn50I0l7w67XDsXuKsqzSDrE= github.com/dunglas/skipfilter v1.0.0 h1:JG9SgGg4n6BlFwuTYzb9RIqjH7PfwszvWehanrYWPF4= github.com/dunglas/skipfilter v1.0.0/go.mod h1:ryhr8j7CAHSjzeN7wI6YEuwoArQ3OQmRqWWVCEAfb9w= github.com/dunglas/vulcain v1.2.1 h1:pkPwvIfoa/xmWSVUyhntbIKT+XO2VFMyhLKv1gA61O8= @@ -158,10 +165,12 @@ github.com/dunglas/vulcain/caddy v1.2.1/go.mod h1:8QrmLTfURmW2VgjTR6Gb9a53FrZjsp github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146 h1:h3vVM6X45PK0mAk8NqiYNQGXTyhvXy1HQ5GhuQN4eeA= -github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146/go.mod h1:sVUOkwtftoj71nnJRG2S0oWNfXFdKpz/M9vK0z06nmM= +github.com/e-dant/watcher/watcher-go v0.0.0-20260104182512-c28e9078050a h1:e/m9m8cJgjzw2Ol7tKTu4B/lM5F3Ym7ryKI+oyw0T8Y= +github.com/e-dant/watcher/watcher-go v0.0.0-20260104182512-c28e9078050a/go.mod h1:sVUOkwtftoj71nnJRG2S0oWNfXFdKpz/M9vK0z06nmM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= +github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -171,8 +180,8 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= -github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= -github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= @@ -192,14 +201,14 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1 github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= @@ -210,8 +219,8 @@ github.com/google/brotli/go/cbrotli v1.1.0 h1:YwHD/rwSgUSL4b2S3ZM2jnNymm+tmwKQqj github.com/google/brotli/go/cbrotli v1.1.0/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= -github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= +github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= github.com/google/certificate-transparency-go v1.3.2 h1:9ahSNZF2o7SYMaKaXhAumVEzXB2QaayzII9C8rv7v+A= github.com/google/certificate-transparency-go v1.3.2/go.mod h1:H5FpMUaGa5Ab2+KCYsxg6sELw3Flkl7pGZzWdBoYLXs= @@ -219,8 +228,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-tpm v0.9.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA= -github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm-tools v0.4.7 h1:J3ycC8umYxM9A4eF73EofRZu4BxY0jjQnUnkhIBbvws= github.com/google/go-tpm-tools v0.4.7/go.mod h1:gSyXTZHe3fgbzb6WEGd90QucmsnT1SRdlye82gH8QjQ= github.com/google/go-tspi v0.3.0 h1:ADtq8RKfP+jrTyIWIZDIYcKOMecRqNJFOew2IT0Inus= @@ -229,16 +238,16 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= -github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= -github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= @@ -251,16 +260,16 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= -github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -273,6 +282,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= +github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk= +github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U= +github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ= github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= @@ -284,14 +297,14 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/maypok86/otter/v2 v2.2.1 h1:hnGssisMFkdisYcvQ8L019zpYQcdtPse+g0ps2i7cfI= -github.com/maypok86/otter/v2 v2.2.1/go.mod h1:1NKY9bY+kB5jwCXBJfE59u+zAwOt6C7ni1FTlFFMqVs= +github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w= +github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/mholt/acmez/v3 v3.1.4 h1:DyzZe/RnAzT3rpZj/2Ii5xZpiEvvYk3cQEN/RmqxwFQ= -github.com/mholt/acmez/v3 v3.1.4/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= -github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc= -github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g= +github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= +github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -319,8 +332,8 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/peterbourgon/diskv/v3 v3.0.1 h1:x06SQA46+PKIUftmEujdwSEpIx8kR+M9eLYsUxeYveU= github.com/peterbourgon/diskv/v3 v3.0.1/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o= -github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= -github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= +github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4= +github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -333,14 +346,16 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= -github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10= -github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= @@ -358,14 +373,14 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/slackhq/nebula v1.9.7 h1:v5u46efIyYHGdfjFnozQbRRhMdaB9Ma1SSTcUcE2lfE= -github.com/slackhq/nebula v1.9.7/go.mod h1:1+4q4wd3dDAjO8rKCttSb9JIVbklQhuJiBp5I0lbIsQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/slackhq/nebula v1.10.3 h1:EstYj8ODEcv6T0R9X5BVq1zgWZnyU5gtPzk99QF1PMU= +github.com/slackhq/nebula v1.10.3/go.mod h1:IL5TUQm4x9IFx2kCKPYm1gP47pwd5b8QGnnBH2RHnvs= github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY= github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262/go.mod h1:MyOHs9Po2fbM1LHej6sBUT8ozbxmMOFG+E+rx/GSGuc= -github.com/smallstep/certificates v0.29.0 h1:f90szTKYTW62bmCc+qE5doGqIGPVxTQb8Ba37e/K8Zs= -github.com/smallstep/certificates v0.29.0/go.mod h1:27WI0od6gu84mvE4mYQ/QZGyYwHXvhsiSRNC+y3t+mo= +github.com/smallstep/certificates v0.30.0-rc2.0.20260211214201-20608299c29c h1:XQpX0IPYUAoJ661YlgfOJmY48ZOhIbglw4E2gw9mcyc= +github.com/smallstep/certificates v0.30.0-rc2.0.20260211214201-20608299c29c/go.mod h1:75NRLmYJq6ZcCb8ApJc+W1eL4oMYwjeufMJDHpv4rx4= github.com/smallstep/cli-utils v0.12.2 h1:lGzM9PJrH/qawbzMC/s2SvgLdJPKDWKwKzx9doCVO+k= github.com/smallstep/cli-utils v0.12.2/go.mod h1:uCPqefO29goHLGqFnwk0i8W7XJu18X3WHQFRtOm/00Y= github.com/smallstep/go-attestation v0.4.4-0.20241119153605-2306d5b464ca h1:VX8L0r8vybH0bPeaIxh4NQzafKQiqvlOn8pmOXbFLO4= @@ -400,8 +415,6 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= -github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -412,7 +425,6 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -450,8 +462,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= -github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= @@ -464,38 +476,66 @@ go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/prometheus v0.65.0 h1:I/7S/yWobR3QHFLqHsJ8QOndoiFsj1VgHpQiq43KlUI= +go.opentelemetry.io/contrib/bridges/prometheus v0.65.0/go.mod h1:jPF6gn3y1E+nozCAEQj3c6NZ8KY+tvAgSVfvoOJUFac= +go.opentelemetry.io/contrib/exporters/autoexport v0.65.0 h1:2gApdml7SznX9szEKFjKjM4qGcGSvAybYLBY319XG3g= +go.opentelemetry.io/contrib/exporters/autoexport v0.65.0/go.mod h1:0QqAGlbHXhmPYACG3n5hNzO5DnEqqtg4VcK5pr22RI0= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= -go.opentelemetry.io/contrib/propagators/autoprop v0.64.0 h1:VVrb1ErDD0Tlh/0K0rUqjky1e8AekjspTFN9sU2ekaA= -go.opentelemetry.io/contrib/propagators/autoprop v0.64.0/go.mod h1:QCsOQk+9Ep8Mkp4/aPtSzUT0dc8SaPYzBAE6o1jYuSE= -go.opentelemetry.io/contrib/propagators/aws v1.39.0 h1:IvNR8pAVGpkK1CHMjU/YE6B6TlnAPGFvogkMWRWU6wo= -go.opentelemetry.io/contrib/propagators/aws v1.39.0/go.mod h1:TUsFCERuGM4IGhJG9w+9l0nzmHUKHuaDYYNF6mtNgjY= -go.opentelemetry.io/contrib/propagators/b3 v1.39.0 h1:PI7pt9pkSnimWcp5sQhUA9OzLbc3Ba4sL+VEUTNsxrk= -go.opentelemetry.io/contrib/propagators/b3 v1.39.0/go.mod h1:5gV/EzPnfYIwjzj+6y8tbGW2PKWhcsz5e/7twptRVQY= -go.opentelemetry.io/contrib/propagators/jaeger v1.39.0 h1:Gz3yKzfMSEFzF0Vy5eIpu9ndpo4DhXMCxsLMF0OOApo= -go.opentelemetry.io/contrib/propagators/jaeger v1.39.0/go.mod h1:2D/cxxCqTlrday0rZrPujjg5aoAdqk1NaNyoXn8FJn8= -go.opentelemetry.io/contrib/propagators/ot v1.39.0 h1:vKTve1W/WKPVp1fzJamhCDDECt+5upJJ65bPyWoddGg= -go.opentelemetry.io/contrib/propagators/ot v1.39.0/go.mod h1:FH5VB2N19duNzh1Q8ks6CsZFyu3LFhNLiA9lPxyEkvU= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/contrib/propagators/autoprop v0.65.0 h1:kTaCycF9Xkm8VBBvH0rJ4wFeRjtIV55Erk3uuVsIs5s= +go.opentelemetry.io/contrib/propagators/autoprop v0.65.0/go.mod h1:rooPzAbXfxMX9fsPJjmOBg2SN4RhFEV8D7cfGK+N3tE= +go.opentelemetry.io/contrib/propagators/aws v1.40.0 h1:4VIrh75jW4RTimUNx1DSk+6H9/nDr1FvmKoOVDh3K04= +go.opentelemetry.io/contrib/propagators/aws v1.40.0/go.mod h1:B0dCov9KNQGlut3T8wZZjDnLXEXdBroM7bFsHh/gRos= +go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg= +go.opentelemetry.io/contrib/propagators/b3 v1.40.0/go.mod h1:72WvbdxbOfXaELEQfonFfOL6osvcVjI7uJEE8C2nkrs= +go.opentelemetry.io/contrib/propagators/jaeger v1.40.0 h1:aXl9uobjJs5vquMLt9ZkI/3zIuz8XQ3TqOKSWx0/xdU= +go.opentelemetry.io/contrib/propagators/jaeger v1.40.0/go.mod h1:ioMePqe6k6c/ovXSkmkMr1mbN5qRBGJxNTVop7/2XO0= +go.opentelemetry.io/contrib/propagators/ot v1.40.0 h1:Lon8J5SPmWaL1Ko2TIlCNHJ42/J1b5XbJlgJaE/9m7I= +go.opentelemetry.io/contrib/propagators/ot v1.40.0/go.mod h1:dKWtJTlp1Yj+8Cneye5idO46eRPIbi23qVuJYKjNnvY= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 h1:NOyNnS19BF2SUDApbOKbDtWZ0IK7b8FJ2uAGdIWOGb0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0/go.mod h1:VL6EgVikRLcJa9ftukrHu/ZkkhFBSo1lzvdBC9CF1ss= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go.opentelemetry.io/otel/exporters/prometheus v0.62.0 h1:krvC4JMfIOVdEuNPTtQ0ZjCiXrybhv+uOHMfHRmnvVo= +go.opentelemetry.io/otel/exporters/prometheus v0.62.0/go.mod h1:fgOE6FM/swEnsVQCqCnbOfRV4tOnWPg7bVeo4izBuhQ= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0 h1:ivlbaajBWJqhcCPniDqDJmRwj4lc6sRT+dCAVKNmxlQ= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0/go.mod h1:u/G56dEKDDwXNCVLsbSrllB2o8pbtFLUC4HpR66r2dc= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8= +go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4= +go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI= +go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4= +go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4= +go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= -go.step.sm/crypto v0.75.0 h1:UAHYD6q6ggYyzLlIKHv1MCUVjZIesXRZpGTlRC/HSHw= -go.step.sm/crypto v0.75.0/go.mod h1:wwQ57+ajmDype9mrI/2hRyrvJd7yja5xVgWYqpUN3PE= +go.step.sm/crypto v0.76.2 h1:JJ/yMcs/rmcCAwlo+afrHjq74XBFRTJw5B2y4Q4Z4c4= +go.step.sm/crypto v0.76.2/go.mod h1:m6KlB/HzIuGFep0UWI5e0SYi38UxpoKeCg6qUaHV6/Q= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -519,19 +559,19 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/crypto/x509roots/fallback v0.0.0-20251210140736-7dacc380ba00 h1:qObov2/X4yIpr98j5t6samg3mMF12Rl4taUJd1rWj+c= -golang.org/x/crypto/x509roots/fallback v0.0.0-20251210140736-7dacc380ba00/go.mod h1:MEIPiCnxvQEjA4astfaKItNwEVZA5Ki+3+nyGbJ5N18= -golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= -golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541 h1:FmKxj9ocLKn45jiR2jQMwCVhDvaK7fKQFzfuT9GvyK8= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -540,10 +580,10 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -561,7 +601,6 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -570,8 +609,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -581,8 +620,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -592,8 +631,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -602,31 +641,29 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.257.0 h1:8Y0lzvHlZps53PEaw+G29SsQIkuKrumGWs9puiexNAA= -google.golang.org/api v0.257.0/go.mod h1:4eJrr+vbVaZSqs7vovFd1Jb/A6ml6iw2e6FBYf3GAO4= -google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= -google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= -google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU= -google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= -google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.0 h1:6Al3kEFFP9VJhRz3DID6quisgPnTeZVr4lep9kkxdPA= -google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.0/go.mod h1:QLvsjh0OIR0TYBeiu2bkWGTJBUNQ64st52iWj/yA93I= +google.golang.org/api v0.267.0 h1:w+vfWPMPYeRs8qH1aYYsFX68jMls5acWl/jocfLomwE= +google.golang.org/api v0.267.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= +google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1 h1:/WILD1UcXj/ujCxgoL/DvRgt2CP3txG8+FwkUbb9110= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1/go.mod h1:YNKnb2OAApgYn2oYY47Rn7alMr1zWjb2U8Q0aoGWiNc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= -gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/caddy/mercure-skip.go b/caddy/mercure-skip.go index 7aead098f8..04f2ff2ba0 100644 --- a/caddy/mercure-skip.go +++ b/caddy/mercure-skip.go @@ -2,12 +2,17 @@ package caddy +import ( + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" +) + type mercureContext struct { } -func (f *FrankenPHPModule) configureHotReload(_ *FrankenPHPApp) error { - return nil +func (f *FrankenPHPModule) assignMercureHub(_ caddy.Context) { } -func (f *FrankenPHPModule) assignMercureHub(_ caddy.Context) { +func createMercureRoute() (caddyhttp.Route, error) { + return caddyhttp.Route{}, nil } diff --git a/caddy/mercure.go b/caddy/mercure.go index 081a6d3430..2de94259ca 100644 --- a/caddy/mercure.go +++ b/caddy/mercure.go @@ -3,7 +3,12 @@ package caddy import ( + "encoding/json" + "errors" + "os" "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" "github.com/dunglas/frankenphp" "github.com/dunglas/mercure" mercureCaddy "github.com/dunglas/mercure/caddy" @@ -22,8 +27,7 @@ func (f *FrankenPHPModule) assignMercureHub(ctx caddy.Context) { return } - opt := frankenphp.WithMercureHub(f.mercureHub) - f.mercureHubRequestOption = &opt + f.requestOptions = append(f.requestOptions, frankenphp.WithMercureHub(f.mercureHub)) for i, wc := range f.Workers { wc.mercureHub = f.mercureHub @@ -32,3 +36,36 @@ func (f *FrankenPHPModule) assignMercureHub(ctx caddy.Context) { f.Workers[i] = wc } } + +func createMercureRoute() (caddyhttp.Route, error) { + mercurePublisherJwtKey := os.Getenv("MERCURE_PUBLISHER_JWT_KEY") + if mercurePublisherJwtKey == "" { + return caddyhttp.Route{}, errors.New(`The "MERCURE_PUBLISHER_JWT_KEY" environment variable must be set to use the Mercure.rocks hub`) + } + + mercureSubscriberJwtKey := os.Getenv("MERCURE_SUBSCRIBER_JWT_KEY") + if mercureSubscriberJwtKey == "" { + return caddyhttp.Route{}, errors.New(`The "MERCURE_SUBSCRIBER_JWT_KEY" environment variable must be set to use the Mercure.rocks hub`) + } + + mercureRoute := caddyhttp.Route{ + HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject( + mercureCaddy.Mercure{ + PublisherJWT: mercureCaddy.JWTConfig{ + Alg: os.Getenv("MERCURE_PUBLISHER_JWT_ALG"), + Key: mercurePublisherJwtKey, + }, + SubscriberJWT: mercureCaddy.JWTConfig{ + Alg: os.Getenv("MERCURE_SUBSCRIBER_JWT_ALG"), + Key: mercureSubscriberJwtKey, + }, + }, + "handler", + "mercure", + nil, + ), + }, + } + + return mercureRoute, nil; +} diff --git a/caddy/module.go b/caddy/module.go index b116a5e1ac..e30535093b 100644 --- a/caddy/module.go +++ b/caddy/module.go @@ -51,7 +51,7 @@ type FrankenPHPModule struct { preparedEnv frankenphp.PreparedEnv preparedEnvNeedsReplacement bool logger *slog.Logger - mercureHubRequestOption *frankenphp.RequestOption + requestOptions []frankenphp.RequestOption } // CaddyModule returns the Caddy module information. @@ -108,8 +108,7 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error { } else { f.Root = filepath.Join(frankenphp.EmbeddedAppPath, defaultDocumentRoot) - var rrs bool - f.ResolveRootSymlink = &rrs + f.ResolveRootSymlink = new(false) } } else if frankenphp.EmbeddedAppPath != "" && filepath.IsLocal(f.Root) { f.Root = filepath.Join(frankenphp.EmbeddedAppPath, f.Root) @@ -119,9 +118,19 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error { f.SplitPath = []string{".php"} } + if opt, err := frankenphp.WithRequestSplitPath(f.SplitPath); err == nil { + f.requestOptions = append(f.requestOptions, opt) + } else { + f.requestOptions = append(f.requestOptions, opt) + } + if f.ResolveRootSymlink == nil { - rrs := true - f.ResolveRootSymlink = &rrs + f.ResolveRootSymlink = new(true) + } + + // Always pre-compute absolute file names for fallback matching + for i := range f.Workers { + f.Workers[i].absFileName, _ = fastabs.FastAbs(f.Workers[i].FileName) } if !needReplacement(f.Root) { @@ -138,7 +147,26 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error { } f.resolvedDocumentRoot = root + + // Resolve symlinks in worker file paths + for i, wc := range f.Workers { + if filepath.IsAbs(wc.FileName) { + resolvedPath, _ := filepath.EvalSymlinks(wc.FileName) + f.Workers[i].FileName = resolvedPath + f.Workers[i].absFileName = resolvedPath + } + } + } + + // Pre-compute relative match paths for all workers (requires resolved document root) + docRootWithSep := f.resolvedDocumentRoot + string(filepath.Separator) + for i := range f.Workers { + if strings.HasPrefix(f.Workers[i].absFileName, docRootWithSep) { + f.Workers[i].matchRelPath = filepath.ToSlash(f.Workers[i].absFileName[len(f.resolvedDocumentRoot):]) + } } + + f.requestOptions = append(f.requestOptions, frankenphp.WithRequestResolvedDocumentRoot(f.resolvedDocumentRoot)) } if f.preparedEnv == nil { @@ -153,6 +181,10 @@ func (f *FrankenPHPModule) Provision(ctx caddy.Context) error { } } + if !f.preparedEnvNeedsReplacement { + f.requestOptions = append(f.requestOptions, frankenphp.WithRequestPreparedEnv(f.preparedEnv)) + } + if err := f.configureHotReload(fapp); err != nil { return err } @@ -168,31 +200,32 @@ func needReplacement(s string) bool { // ServeHTTP implements caddyhttp.MiddlewareHandler. func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ caddyhttp.Handler) error { ctx := r.Context() - origReq := ctx.Value(caddyhttp.OriginalRequestCtxKey).(http.Request) repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer) - var ( - documentRootOption frankenphp.RequestOption - documentRoot string - ) + documentRoot := f.resolvedDocumentRoot + + opts := make([]frankenphp.RequestOption, 0, len(f.requestOptions)+4) + opts = append(opts, f.requestOptions...) - if f.resolvedDocumentRoot == "" { + if documentRoot == "" { documentRoot = repl.ReplaceKnown(f.Root, "") if documentRoot == "" && frankenphp.EmbeddedAppPath != "" { documentRoot = frankenphp.EmbeddedAppPath } - documentRootOption = frankenphp.WithRequestDocumentRoot(documentRoot, *f.ResolveRootSymlink) - } else { - documentRoot = f.resolvedDocumentRoot - documentRootOption = frankenphp.WithRequestResolvedDocumentRoot(documentRoot) + + // If we do not have a resolved document root, then we cannot resolve the symlink of our cwd because it may + // resolve to a different directory than the one we are currently in. + // This is especially important if there are workers running. + opts = append(opts, frankenphp.WithRequestDocumentRoot(documentRoot, false)) } - env := f.preparedEnv if f.preparedEnvNeedsReplacement { - env = make(frankenphp.PreparedEnv, len(f.Env)) + env := make(frankenphp.PreparedEnv, len(f.Env)) for k, v := range f.preparedEnv { env[k] = repl.ReplaceKnown(v, "") } + + opts = append(opts, frankenphp.WithRequestPreparedEnv(env)) } workerName := "" @@ -203,31 +236,14 @@ func (f *FrankenPHPModule) ServeHTTP(w http.ResponseWriter, r *http.Request, _ c } } - var ( - err error - fr *http.Request - ) - - if f.mercureHubRequestOption == nil { - fr, err = frankenphp.NewRequestWithContext( - r, - documentRootOption, - frankenphp.WithRequestSplitPath(f.SplitPath), - frankenphp.WithRequestPreparedEnv(env), - frankenphp.WithOriginalRequest(&origReq), - frankenphp.WithWorkerName(workerName), - ) - } else { - fr, err = frankenphp.NewRequestWithContext( - r, - documentRootOption, - frankenphp.WithRequestSplitPath(f.SplitPath), - frankenphp.WithRequestPreparedEnv(env), - frankenphp.WithOriginalRequest(&origReq), + fr, err := frankenphp.NewRequestWithContext( + r, + append( + opts, + frankenphp.WithOriginalRequest(new(ctx.Value(caddyhttp.OriginalRequestCtxKey).(http.Request))), frankenphp.WithWorkerName(workerName), - *f.mercureHubRequestOption, - ) - } + )..., + ) if err != nil { return caddyhttp.Error(http.StatusInternalServerError, err) @@ -461,8 +477,7 @@ func parsePhpServer(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) if phpsrv.Root == "" { phpsrv.Root = filepath.Join(frankenphp.EmbeddedAppPath, defaultDocumentRoot) fsrv.Root = phpsrv.Root - rrs := false - phpsrv.ResolveRootSymlink = &rrs + phpsrv.ResolveRootSymlink = new(false) } else if filepath.IsLocal(fsrv.Root) { phpsrv.Root = filepath.Join(frankenphp.EmbeddedAppPath, phpsrv.Root) fsrv.Root = phpsrv.Root diff --git a/caddy/php-server.go b/caddy/php-server.go index 612e4c5ecd..373eb4e0be 100644 --- a/caddy/php-server.go +++ b/caddy/php-server.go @@ -10,8 +10,6 @@ import ( "strings" "time" - mercureModule "github.com/dunglas/mercure/caddy" - "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig" caddycmd "github.com/caddyserver/caddy/v2/cmd" @@ -113,7 +111,7 @@ func cmdPHPServer(fs caddycmd.Flags) (int, error) { } if _, err := os.Stat("Caddyfile"); err == nil { - config, _, err := caddycmd.LoadConfig("Caddyfile", "caddyfile") + config, _, _, err := caddycmd.LoadConfig("Caddyfile", "caddyfile") if err != nil { return caddy.ExitCodeFailedStartup, err } @@ -253,34 +251,10 @@ func cmdPHPServer(fs caddycmd.Flags) (int, error) { } if mercure { - mercurePublisherJwtKey := os.Getenv("MERCURE_PUBLISHER_JWT_KEY") - if mercurePublisherJwtKey == "" { - panic(`The "MERCURE_PUBLISHER_JWT_KEY" environment variable must be set to use the Mercure.rocks hub`) - } - - mercureSubscriberJwtKey := os.Getenv("MERCURE_SUBSCRIBER_JWT_KEY") - if mercureSubscriberJwtKey == "" { - panic(`The "MERCURE_SUBSCRIBER_JWT_KEY" environment variable must be set to use the Mercure.rocks hub`) - } - - mercureRoute := caddyhttp.Route{ - HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject( - mercureModule.Mercure{ - PublisherJWT: mercureModule.JWTConfig{ - Alg: os.Getenv("MERCURE_PUBLISHER_JWT_ALG"), - Key: mercurePublisherJwtKey, - }, - SubscriberJWT: mercureModule.JWTConfig{ - Alg: os.Getenv("MERCURE_SUBSCRIBER_JWT_ALG"), - Key: mercureSubscriberJwtKey, - }, - }, - "handler", - "mercure", - nil, - ), - }, - } + mercureRoute, err := createMercureRoute() + if err != nil { + return caddy.ExitCodeFailedStartup, err + } subroute.Routes = append(caddyhttp.RouteList{mercureRoute}, subroute.Routes...) } diff --git a/caddy/workerconfig.go b/caddy/workerconfig.go index c22cf89448..c50f0d0688 100644 --- a/caddy/workerconfig.go +++ b/caddy/workerconfig.go @@ -2,6 +2,7 @@ package caddy import ( "net/http" + "path" "path/filepath" "strconv" @@ -43,6 +44,8 @@ type workerConfig struct { options []frankenphp.WorkerOption requestOptions []frankenphp.RequestOption + absFileName string + matchRelPath string // pre-computed relative URL path for fast matching } func unmarshalWorker(d *caddyfile.Dispenser) (workerConfig, error) { @@ -171,15 +174,28 @@ func (wc *workerConfig) inheritEnv(env map[string]string) { } func (wc *workerConfig) matchesPath(r *http.Request, documentRoot string) bool { - // try to match against a pattern if one is assigned if len(wc.MatchPath) != 0 { return (caddyhttp.MatchPath)(wc.MatchPath).Match(r) } - // if there is no pattern, try to match against the actual path (in the public directory) - fullScriptPath, _ := fastabs.FastAbs(documentRoot + "/" + r.URL.Path) - absFileName, _ := fastabs.FastAbs(wc.FileName) + // fast path: compare the request URL path against the pre-computed relative path + if wc.matchRelPath != "" { + reqPath := r.URL.Path + if reqPath == wc.matchRelPath { + return true + } + + // ensure leading slash for relative paths (see #2166) + if reqPath == "" || reqPath[0] != '/' { + reqPath = "/" + reqPath + } + + return path.Clean(reqPath) == wc.matchRelPath + } + + // fallback when documentRoot is dynamic (contains placeholders) + fullPath, _ := fastabs.FastAbs(filepath.Join(documentRoot, r.URL.Path)) - return fullScriptPath == absFileName + return fullPath == wc.absFileName } diff --git a/cgi.go b/cgi.go index 63fb1339b9..4668feb039 100644 --- a/cgi.go +++ b/cgi.go @@ -18,9 +18,12 @@ import ( "net/http" "path/filepath" "strings" + "unicode/utf8" "unsafe" "github.com/dunglas/frankenphp/internal/phpheaders" + "golang.org/x/text/language" + "golang.org/x/text/search" ) // Protocol versions, in Apache mod_ssl format: https://httpd.apache.org/docs/current/mod/mod_ssl.html @@ -64,6 +67,20 @@ var knownServerKeys = []string{ "REQUEST_URI", } +// cStringHTTPMethods caches C string versions of common HTTP methods +// to avoid allocations in pinCString on every request. +var cStringHTTPMethods = map[string]*C.char{ + "GET": C.CString("GET"), + "HEAD": C.CString("HEAD"), + "POST": C.CString("POST"), + "PUT": C.CString("PUT"), + "DELETE": C.CString("DELETE"), + "CONNECT": C.CString("CONNECT"), + "OPTIONS": C.CString("OPTIONS"), + "TRACE": C.CString("TRACE"), + "PATCH": C.CString("PATCH"), +} + // computeKnownVariables returns a set of CGI environment variables for the request. // // TODO: handle this case https://github.com/caddyserver/caddy/issues/3718 @@ -81,8 +98,9 @@ func addKnownVariablesToServer(fc *frankenPHPContext, trackVarsArray *C.zval) { } // Remove [] from IPv6 addresses - ip = strings.Replace(ip, "[", "", 1) - ip = strings.Replace(ip, "]", "", 1) + if len(ip) > 0 && ip[0] == '[' { + ip = ip[1 : len(ip)-1] + } var https, sslProtocol, sslCipher, rs string @@ -95,7 +113,7 @@ func addKnownVariablesToServer(fc *frankenPHPContext, trackVarsArray *C.zval) { rs = "https" https = "on" - // and pass the protocol details in a manner compatible with apache's mod_ssl + // and pass the protocol details in a manner compatible with Apache's mod_ssl // (which is why these have an SSL_ prefix and not TLS_). if v, ok := tlsProtocolStrings[request.TLS.Version]; ok { sslProtocol = v @@ -135,7 +153,7 @@ func addKnownVariablesToServer(fc *frankenPHPContext, trackVarsArray *C.zval) { if fc.originalRequest != nil { requestURI = fc.originalRequest.URL.RequestURI() } else { - requestURI = request.URL.RequestURI() + requestURI = fc.requestURI } C.frankenphp_register_bulk( @@ -145,7 +163,7 @@ func addKnownVariablesToServer(fc *frankenPHPContext, trackVarsArray *C.zval) { packCgiVariable(keys["REMOTE_PORT"], port), packCgiVariable(keys["DOCUMENT_ROOT"], fc.documentRoot), packCgiVariable(keys["PATH_INFO"], fc.pathInfo), - packCgiVariable(keys["PHP_SELF"], request.URL.Path), + packCgiVariable(keys["PHP_SELF"], ensureLeadingSlash(request.URL.Path)), packCgiVariable(keys["DOCUMENT_URI"], fc.docURI), packCgiVariable(keys["SCRIPT_FILENAME"], fc.scriptFilename), packCgiVariable(keys["SCRIPT_NAME"], fc.scriptName), @@ -249,27 +267,67 @@ func splitCgiPath(fc *frankenPHPContext) { // TODO: is it possible to delay this and avoid saving everything in the context? // SCRIPT_FILENAME is the absolute path of SCRIPT_NAME fc.scriptFilename = sanitizedPathJoin(fc.documentRoot, fc.scriptName) - fc.worker = getWorkerByPath(fc.scriptFilename) + fc.worker = workersByPath[fc.scriptFilename] } -// splitPos returns the index where path should -// be split based on SplitPath. +var splitSearchNonASCII = search.New(language.Und, search.IgnoreCase) + +// splitPos returns the index where path should be split based on splitPath. // example: if splitPath is [".php"] // "/path/to/script.php/some/path": ("/path/to/script.php", "/some/path") -// -// Adapted from https://github.com/caddyserver/caddy/blob/master/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go -// Copyright 2015 Matthew Holt and The Caddy Authors func splitPos(path string, splitPath []string) int { if len(splitPath) == 0 { return 0 } - lowerPath := strings.ToLower(path) + pathLen := len(path) + + // We are sure that split strings are all ASCII-only and lower-case because of validation and normalization in WithRequestSplitPath for _, split := range splitPath { - if idx := strings.Index(lowerPath, strings.ToLower(split)); idx > -1 { - return idx + len(split) + splitLen := len(split) + + for i := 0; i < pathLen; i++ { + if path[i] >= utf8.RuneSelf { + if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 { + return end + } + + break + } + + if i+splitLen > pathLen { + continue + } + + match := true + for j := 0; j < splitLen; j++ { + c := path[i+j] + + if c >= utf8.RuneSelf { + if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 { + return end + } + + break + } + + if 'A' <= c && c <= 'Z' { + c += 'a' - 'A' + } + + if c != split[j] { + match = false + + break + } + } + + if match { + return i + splitLen + } } } + return -1 } @@ -277,26 +335,20 @@ func splitPos(path string, splitPath []string) int { // See: https://github.com/php/php-src/blob/345e04b619c3bc11ea17ee02cdecad6ae8ce5891/main/SAPI.h#L72 // //export go_update_request_info -func go_update_request_info(threadIndex C.uintptr_t, info *C.sapi_request_info) { +func go_update_request_info(threadIndex C.uintptr_t, info *C.sapi_request_info) *C.char { thread := phpThreads[threadIndex] fc := thread.frankenPHPContext() request := fc.request if request == nil { - return + return nil } - authUser, authPassword, ok := request.BasicAuth() - if ok { - if authPassword != "" { - info.auth_password = thread.pinCString(authPassword) - } - if authUser != "" { - info.auth_user = thread.pinCString(authUser) - } + if m, ok := cStringHTTPMethods[request.Method]; ok { + info.request_method = m + } else { + info.request_method = thread.pinCString(request.Method) } - - info.request_method = thread.pinCString(request.Method) info.query_string = thread.pinCString(request.URL.RawQuery) info.content_length = C.zend_long(request.ContentLength) @@ -308,9 +360,16 @@ func go_update_request_info(threadIndex C.uintptr_t, info *C.sapi_request_info) info.path_translated = thread.pinCString(sanitizedPathJoin(fc.documentRoot, fc.pathInfo)) // See: http://www.oreilly.com/openbook/cgi/ch02_04.html } - info.request_uri = thread.pinCString(request.URL.RequestURI()) + info.request_uri = thread.pinCString(fc.requestURI) info.proto_num = C.int(request.ProtoMajor*1000 + request.ProtoMinor) + + authorizationHeader := request.Header.Get("Authorization") + if authorizationHeader == "" { + return nil + } + + return thread.pinCString(authorizationHeader) } // SanitizedPathJoin performs filepath.Join(root, reqPath) that @@ -343,7 +402,16 @@ func sanitizedPathJoin(root, reqPath string) string { const separator = string(filepath.Separator) +func ensureLeadingSlash(path string) string { + if path == "" || path[0] == '/' { + return path + } + + return "/" + path +} + func toUnsafeChar(s string) *C.char { sData := unsafe.StringData(s) + return (*C.char)(unsafe.Pointer(sData)) } diff --git a/cgi_test.go b/cgi_test.go new file mode 100644 index 0000000000..c4c7a7701c --- /dev/null +++ b/cgi_test.go @@ -0,0 +1,210 @@ +package frankenphp + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEnsureLeadingSlash(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + expected string + }{ + {"/index.php", "/index.php"}, + {"index.php", "/index.php"}, + {"/", "/"}, + {"", ""}, + {"/path/to/script.php", "/path/to/script.php"}, + {"path/to/script.php", "/path/to/script.php"}, + {"/index.php/path/info", "/index.php/path/info"}, + {"index.php/path/info", "/index.php/path/info"}, + } + + for _, tt := range tests { + t.Run(tt.input+"-"+tt.expected, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.expected, ensureLeadingSlash(tt.input), "ensureLeadingSlash(%q)", tt.input) + }) + } +} + +func TestSplitPos(t *testing.T) { + tests := []struct { + name string + path string + splitPath []string + wantPos int + }{ + { + name: "simple php extension", + path: "/path/to/script.php", + splitPath: []string{".php"}, + wantPos: 19, + }, + { + name: "php extension with path info", + path: "/path/to/script.php/some/path", + splitPath: []string{".php"}, + wantPos: 19, + }, + { + name: "case insensitive match", + path: "/path/to/script.PHP", + splitPath: []string{".php"}, + wantPos: 19, + }, + { + name: "mixed case match", + path: "/path/to/script.PhP/info", + splitPath: []string{".php"}, + wantPos: 19, + }, + { + name: "no match", + path: "/path/to/script.txt", + splitPath: []string{".php"}, + wantPos: -1, + }, + { + name: "empty split path", + path: "/path/to/script.php", + splitPath: []string{}, + wantPos: 0, + }, + { + name: "multiple split paths first match", + path: "/path/to/script.php", + splitPath: []string{".php", ".phtml"}, + wantPos: 19, + }, + { + name: "multiple split paths second match", + path: "/path/to/script.phtml", + splitPath: []string{".php", ".phtml"}, + wantPos: 21, + }, + // Unicode case-folding tests (security fix for GHSA-g966-83w7-6w38) + // U+023A (Ⱥ) lowercases to U+2C65 (ⱥ), which has different UTF-8 byte length + // Ⱥ: 2 bytes (C8 BA), ⱥ: 3 bytes (E2 B1 A5) + { + name: "unicode path with case-folding length expansion", + path: "/ȺȺȺȺshell.php", + splitPath: []string{".php"}, + wantPos: 18, // correct position in original string + }, + { + name: "unicode path with extension after expansion chars", + path: "/ȺȺȺȺshell.php/path/info", + splitPath: []string{".php"}, + wantPos: 18, + }, + { + name: "unicode in filename with multiple php occurrences", + path: "/ȺȺȺȺshell.php.txt.php", + splitPath: []string{".php"}, + wantPos: 18, // should match first .php, not be confused by byte offset shift + }, + { + name: "unicode case insensitive extension", + path: "/ȺȺȺȺshell.PHP", + splitPath: []string{".php"}, + wantPos: 18, + }, + { + name: "unicode in middle of path", + path: "/path/Ⱥtest/script.php", + splitPath: []string{".php"}, + wantPos: 23, // Ⱥ is 2 bytes, so path is 23 bytes total, .php ends at byte 23 + }, + { + name: "unicode only in directory not filename", + path: "/Ⱥ/script.php", + splitPath: []string{".php"}, + wantPos: 14, + }, + // Additional Unicode characters that expand when lowercased + // U+0130 (İ - Turkish capital I with dot) lowercases to U+0069 + U+0307 + { + name: "turkish capital I with dot", + path: "/İtest.php", + splitPath: []string{".php"}, + wantPos: 11, + }, + // Ensure standard ASCII still works correctly + { + name: "ascii only path with case variation", + path: "/PATH/TO/SCRIPT.PHP/INFO", + splitPath: []string{".php"}, + wantPos: 19, + }, + { + name: "path at root", + path: "/index.php", + splitPath: []string{".php"}, + wantPos: 10, + }, + { + name: "extension in middle of filename", + path: "/test.php.bak", + splitPath: []string{".php"}, + wantPos: 9, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPos := splitPos(tt.path, tt.splitPath) + assert.Equal(t, tt.wantPos, gotPos, "splitPos(%q, %v)", tt.path, tt.splitPath) + + // Verify that the split produces valid substrings + if gotPos > 0 && gotPos <= len(tt.path) { + scriptName := tt.path[:gotPos] + pathInfo := tt.path[gotPos:] + + // The script name should end with one of the split extensions (case-insensitive) + hasValidEnding := false + for _, split := range tt.splitPath { + if strings.HasSuffix(strings.ToLower(scriptName), split) { + hasValidEnding = true + + break + } + } + assert.True(t, hasValidEnding, "script name %q should end with one of %v", scriptName, tt.splitPath) + + // Original path should be reconstructable + assert.Equal(t, tt.path, scriptName+pathInfo, "path should be reconstructable from split parts") + } + }) + } +} + +// TestSplitPosUnicodeSecurityRegression specifically tests the vulnerability +// described in GHSA-g966-83w7-6w38 where Unicode case-folding caused +// incorrect SCRIPT_NAME/PATH_INFO splitting +func TestSplitPosUnicodeSecurityRegression(t *testing.T) { + // U+023A: Ⱥ (UTF-8: C8 BA). Lowercase is ⱥ (UTF-8: E2 B1 A5), longer in bytes. + path := "/ȺȺȺȺshell.php.txt.php" + split := []string{".php"} + + pos := splitPos(path, split) + + // The vulnerable code would return 22 (computed on lowercased string) + // The correct code should return 18 (position in original string) + expectedPos := strings.Index(path, ".php") + len(".php") + assert.Equal(t, expectedPos, pos, "split position should match first .php in original string") + assert.Equal(t, 18, pos, "split position should be 18, not 22") + + if pos > 0 && pos <= len(path) { + scriptName := path[:pos] + pathInfo := path[pos:] + + assert.Equal(t, "/ȺȺȺȺshell.php", scriptName, "script name should be the path up to first .php") + assert.Equal(t, ".txt.php", pathInfo, "path info should be the remainder after first .php") + } +} diff --git a/context.go b/context.go index 3126e8f9aa..92f3b7471c 100644 --- a/context.go +++ b/context.go @@ -28,13 +28,15 @@ type frankenPHPContext struct { pathInfo string scriptName string scriptFilename string + requestURI string // Whether the request is already closed by us isDone bool - responseWriter http.ResponseWriter - handlerParameters any - handlerReturn any + responseWriter http.ResponseWriter + responseController *http.ResponseController + handlerParameters any + handlerReturn any done chan any startedAt time.Time @@ -93,6 +95,8 @@ func NewRequestWithContext(r *http.Request, opts ...RequestOption) (*http.Reques splitCgiPath(fc) } + fc.requestURI = r.URL.RequestURI() + c := context.WithValue(r.Context(), contextKey, fc) return r.WithContext(c), nil diff --git a/dev-alpine.Dockerfile b/dev-alpine.Dockerfile index b929c0a1b0..04efda04e8 100644 --- a/dev-alpine.Dockerfile +++ b/dev-alpine.Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 #checkov:skip=CKV_DOCKER_2 #checkov:skip=CKV_DOCKER_3 -FROM golang:1.25-alpine +FROM golang:1.26-alpine ENV GOTOOLCHAIN=local ENV CFLAGS="-ggdb3" diff --git a/dev.Dockerfile b/dev.Dockerfile index 62e83e3821..a0d1496b98 100644 --- a/dev.Dockerfile +++ b/dev.Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1 #checkov:skip=CKV_DOCKER_2 #checkov:skip=CKV_DOCKER_3 -FROM golang:1.25 +FROM golang:1.26 ENV GOTOOLCHAIN=local ENV CFLAGS="-ggdb3" diff --git a/docker-bake.hcl b/docker-bake.hcl index ec4a8d52a7..0d7cc1656f 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -11,7 +11,11 @@ variable "PHP_VERSION" { } variable "GO_VERSION" { - default = "1.25" + default = "1.26" +} + +variable "BASE_FINGERPRINT" { + default = "" } variable "SPC_OPT_BUILD_ARGS" { @@ -120,6 +124,7 @@ target "default" { "org.opencontainers.image.created" = "${timestamp()}" "org.opencontainers.image.version" = VERSION "org.opencontainers.image.revision" = SHA + "dev.frankenphp.base.fingerprint" = BASE_FINGERPRINT } args = { FRANKENPHP_VERSION = VERSION @@ -146,6 +151,7 @@ target "static-builder-musl" { "org.opencontainers.image.created" = "${timestamp()}" "org.opencontainers.image.version" = VERSION "org.opencontainers.image.revision" = SHA + "dev.frankenphp.base.fingerprint" = BASE_FINGERPRINT } args = { FRANKENPHP_VERSION = VERSION @@ -171,6 +177,7 @@ target "static-builder-gnu" { "org.opencontainers.image.created" = "${timestamp()}" "org.opencontainers.image.version" = VERSION "org.opencontainers.image.revision" = SHA + "dev.frankenphp.base.fingerprint" = BASE_FINGERPRINT } args = { FRANKENPHP_VERSION = VERSION diff --git a/docs/cn/CONTRIBUTING.md b/docs/cn/CONTRIBUTING.md index 8b6666bd46..b72c63fc98 100644 --- a/docs/cn/CONTRIBUTING.md +++ b/docs/cn/CONTRIBUTING.md @@ -33,7 +33,7 @@ docker run --cap-add=SYS_PTRACE --security-opt seccomp=unconfined -p 8080:8080 - ## 运行测试套件 ```console -go test -tags watcher -race -v ./... +go test -race -v ./... ``` ## Caddy 模块 @@ -42,7 +42,7 @@ go test -tags watcher -race -v ./... ```console cd caddy/frankenphp/ -go build -tags watcher,brotli,nobadger,nomysql,nopgx +go build -tags nobadger,nomysql,nopgx cd ../../ ``` @@ -175,7 +175,7 @@ docker buildx bake -f docker-bake.hcl --pull --no-cache --push 8. 在容器中,可以使用 GDB 和以下: ```console - go test -tags watcher -c -ldflags=-w + go test -c -ldflags=-w gdb --args frankenphp.test -test.run ^MyTest$ ``` diff --git a/docs/config.md b/docs/config.md index 65bd5354a6..0252f54256 100644 --- a/docs/config.md +++ b/docs/config.md @@ -12,7 +12,7 @@ A minimal `Caddyfile` to serve a PHP application is shown below: # The hostname to respond to localhost -# Optionaly, the directory to serve files from, otherwise defaults to the current directory +# Optionally, the directory to serve files from, otherwise defaults to the current directory #root public/ php_server ``` @@ -213,8 +213,10 @@ This is useful for development environments. } ``` -If the `watch` directory is not specified, it will fall back to `./**/*.{php,yaml,yml,twig,env}`, -which watches all `.php`, `.yaml`, `.yml`, `.twig` and `.env` files in the directory and subdirectories +This feature is often used in combination with [hot reload](hot-reload.md). + +If the `watch` directory is not specified, it will fall back to `./**/*.{env,php,twig,yaml,yml}`, +which watches all `.env`, `.php`, `.twig`, `.yaml` and `.yml` files in the directory and subdirectories where the FrankenPHP process was started. You can instead also specify one or more directories via a [shell filename pattern](https://pkg.go.dev/path/filepath#Match): @@ -239,7 +241,7 @@ where the FrankenPHP process was started. You can instead also specify one or mo The file watcher is based on [e-dant/watcher](https://github.com/e-dant/watcher). -## Matching the worker to a path +## Matching the Worker To a Path In traditional PHP applications, scripts are always placed in the public directory. This is also true for worker scripts, which are treated like any other PHP script. diff --git a/docs/docker.md b/docs/docker.md index 23af3fa027..bb3f66d606 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -203,6 +203,79 @@ The Docker images are built: - when a new release is tagged - daily at 4 am UTC, if new versions of the official PHP images are available +## Hardening Images + +To further reduce the attack surface and size of your FrankenPHP Docker images, it's also possible to build them on top of a +[Google distroless](https://github.com/GoogleContainerTools/distroless) or +[Docker hardened](https://www.docker.com/products/hardened-images) image. + +> [!WARNING] +> These minimal base images do not include a shell or package manager, which makes debugging more difficult. +> They are therefore recommended only for production if security is a high priority. + +When adding additional PHP extensions, you will need an intermediate build stage: + +```dockerfile +FROM dunglas/frankenphp AS builder + +# Add additional PHP extensions here +RUN install-php-extensions pdo_mysql pdo_pgsql #... + +# Copy shared libs of frankenphp and all installed extensions to temporary location +# You can also do this step manually by analyzing ldd output of frankenphp binary and each extension .so file +RUN apt-get update && apt-get install -y libtree && \ + EXT_DIR="$(php -r 'echo ini_get("extension_dir");')" && \ + FRANKENPHP_BIN="$(which frankenphp)"; \ + LIBS_TMP_DIR="/tmp/libs"; \ + mkdir -p "$LIBS_TMP_DIR"; \ + for target in "$FRANKENPHP_BIN" $(find "$EXT_DIR" -maxdepth 2 -type f -name "*.so"); do \ + libtree -pv "$target" | sed 's/.*── \(.*\) \[.*/\1/' | grep -v "^$target" | while IFS= read -r lib; do \ + [ -z "$lib" ] && continue; \ + base=$(basename "$lib"); \ + destfile="$LIBS_TMP_DIR/$base"; \ + if [ ! -f "$destfile" ]; then \ + cp "$lib" "$destfile"; \ + fi; \ + done; \ + done + + +# Distroless debian base image, make sure this is the same debian version as the base image +FROM gcr.io/distroless/base-debian13 +# Docker hardened image alternative +# FROM dhi.io/debian:13 + +# Location of your app and Caddyfile to be copied into the container +ARG PATH_TO_APP="." +ARG PATH_TO_CADDYFILE="./Caddyfile" + +# Copy your app into /app +# For further hardening make sure only writable paths are owned by the nonroot user +COPY --chown=nonroot:nonroot "$PATH_TO_APP" /app +COPY "$PATH_TO_CADDYFILE" /etc/caddy/Caddyfile + +# Copy frankenphp and necessary libs +COPY --from=builder /usr/local/bin/frankenphp /usr/local/bin/frankenphp +COPY --from=builder --chown=nonroot:nonroot /usr/local/lib/php/extensions /usr/local/lib/php/extensions +COPY --from=builder /tmp/libs /usr/lib + +# Copy php.ini configuration files +COPY --from=builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d +COPY --from=builder /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini + +# Create necessary caddy dirs +# These dirs also need to be writable in case of a read-only root filesystem +COPY --from=builder --chown=nonroot:nonroot /data/caddy /data/caddy +COPY --from=builder --chown=nonroot:nonroot /config/caddy /config/caddy + +USER nonroot + +WORKDIR /app + +# entrypoint to run frankenphp with the provided Caddyfile +ENTRYPOINT ["/usr/local/bin/frankenphp", "run", "-c", "/etc/caddy/Caddyfile"] +``` + ## Development Versions Development versions are available in the [`dunglas/frankenphp-dev`](https://hub.docker.com/repository/docker/dunglas/frankenphp-dev) Docker repository. diff --git a/docs/extension-workers.md b/docs/extension-workers.md new file mode 100644 index 0000000000..dd8527d272 --- /dev/null +++ b/docs/extension-workers.md @@ -0,0 +1,172 @@ +# Extension Workers + +Extension Workers enable your [FrankenPHP extension](https://frankenphp.dev/docs/extensions/) to manage a dedicated pool of PHP threads for executing background tasks, handling asynchronous events, or implementing custom protocols. Useful for queue systems, event listeners, schedulers, etc. + +## Registering the Worker + +### Static Registration + +If you don't need to make the worker configurable by the user (fixed script path, fixed number of threads), you can simply register the worker in the `init()` function. + +```go +package myextension + +import ( + "github.com/dunglas/frankenphp" + "github.com/dunglas/frankenphp/caddy" +) + +// Global handle to communicate with the worker pool +var worker frankenphp.Workers + +func init() { + // Register the worker when the module is loaded. + worker = caddy.RegisterWorkers( + "my-internal-worker", // Unique name + "worker.php", // Script path (relative to execution or absolute) + 2, // Fixed Thread count + // Optional Lifecycle Hooks + frankenphp.WithWorkerOnServerStartup(func() { + // Global setup logic... + }), + ) +} +``` + +### In a Caddy Module (Configurable by the user) + +If you plan to share your extension (like a generic queue or event listener), you should wrap it in a Caddy module. This allows users to configure the script path and thread count via their `Caddyfile`. This requires implementing the `caddy.Provisioner` interface and parsing the Caddyfile ([see an example](https://github.com/dunglas/frankenphp-queue/blob/989120d394d66dd6c8e2101cac73dd622fade334/caddy.go)). + +### In a Pure Go Application (Embedding) + +If you are [embedding FrankenPHP in a standard Go application without caddy](https://pkg.go.dev/github.com/dunglas/frankenphp#example-ServeHTTP), you can register extension workers using `frankenphp.WithExtensionWorkers` when initializing options. + +## Interacting with Workers + +Once the worker pool is active, you can dispatch tasks to it. This can be done inside [native functions exported to PHP](https://frankenphp.dev/docs/extensions/#writing-the-extension), or from any Go logic such as a cron scheduler, an event listener (MQTT, Kafka), or a any other goroutine. + +### Headless Mode : `SendMessage` + +Use `SendMessage` to pass raw data directly to your worker script. This is ideal for queues or simple commands. + +#### Example: An Async Queue Extension + +```go +// #include +import "C" +import ( + "context" + "unsafe" + "github.com/dunglas/frankenphp" +) + +//export_php:function my_queue_push(mixed $data): bool +func my_queue_push(data *C.zval) bool { + // 1. Ensure worker is ready + if worker == nil { + return false + } + + // 2. Dispatch to the background worker + _, err := worker.SendMessage( + context.Background(), // Standard Go context + unsafe.Pointer(data), // Data to pass to the worker + nil, // Optional http.ResponseWriter + ) + + return err == nil +} +``` + +### HTTP Emulation :`SendRequest` + +Use `SendRequest` if your extension needs to invoke a PHP script that expects a standard web environment (populating `$_SERVER`, `$_GET`, etc.). + +```go +// #include +import "C" +import ( + "net/http" + "net/http/httptest" + "unsafe" + "github.com/dunglas/frankenphp" +) + +//export_php:function my_worker_http_request(string $path): string +func my_worker_http_request(path *C.zend_string) unsafe.Pointer { + // 1. Prepare the request and recorder + url := frankenphp.GoString(unsafe.Pointer(path)) + req, _ := http.NewRequest("GET", url, http.NoBody) + rr := httptest.NewRecorder() + + // 2. Dispatch to the worker + if err := worker.SendRequest(rr, req); err != nil { + return nil + } + + // 3. Return the captured response + return frankenphp.PHPString(rr.Body.String(), false) +} +``` + +## Worker Script + +The PHP worker script runs in a loop and can handle both raw messages and HTTP requests. + +```php + [!NOTE] > @@ -406,12 +406,15 @@ const MAX_CONNECTIONS = 100 const API_VERSION = "1.2.3" //export_php:const -const STATUS_OK = iota - -//export_php:const -const STATUS_ERROR = iota +const ( + STATUS_OK = iota + STATUS_ERROR +) ``` +> [!NOTE] +> PHP constants will take the name of the Go constant, thus using upper case letters is recommended. + #### Class Constants Use the `//export_php:classconst ClassName` directive to create constants that belong to a specific PHP class: @@ -429,15 +432,16 @@ const STATUS_INACTIVE = 0 const ROLE_ADMIN = "admin" //export_php:classconst Order -const STATE_PENDING = iota - -//export_php:classconst Order -const STATE_PROCESSING = iota - -//export_php:classconst Order -const STATE_COMPLETED = iota +const ( + STATE_PENDING = iota + STATE_PROCESSING + STATE_COMPLETED +) ``` +> [!NOTE] +> Just like global constants, the class constants will take the name of the Go constant. + Class constants are accessible using the class name scope in PHP: ```php @@ -587,7 +591,18 @@ GEN_STUB_SCRIPT=php-src/build/gen_stub.php frankenphp extension-init my_extensio > [!NOTE] > Don't forget to set the `GEN_STUB_SCRIPT` environment variable to the path of the `gen_stub.php` file in the PHP sources you downloaded earlier. This is the same `gen_stub.php` script mentioned in the manual implementation section. -If everything went well, a new directory named `build` should have been created. This directory contains the generated files for your extension, including the `my_extension.go` file with the generated PHP function stubs. +If everything went well, your project directory should contain the following files for your extension: + +- **`my_extension.go`** - Your original source file (remains unchanged) +- **`my_extension_generated.go`** - Generated file with CGO wrappers that call your functions +- **`my_extension.stub.php`** - PHP stub file for IDE autocompletion +- **`my_extension_arginfo.h`** - PHP argument information +- **`my_extension.h`** - C header file +- **`my_extension.c`** - C implementation file +- **`README.md`** - Documentation + +> [!IMPORTANT] +> **Your source file (`my_extension.go`) is never modified.** The generator creates a separate `_generated.go` file containing CGO wrappers that call your original functions. This means you can safely version control your source file without worrying about generated code polluting it. ### Integrating the Generated Extension into FrankenPHP diff --git a/docs/fr/CONTRIBUTING.md b/docs/fr/CONTRIBUTING.md index 77e889b2fa..eeacb7bb15 100644 --- a/docs/fr/CONTRIBUTING.md +++ b/docs/fr/CONTRIBUTING.md @@ -33,7 +33,7 @@ Si votre version de Docker est inférieure à 23.0, la construction échouera à ## Exécution de la suite de tests ```console -go test -tags watcher -race -v ./... +go test -race -v ./... ``` ## Module Caddy @@ -42,7 +42,7 @@ Construire Caddy avec le module FrankenPHP : ```console cd caddy/frankenphp/ -go build -tags watcher,brotli,nobadger,nomysql,nopgx +go build -tags nobadger,nomysql,nopgx cd ../../ ``` @@ -176,7 +176,7 @@ docker buildx bake -f docker-bake.hcl --pull --no-cache --push 8. Dans le conteneur, vous pouvez utiliser GDB et similaires : ```console - go test -tags watcher -c -ldflags=-w + go test -c -ldflags=-w gdb --args frankenphp.test -test.run ^MyTest$ ``` diff --git a/docs/fr/extensions.md b/docs/fr/extensions.md index caf50e9ca7..488a28c5de 100644 --- a/docs/fr/extensions.md +++ b/docs/fr/extensions.md @@ -402,12 +402,15 @@ const MAX_CONNECTIONS = 100 const API_VERSION = "1.2.3" //export_php:const -const STATUS_OK = iota - -//export_php:const -const STATUS_ERROR = iota +const ( + STATUS_OK = iota + STATUS_ERROR +) ``` +> [!NOTE] +> Les constantes PHP prennent le nom de la constante Go, d'où l'utilisation de majuscules pour les noms des constants en Go. + #### Constantes de Classe Utilisez la directive `//export_php:classconst ClassName` pour créer des constantes qui appartiennent à une classe PHP spécifique : @@ -425,15 +428,16 @@ const STATUS_INACTIVE = 0 const ROLE_ADMIN = "admin" //export_php:classconst Order -const STATE_PENDING = iota - -//export_php:classconst Order -const STATE_PROCESSING = iota - -//export_php:classconst Order -const STATE_COMPLETED = iota +const ( + STATE_PENDING = iota + STATE_PROCESSING + STATE_COMPLETED +) ``` +> [!NOTE] +> Comme les constantes globales, les constantes de classe prennent le nom de la constante Go. + Les constantes de classe sont accessibles en utilisant la portée du nom de classe en PHP : ```php diff --git a/docs/hot-reload.md b/docs/hot-reload.md new file mode 100644 index 0000000000..9219461563 --- /dev/null +++ b/docs/hot-reload.md @@ -0,0 +1,139 @@ +# Hot Reload + +FrankenPHP includes a built-in **hot reload** feature designed to vastly improve the developer experience. + +![Mercure](hot-reload.png) + +This feature provides a workflow similar to **Hot Module Replacement (HMR)** found in modern JavaScript tooling (like Vite or webpack). +Instead of manually refreshing the browser after every file change (PHP code, templates, JavaScript and CSS files...), +FrankenPHP updates the content in real-time. + +Hot Reload natively works with WordPress, Laravel, Symfony, and any other PHP application or framework. + +When enabled, FrankenPHP watches your current working directory for filesystem changes. +When a file is modified, it pushes a [Mercure](mercure.md) update to the browser. + +Depending on your setup, the browser will either: + +- **Morph the DOM** (preserving scroll position and input state) if [Idiomorph](https://github.com/bigskysoftware/idiomorph) is loaded. +- **Reload the page** (standard live reload) if Idiomorph is not present. + +## Configuration + +To enable hot reloading, enable Mercure, then add the `hot_reload` sub-directive to the `php_server` directive in your `Caddyfile`. + +> [!WARNING] +> This feature is intended for **development environments only**. +> Do not enable `hot_reload` in production, as watching the filesystem incurs performance overhead and exposes internal endpoints. + +```caddyfile +localhost + +mercure { + anonymous +} + +root public/ +php_server { + hot_reload +} +``` + +By default, FrankenPHP will watch all files in the current working directory matching this glob pattern: `./**/*.{css,env,gif,htm,html,jpg,jpeg,js,mjs,php,png,svg,twig,webp,xml,yaml,yml}` + +It's possible to explicitly set the files to watch using the glob syntax: + +```caddyfile +localhost + +mercure { + anonymous +} + +root public/ +php_server { + hot_reload src/**/*{.php,.js} config/**/*.yaml +} +``` + +Use the long form to specify the Mercure topic to use as well as which directories or files to watch by providing paths to the `hot_reload` option: + +```caddyfile +localhost + +mercure { + anonymous +} + +root public/ +php_server { + hot_reload { + topic hot-reload-topic + watch src/**/*.php + watch assets/**/*.{ts,json} + watch templates/ + watch public/css/ + } +} +``` + +## Client-Side Integration + +While the server detects changes, the browser needs to subscribe to these events to update the page. +FrankenPHP exposes the Mercure Hub URL to use for subscribing to file changes via the `$_SERVER['FRANKENPHP_HOT_RELOAD']` environment variable. + +A convenience JavaScript library, [frankenphp-hot-reload](https://www.npmjs.com/package/frankenphp-hot-reload), is also available to handle the client-side logic. +To use it, add the following to your main layout: + +```php + +FrankenPHP Hot Reload + + + + + +``` + +The library will automatically subscribe to the Mercure hub, fetch the current URL in the background when a file change is detected and morph the DOM. +It is available as a [npm](https://www.npmjs.com/package/frankenphp-hot-reload) package and on [GitHub](https://github.com/dunglas/frankenphp-hot-reload). + +Alternatively, you can implement your own client-side logic by subscribing directly to the Mercure hub using the `EventSource` native JavaScript class. + +### Worker Mode + +If you are running your application in [Worker Mode](https://frankenphp.dev/docs/worker/), your application script remains in memory. +This means changes to your PHP code will not be reflected immediately, even if the browser reloads. + +For the best developer experience, you should combine `hot_reload` with [the `watch` sub-directive in the `worker` directive](config.md#watching-for-file-changes). + +- `hot_reload`: refreshes the **browser** when files change +- `worker.watch`: restarts the worker when files change + +```caddy +localhost + +mercure { + anonymous +} + +root public/ +php_server { + hot_reload + worker { + file /path/to/my_worker.php + watch + } +} +``` + +### How it works + +1. **Watch**: FrankenPHP monitors the filesystem for modifications using [the `e-dant/watcher` library](https://github.com/e-dant/watcher) under the hood (we contributed the Go binding). +2. **Restart (Worker Mode)**: if `watch` is enabled in the worker config, the PHP worker is restarted to load the new code. +3. **Push**: a JSON payload containing the list of changed files is sent to the built-in [Mercure hub](https://mercure.rocks). +4. **Receive**: The browser, listening via the JavaScript library, receives the Mercure event. +5. **Update**: + +- If **Idiomorph** is detected, it fetches the updated content and morphs the current HTML to match the new state, applying changes instantly without losing state. +- Otherwise, `window.location.reload()` is called to refresh the page. diff --git a/docs/hot-reload.png b/docs/hot-reload.png new file mode 100644 index 0000000000..7ba2068b68 Binary files /dev/null and b/docs/hot-reload.png differ diff --git a/docs/ja/CONTRIBUTING.md b/docs/ja/CONTRIBUTING.md index 210368499a..b7a0da5a97 100644 --- a/docs/ja/CONTRIBUTING.md +++ b/docs/ja/CONTRIBUTING.md @@ -33,7 +33,7 @@ docker run --cap-add=SYS_PTRACE --security-opt seccomp=unconfined -p 8080:8080 - ## テストスイートの実行 ```console -go test -tags watcher -race -v ./... +go test -race -v ./... ``` ## Caddyモジュール @@ -42,7 +42,7 @@ FrankenPHPのCaddyモジュール付きでCaddyをビルドします: ```console cd caddy/frankenphp/ -go build -tags watcher,brotli,nobadger,nomysql,nopgx +go build -tags nobadger,nomysql,nopgx cd ../../ ``` @@ -175,7 +175,7 @@ docker buildx bake -f docker-bake.hcl --pull --no-cache --push 8. コンテナ内で、GDBなどを使用できます: ```console - go test -tags watcher -c -ldflags=-w + go test -c -ldflags=-w gdb --args frankenphp.test -test.run ^MyTest$ ``` diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 0000000000..d591bf3630 --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,73 @@ +# Logging + +FrankenPHP integrates seamlessly with [Caddy's logging system](https://caddyserver.com/docs/logging). +You can log messages using standard PHP functions or leverage the dedicated `frankenphp_log()` function for advanced +structured logging capabilities. + +## `frankenphp_log()` + +The `frankenphp_log()` function allows you to emit structured logs directly from your PHP application, +making ingestion into platforms like Datadog, Grafana Loki, or Elastic, as well as OpenTelemetry support, much easier. + +Under the hood, `frankenphp_log()` wraps [Go's `log/slog` package](https://pkg.go.dev/log/slog) to provide rich logging +features. + +These logs include the severity level and optional context data. + +```php +function frankenphp_log(string $message, int $level = FRANKENPHP_LOG_LEVEL_INFO, array $context = []): void +``` + +### Parameters + +- **`message`**: The log message string. +- **`level`**: The severity level of the log. Can be any arbitrary integer. Convenience constants are provided for common levels: `FRANKENPHP_LOG_LEVEL_DEBUG` (`-4`), `FRANKENPHP_LOG_LEVEL_INFO` (`0`), `FRANKENPHP_LOG_LEVEL_WARN` (`4`) and `FRANKENPHP_LOG_LEVEL_ERROR` (`8`)). Default is `FRANKENPHP_LOG_LEVEL_INFO`. +- **`context`**: An associative array of additional data to include in the log entry. + +### Example + +```php + memory_get_usage(), + 'peak_usage' => memory_get_peak_usage(), + ], +); + +``` + +When viewing the logs (e.g., via `docker compose logs`), the output will appear as structured JSON: + +```json +{"level":"info","ts":1704067200,"logger":"frankenphp","msg":"Hello from FrankenPHP!"} +{"level":"warn","ts":1704067200,"logger":"frankenphp","msg":"Memory usage high","current_usage":10485760,"peak_usage":12582912} +``` + +## `error_log()` + +FrankenPHP also allows logging using the standard `error_log()` function. If the `$message_type` parameter is `4` (SAPI), +these messages are routed to the Caddy logger. + +By default, messages sent via `error_log()` are treated as unstructured text. +They are useful for compatibility with existing applications or libraries that rely on the standard PHP library. + +### Example with error_log() + +```php +error_log("Database connection failed", 4); +``` + +This will appear in the Caddy logs, often prefixed to indicate it originated from PHP. + +> [!TIP] +> For better observability in production environments, prefer `frankenphp_log()` +> as it allows you to filter logs by level (Debug, Error, etc.) +> and query specific fields in your logging infrastructure. diff --git a/docs/mercure.md b/docs/mercure.md index 06b42c513f..25a619b878 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -19,6 +19,8 @@ localhost mercure { # The secret key used to sign the JWT tokens for publishers publisher_jwt !ChangeThisMercureHubJWTSecretKey! + # When publisher_jwt is set, you must set subscriber_jwt too! + subscriber_jwt !ChangeThisMercureHubJWTSecretKey! # Allows anonymous subscribers (without JWT) anonymous } diff --git a/docs/performance.md b/docs/performance.md index 7910b26513..5ca732d7f7 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -5,9 +5,9 @@ However, it is possible to substantially improve performance using an appropriat ## Number of Threads and Workers -By default, FrankenPHP starts 2 times more threads and workers (in worker mode) than the available numbers of CPU. +By default, FrankenPHP starts 2 times more threads and workers (in worker mode) than the available number of CPU cores. -The appropriate values depend heavily on how your application is written, what it does and your hardware. +The appropriate values depend heavily on how your application is written, what it does, and your hardware. We strongly recommend changing these values. For best system stability, it is recommended to have `num_threads` x `memory_limit` < `available_memory`. To find the right values, it's best to run load tests simulating real traffic. @@ -43,7 +43,9 @@ Also, [some bugs only happen when using musl](https://github.com/php/php-src/iss In production environments, we recommend using FrankenPHP linked against glibc, compiled with an appropriate optimization level. -This can be achieved by using the Debian Docker images, using our maintainers [.deb](https://debs.henderkes.com) or [.rpm](https://rpms.henderkes.com) packages, or by [compiling FrankenPHP from sources](compile.md). +This can be achieved by using the Debian Docker images, using [our maintainers .deb, .rpm, or .apk packages](https://pkgs.henderkes.com), or by [compiling FrankenPHP from sources](compile.md). + +For leaner or more secure containers, you may want to consider [a hardened Debian image](docker.md#hardening-images) rather than Alpine. ## Go Runtime Configuration @@ -87,6 +89,18 @@ php_server { ``` This can significantly reduce the number of unnecessary file operations. +A worker equivalent of the previous configuration would be: + +```caddyfile +route { + php_server { # use "php" instead of "php_server" if you don't need the file server at all + root /root/to/your/app + worker /path/to/worker.php { + match * # send all requests directly to the worker + } + } +} +``` An alternate approach with 0 unnecessary file system operations would be to instead use the `php` directive and split files from PHP by path. This approach works well if your entire application is served by one entry file. @@ -146,7 +160,7 @@ All usual PHP-related performance optimizations apply with FrankenPHP. In particular: -- check that [OPcache](https://www.php.net/manual/en/book.opcache.php) is installed, enabled and properly configured +- check that [OPcache](https://www.php.net/manual/en/book.opcache.php) is installed, enabled, and properly configured - enable [Composer autoloader optimizations](https://getcomposer.org/doc/articles/autoloader-optimization.md) - ensure that the `realpath` cache is big enough for the needs of your application - use [preloading](https://www.php.net/manual/en/opcache.preloading.php) @@ -164,22 +178,18 @@ limits the concurrency of requests going towards the slow endpoint, similar to a connection pool. ```caddyfile -{ - frankenphp { - max_threads 100 # max 100 threads shared by all workers - } -} - example.com { php_server { root /app/public # the root of your application worker index.php { match /slow-endpoint/* # all requests with path /slow-endpoint/* are handled by this thread pool - num 10 # minimum 10 threads for requests matching /slow-endpoint/* + num 1 # minimum 1 threads for requests matching /slow-endpoint/* + max_threads 20 # allow up to 20 threads for requests matching /slow-endpoint/*, if needed } worker index.php { match * # all other requests are handled separately - num 20 # minimum 20 threads for other requests, even if the slow endppoints start hanging + num 1 # minimum 1 threads for other requests, even if the slow endpoints start hanging + max_threads 20 # allow up to 20 threads for other requests, if needed } } } diff --git a/docs/pt-br/CONTRIBUTING.md b/docs/pt-br/CONTRIBUTING.md index 7183424df7..a24a9718a9 100644 --- a/docs/pt-br/CONTRIBUTING.md +++ b/docs/pt-br/CONTRIBUTING.md @@ -39,7 +39,7 @@ a flag de configuração `--debug`. ## Executando a suite de testes ```console -go test -tags watcher -race -v ./... +go test -race -v ./... ``` ## Módulo Caddy @@ -48,7 +48,7 @@ Construa o Caddy com o módulo Caddy FrankenPHP: ```console cd caddy/frankenphp/ -go build -tags watcher,brotli,nobadger,nomysql,nopgx +go build -tags nobadger,nomysql,nopgx cd ../../ ``` @@ -186,7 +186,7 @@ docker buildx bake -f docker-bake.hcl --pull --no-cache --push 8. No contêiner, você pode usar o GDB e similares: ```console - go test -tags watcher -c -ldflags=-w + go test -c -ldflags=-w gdb --args frankenphp.test -test.run ^MyTest$ ``` diff --git a/docs/ru/CONTRIBUTING.md b/docs/ru/CONTRIBUTING.md index 55d39d66e2..58b9f5ad0f 100644 --- a/docs/ru/CONTRIBUTING.md +++ b/docs/ru/CONTRIBUTING.md @@ -33,7 +33,7 @@ docker run --cap-add=SYS_PTRACE --security-opt seccomp=unconfined -p 8080:8080 - ## Запуск тестов ```console -go test -tags watcher -race -v ./... +go test -race -v ./... ``` ## Модуль Caddy @@ -42,7 +42,7 @@ go test -tags watcher -race -v ./... ```console cd caddy/frankenphp/ -go build -tags watcher,brotli,nobadger,nomysql,nopgx +go build -tags nobadger,nomysql,nopgx cd ../../ ``` @@ -175,7 +175,7 @@ docker buildx bake -f docker-bake.hcl --pull --no-cache --push 8. В контейнере используйте GDB и другие инструменты: ```console - go test -tags watcher -c -ldflags=-w + go test -c -ldflags=-w gdb --args frankenphp.test -test.run ^MyTest$ ``` diff --git a/docs/tr/CONTRIBUTING.md b/docs/tr/CONTRIBUTING.md index ea71dbff1a..ec60d2b340 100644 --- a/docs/tr/CONTRIBUTING.md +++ b/docs/tr/CONTRIBUTING.md @@ -33,7 +33,7 @@ Docker sürümünüz 23.0'dan düşükse, derleme dockerignore [pattern issue](h ## Test senaryolarını çalıştırma ```console -go test -tags watcher -race -v ./... +go test -race -v ./... ``` ## Caddy modülü @@ -175,7 +175,7 @@ docker buildx bake -f docker-bake.hcl --pull --no-cache --push 8. Konteynerde GDB ve benzerlerini kullanabilirsiniz: ```console - go test -tags watcher -c -ldflags=-w + go test -c -ldflags=-w gdb --args frankenphp.test -test.run ^MyTest$ ``` diff --git a/docs/translate.php b/docs/translate.php new file mode 100644 index 0000000000..d4575c7cd6 --- /dev/null +++ b/docs/translate.php @@ -0,0 +1,127 @@ + 'Chinese', + 'fr' => 'French', + 'ja' => 'Japanese', + 'pt-br' => 'Portuguese (Brazilian)', + 'ru' => 'Russian', + 'tr' => 'Turkish', +]; + +function makeGeminiRequest(string $systemPrompt, string $userPrompt, string $model, string $apiKey, int $reties = 2): string +{ + $url = "https://generativelanguage.googleapis.com/v1beta/models/$model:generateContent"; + $body = json_encode([ + "contents" => [ + ["role" => "model", "parts" => ['text' => $systemPrompt]], + ["role" => "user", "parts" => ['text' => $userPrompt]] + ], + ]); + + $response = @file_get_contents($url, false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => "Content-Type: application/json\r\nX-Goog-Api-Key: $apiKey\r\nContent-Length: " . strlen($body) . "\r\n", + 'content' => $body, + 'timeout' => 300, + ] + ])); + $generatedDocs = json_decode($response, true)['candidates'][0]['content']['parts'][0]['text'] ?? ''; + + if (!$response || !$generatedDocs) { + print_r(error_get_last()); + print_r($response); + if ($reties > 0) { + echo "Retrying... ($reties retries left)\n"; + sleep(SLEEP_SECONDS_BETWEEN_REQUESTS); + return makeGeminiRequest($systemPrompt, $userPrompt, $model, $apiKey, $reties - 1); + } + exit(1); + } + + return $generatedDocs; +} + +function createPrompt(string $language, string $englishFile, string $currentTranslation): array +{ + $systemPrompt = << trim($filename), $fileToTranslate); +$apiKey = $_SERVER['GEMINI_API_KEY'] ?? $_ENV['GEMINI_API_KEY'] ?? ''; +if (!$apiKey) { + echo 'Enter gemini api key ($GEMINI_API_KEY): '; + $apiKey = trim(fgets(STDIN)); +} + +$files = array_filter(scandir(__DIR__), fn($filename) => str_ends_with($filename, '.md')); +foreach ($files as $file) { + $englishFile = file_get_contents(__DIR__ . "/$file"); + if ($fileToTranslate && !in_array($file, $fileToTranslate)) { + continue; + } + foreach (LANGUAGES as $language => $languageName) { + echo "Translating $file to $languageName\n"; + $currentTranslation = file_get_contents(__DIR__ . "/$language/$file") ?: ''; + [$systemPrompt, $userPrompt] = createPrompt($language, $englishFile, $currentTranslation); + $markdown = makeGeminiRequest($systemPrompt, $userPrompt, MODEL, $apiKey); + + echo "Writing translated file to $language/$file\n"; + file_put_contents(__DIR__ . "/$language/$file", sanitizeMarkdown($markdown)); + + echo "sleeping to avoid rate limiting...\n"; + sleep(SLEEP_SECONDS_BETWEEN_REQUESTS); + } +} diff --git a/docs/wordpress.md b/docs/wordpress.md new file mode 100644 index 0000000000..bc9f2756fc --- /dev/null +++ b/docs/wordpress.md @@ -0,0 +1,59 @@ +# WordPress + +Run [WordPress](https://wordpress.org/) with FrankenPHP to enjoy a modern, high-performance stack with automatic HTTPS, HTTP/3, and Zstandard compression. + +## Minimal Installation + +1. [Download WordPress](https://wordpress.org/download/) +2. Extract the ZIP archive and open a terminal in the extracted directory +3. Run: + + ```console + frankenphp php-server + ``` + +4. Go to `http://localhost/wp-admin/` and follow the installation instructions +5. Enjoy! + +For a production-ready setup, prefer using `frankenphp run` with a `Caddyfile` like this one: + +```caddyfile +example.com + +php_server +encode zstd br gzip +log +``` + +## Hot Reload + +To use the [hot reload](hot-reload.md) feature with WordPress, enable [Mercure](mercure.md) and add the `hot_reload` sub-directive to the `php_server` directive in your `Caddyfile`: + +```caddyfile +localhost + +mercure { + anonymous +} + +php_server { + hot_reload +} +``` + +Then, add the code needed to load the JavaScript libraries in the `functions.php` file of your WordPress theme: + +```php +function hot_reload() { + ?> + + + + + + [!TIP] @@ -70,9 +72,6 @@ The following example shows how to create your own worker script without relying #include #include +#include #include #include #include @@ -70,12 +71,53 @@ frankenphp_config frankenphp_get_config() { } bool should_filter_var = 0; +bool original_user_abort_setting = 0; + __thread uintptr_t thread_index; __thread bool is_worker_thread = false; __thread zval *os_environment = NULL; +__thread HashTable *worker_ini_snapshot = NULL; + +/* Session user handler names (same structure as PS(mod_user_names)). + * In PHP 8.2, mod_user_names is a union with .name.ps_* access. + * In PHP 8.3+, mod_user_names is a direct struct with .ps_* access. */ +typedef struct { + zval ps_open; + zval ps_close; + zval ps_read; + zval ps_write; + zval ps_destroy; + zval ps_gc; + zval ps_create_sid; + zval ps_validate_sid; + zval ps_update_timestamp; +} session_user_handlers; + +/* Macro to access PS(mod_user_names) handlers across PHP versions */ +#if PHP_VERSION_ID >= 80300 +#define PS_MOD_USER_NAMES(handler) PS(mod_user_names).handler +#else +#define PS_MOD_USER_NAMES(handler) PS(mod_user_names).name.handler +#endif + +#define FOR_EACH_SESSION_HANDLER(op) \ + op(ps_open); \ + op(ps_close); \ + op(ps_read); \ + op(ps_write); \ + op(ps_destroy); \ + op(ps_gc); \ + op(ps_create_sid); \ + op(ps_validate_sid); \ + op(ps_update_timestamp) + +__thread session_user_handlers *worker_session_handlers_snapshot = NULL; void frankenphp_update_local_thread_context(bool is_worker) { is_worker_thread = is_worker; + + /* workers should keep running if the user aborts the connection */ + PG(ignore_user_abort) = is_worker ? 1 : original_user_abort_setting; } static void frankenphp_update_request_context() { @@ -85,7 +127,11 @@ static void frankenphp_update_request_context() { /* status It is not reset by zend engine, set it to 200. */ SG(sapi_headers).http_response_code = 200; - go_update_request_info(thread_index, &SG(request_info)); + char *authorization_header = + go_update_request_info(thread_index, &SG(request_info)); + + /* let PHP handle basic auth */ + php_handle_auth_data(authorization_header); } static void frankenphp_free_request_context() { @@ -95,8 +141,6 @@ static void frankenphp_free_request_context() { } /* freed via thread.Unpin() */ - SG(request_info).auth_password = NULL; - SG(request_info).auth_user = NULL; SG(request_info).request_method = NULL; SG(request_info).query_string = NULL; SG(request_info).content_type = NULL; @@ -116,6 +160,13 @@ static void frankenphp_reset_super_globals() { zval *files = &PG(http_globals)[TRACK_VARS_FILES]; zval_ptr_dtor_nogc(files); memset(files, 0, sizeof(*files)); + + /* $_SESSION must be explicitly deleted from the symbol table. + * Unlike other superglobals, $_SESSION is stored in EG(symbol_table) + * with a reference to PS(http_session_vars). The session RSHUTDOWN + * only decrements the refcount but doesn't remove it from the symbol + * table, causing data to leak between requests. */ + zend_hash_str_del(&EG(symbol_table), "_SESSION", sizeof("_SESSION") - 1); } zend_end_try(); @@ -126,13 +177,19 @@ static void frankenphp_reset_super_globals() { if (auto_global->name == _env) { /* skip $_ENV */ } else if (auto_global->name == _server) { - /* always reimport $_SERVER */ + /* always reimport $_SERVER */ auto_global->armed = auto_global->auto_global_callback(auto_global->name); } else if (auto_global->jit) { - /* globals with jit are: $_SERVER, $_ENV, $_REQUEST, $GLOBALS, - * jit will only trigger on script parsing and therefore behaves - * differently in worker mode. We will skip all jit globals - */ + /* JIT globals ($_REQUEST, $GLOBALS) need special handling: + * - $GLOBALS will always be handled by the application, we skip it + * For $_REQUEST: + * - If in symbol_table: re-initialize with current request data + * - If not: do nothing, it may be armed by jit later */ + if (auto_global->name == ZSTR_KNOWN(ZEND_STR_AUTOGLOBAL_REQUEST) && + zend_hash_exists(&EG(symbol_table), auto_global->name)) { + auto_global->armed = + auto_global->auto_global_callback(auto_global->name); + } } else if (auto_global->auto_global_callback) { /* $_GET, $_POST, $_COOKIE, $_FILES are reimported here */ auto_global->armed = auto_global->auto_global_callback(auto_global->name); @@ -166,6 +223,166 @@ static void frankenphp_release_temporary_streams() { ZEND_HASH_FOREACH_END(); } +/* Destructor for INI snapshot hash table entries */ +static void frankenphp_ini_snapshot_dtor(zval *zv) { + zend_string_release((zend_string *)Z_PTR_P(zv)); +} + +/* Save the current state of modified INI entries. + * This captures INI values set by the framework before the worker loop. */ +static void frankenphp_snapshot_ini(void) { + if (worker_ini_snapshot != NULL) { + return; /* Already snapshotted */ + } + + if (EG(modified_ini_directives) == NULL) { + /* Allocate empty table to mark as snapshotted */ + ALLOC_HASHTABLE(worker_ini_snapshot); + zend_hash_init(worker_ini_snapshot, 0, NULL, frankenphp_ini_snapshot_dtor, + 0); + return; + } + + uint32_t num_modified = zend_hash_num_elements(EG(modified_ini_directives)); + ALLOC_HASHTABLE(worker_ini_snapshot); + zend_hash_init(worker_ini_snapshot, num_modified, NULL, + frankenphp_ini_snapshot_dtor, 0); + + zend_ini_entry *ini_entry; + ZEND_HASH_FOREACH_PTR(EG(modified_ini_directives), ini_entry) { + if (ini_entry->value) { + zend_hash_add_ptr(worker_ini_snapshot, ini_entry->name, + zend_string_copy(ini_entry->value)); + } + } + ZEND_HASH_FOREACH_END(); +} + +/* Restore INI values to the state captured by frankenphp_snapshot_ini(). + * - Entries in snapshot with changed values: restore to snapshot value + * - Entries not in snapshot: restore to startup default */ +static void frankenphp_restore_ini(void) { + if (worker_ini_snapshot == NULL || EG(modified_ini_directives) == NULL) { + return; + } + + zend_ini_entry *ini_entry; + zend_string *snapshot_value; + zend_string *entry_name; + + /* Collect entries to restore to default in a separate array. + * We cannot call zend_restore_ini_entry() during iteration because + * it calls zend_hash_del() on EG(modified_ini_directives). */ + uint32_t max_entries = zend_hash_num_elements(EG(modified_ini_directives)); + zend_string **entries_to_restore = + max_entries ? emalloc(max_entries * sizeof(zend_string *)) : NULL; + size_t restore_count = 0; + + ZEND_HASH_FOREACH_STR_KEY_PTR(EG(modified_ini_directives), entry_name, + ini_entry) { + snapshot_value = zend_hash_find_ptr(worker_ini_snapshot, entry_name); + + if (snapshot_value == NULL) { + /* Entry was not in snapshot: collect for restore to startup default */ + entries_to_restore[restore_count++] = zend_string_copy(entry_name); + } else if (!zend_string_equals(ini_entry->value, snapshot_value)) { + /* Entry was in snapshot but value changed: restore to snapshot value. + * zend_alter_ini_entry() does not delete from modified_ini_directives. */ + zend_alter_ini_entry(entry_name, snapshot_value, PHP_INI_USER, + PHP_INI_STAGE_RUNTIME); + } + /* else: Entry in snapshot with same value, nothing to do */ + } + ZEND_HASH_FOREACH_END(); + + /* Now restore entries to default outside of iteration */ + for (size_t i = 0; i < restore_count; i++) { + zend_restore_ini_entry(entries_to_restore[i], PHP_INI_STAGE_RUNTIME); + zend_string_release(entries_to_restore[i]); + } + if (entries_to_restore) { + efree(entries_to_restore); + } +} + +/* Save session user handlers set before the worker loop. + * This allows frameworks to define custom session handlers that persist. */ +static void frankenphp_snapshot_session_handlers(void) { + if (worker_session_handlers_snapshot != NULL) { + return; /* Already snapshotted */ + } + + /* Check if session module is loaded */ + if (zend_hash_str_find_ptr(&module_registry, "session", + sizeof("session") - 1) == NULL) { + return; /* Session module not available */ + } + + /* Check if user session handlers are defined */ + if (Z_ISUNDEF(PS_MOD_USER_NAMES(ps_open))) { + return; /* No user handlers to snapshot */ + } + + worker_session_handlers_snapshot = emalloc(sizeof(session_user_handlers)); + + /* Copy each handler zval with incremented reference count */ +#define SNAPSHOT_HANDLER(h) \ + if (!Z_ISUNDEF(PS_MOD_USER_NAMES(h))) { \ + ZVAL_COPY(&worker_session_handlers_snapshot->h, &PS_MOD_USER_NAMES(h)); \ + } else { \ + ZVAL_UNDEF(&worker_session_handlers_snapshot->h); \ + } + + FOR_EACH_SESSION_HANDLER(SNAPSHOT_HANDLER); + +#undef SNAPSHOT_HANDLER +} + +/* Restore session user handlers from snapshot after RSHUTDOWN freed them. */ +static void frankenphp_restore_session_handlers(void) { + if (worker_session_handlers_snapshot == NULL) { + return; + } + + /* Restore each handler zval. + * Session RSHUTDOWN already freed the handlers via zval_ptr_dtor and set + * them to UNDEF, so we don't need to destroy them again. We simply copy + * from the snapshot (which holds its own reference). */ +#define RESTORE_HANDLER(h) \ + if (!Z_ISUNDEF(worker_session_handlers_snapshot->h)) { \ + ZVAL_COPY(&PS_MOD_USER_NAMES(h), &worker_session_handlers_snapshot->h); \ + } + + FOR_EACH_SESSION_HANDLER(RESTORE_HANDLER); + +#undef RESTORE_HANDLER +} + +/* Free worker state when the worker script terminates. */ +static void frankenphp_cleanup_worker_state(void) { + /* Free INI snapshot */ + if (worker_ini_snapshot != NULL) { + zend_hash_destroy(worker_ini_snapshot); + FREE_HASHTABLE(worker_ini_snapshot); + worker_ini_snapshot = NULL; + } + + /* Free session handlers snapshot */ + if (worker_session_handlers_snapshot != NULL) { +#define FREE_HANDLER(h) \ + if (!Z_ISUNDEF(worker_session_handlers_snapshot->h)) { \ + zval_ptr_dtor(&worker_session_handlers_snapshot->h); \ + } + + FOR_EACH_SESSION_HANDLER(FREE_HANDLER); + +#undef FREE_HANDLER + + efree(worker_session_handlers_snapshot); + worker_session_handlers_snapshot = NULL; + } +} + /* Adapted from php_request_shutdown */ static void frankenphp_worker_request_shutdown() { /* Flush all output buffers */ @@ -187,9 +404,9 @@ static void frankenphp_worker_request_shutdown() { zend_end_try(); /* SAPI related shutdown (free stuff) */ - frankenphp_free_request_context(); zend_try { sapi_deactivate(); } zend_end_try(); + frankenphp_free_request_context(); zend_set_memory_limit(PG(memory_limit)); } @@ -200,6 +417,12 @@ bool frankenphp_shutdown_dummy_request(void) { return false; } + /* Snapshot INI and session handlers BEFORE shutdown. + * The framework has set these up before the worker loop, and we want + * to preserve them. Session RSHUTDOWN will free the handlers. */ + frankenphp_snapshot_ini(); + frankenphp_snapshot_session_handlers(); + frankenphp_worker_request_shutdown(); return true; @@ -255,6 +478,12 @@ static int frankenphp_worker_request_startup() { frankenphp_reset_super_globals(); + /* Restore INI values changed during the previous request back to their + * snapshot state (captured in frankenphp_shutdown_dummy_request). + * This ensures framework settings persist while request-level changes + * are reset. */ + frankenphp_restore_ini(); + const char **module_name; zend_module_entry *module; for (module_name = MODULES_TO_RELOAD; *module_name; module_name++) { @@ -264,6 +493,12 @@ static int frankenphp_worker_request_startup() { module->request_startup_func(module->type, module->module_number); } } + + /* Restore session handlers AFTER session RINIT. + * Session RSHUTDOWN frees mod_user_names callbacks, so we must restore + * them before user code runs. This must happen after RINIT because + * session RINIT may reset some state. */ + frankenphp_restore_session_handlers(); } zend_catch { retval = FAILURE; } zend_end_try(); @@ -609,8 +844,11 @@ static zend_module_entry frankenphp_module = { STANDARD_MODULE_PROPERTIES}; static void frankenphp_request_shutdown() { - frankenphp_free_request_context(); + if (is_worker_thread) { + frankenphp_cleanup_worker_state(); + } php_request_shutdown((void *)0); + frankenphp_free_request_context(); } static int frankenphp_startup(sapi_module_struct *sapi_module) { @@ -1008,6 +1246,7 @@ static void *php_main(void *arg) { char *default_filter; cfg_get_string("filter.default", &default_filter); should_filter_var = default_filter != NULL; + original_user_abort_setting = PG(ignore_user_abort); go_frankenphp_main_thread_is_ready(); @@ -1055,8 +1294,7 @@ static int frankenphp_request_startup() { return SUCCESS; } - frankenphp_free_request_context(); - php_request_shutdown((void *)0); + frankenphp_request_shutdown(); return FAILURE; } @@ -1262,7 +1500,7 @@ int frankenphp_reset_opcache(void) { int frankenphp_get_current_memory_limit() { return PG(memory_limit); } -static zend_module_entry *modules = NULL; +static zend_module_entry **modules = NULL; static int modules_len = 0; static int (*original_php_register_internal_extensions_func)(void) = NULL; @@ -1273,7 +1511,7 @@ PHPAPI int register_internal_extensions(void) { } for (int i = 0; i < modules_len; i++) { - if (zend_register_internal_module(&modules[i]) == NULL) { + if (zend_register_internal_module(modules[i]) == NULL) { return FAILURE; } } @@ -1284,7 +1522,7 @@ PHPAPI int register_internal_extensions(void) { return SUCCESS; } -void register_extensions(zend_module_entry *m, int len) { +void register_extensions(zend_module_entry **m, int len) { modules = m; modules_len = len; diff --git a/frankenphp.go b/frankenphp.go index 693870e1d0..c651de3cf1 100644 --- a/frankenphp.go +++ b/frankenphp.go @@ -611,7 +611,10 @@ func go_sapi_flush(threadIndex C.uintptr_t) bool { return true } - if err := http.NewResponseController(fc.responseWriter).Flush(); err != nil { + if fc.responseController == nil { + fc.responseController = http.NewResponseController(fc.responseWriter) + } + if err := fc.responseController.Flush(); err != nil { ctx := thread.context() if globalLogger.Enabled(ctx, slog.LevelWarn) { @@ -683,34 +686,28 @@ func getLogger(threadIndex C.uintptr_t) (*slog.Logger, context.Context) { func go_log(threadIndex C.uintptr_t, message *C.char, level C.int) { logger, ctx := getLogger(threadIndex) - m := C.GoString(message) le := syslogLevelInfo - if level >= C.int(syslogLevelEmerg) && level <= C.int(syslogLevelDebug) { le = syslogLevel(level) } + var slogLevel slog.Level switch le { case syslogLevelEmerg, syslogLevelAlert, syslogLevelCrit, syslogLevelErr: - if logger.Enabled(ctx, slog.LevelError) { - logger.LogAttrs(ctx, slog.LevelError, m, slog.String("syslog_level", le.String())) - } - + slogLevel = slog.LevelError case syslogLevelWarn: - if logger.Enabled(ctx, slog.LevelWarn) { - logger.LogAttrs(ctx, slog.LevelWarn, m, slog.String("syslog_level", le.String())) - } - + slogLevel = slog.LevelWarn case syslogLevelDebug: - if logger.Enabled(ctx, slog.LevelDebug) { - logger.LogAttrs(ctx, slog.LevelDebug, m, slog.String("syslog_level", le.String())) - } - + slogLevel = slog.LevelDebug default: - if logger.Enabled(ctx, slog.LevelInfo) { - logger.LogAttrs(ctx, slog.LevelInfo, m, slog.String("syslog_level", le.String())) - } + slogLevel = slog.LevelInfo + } + + if !logger.Enabled(ctx, slogLevel) { + return } + + logger.LogAttrs(ctx, slogLevel, C.GoString(message), slog.String("syslog_level", le.String())) } //export go_log_attrs @@ -805,6 +802,8 @@ func resetGlobals() { globalCtx = context.Background() globalLogger = slog.Default() workers = nil + workersByName = nil + workersByPath = nil watcherIsEnabled = false globalMu.Unlock() } diff --git a/frankenphp.h b/frankenphp.h index efbd5fc48f..c833c44f97 100644 --- a/frankenphp.h +++ b/frankenphp.h @@ -76,6 +76,6 @@ void frankenphp_register_bulk( ht_key_value_pair auth_type, ht_key_value_pair remote_ident, ht_key_value_pair request_uri, ht_key_value_pair ssl_cipher); -void register_extensions(zend_module_entry *m, int len); +void register_extensions(zend_module_entry **m, int len); #endif diff --git a/frankenphp_test.go b/frankenphp_test.go index 8c6f3c90da..c1120b6c88 100644 --- a/frankenphp_test.go +++ b/frankenphp_test.go @@ -27,6 +27,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/dunglas/frankenphp" "github.com/dunglas/frankenphp/internal/fastabs" @@ -306,6 +307,56 @@ func testPostSuperGlobals(t *testing.T, opts *testOptions) { }, opts) } +func TestRequestSuperGlobal_module(t *testing.T) { testRequestSuperGlobal(t, nil) } +func TestRequestSuperGlobal_worker(t *testing.T) { + phpIni := make(map[string]string) + phpIni["auto_globals_jit"] = "1" + testRequestSuperGlobal(t, &testOptions{workerScript: "request-superglobal.php", phpIni: phpIni}) +} +func testRequestSuperGlobal(t *testing.T, opts *testOptions) { + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) { + // Test with both GET and POST parameters + // $_REQUEST should contain merged data from both + formData := url.Values{"post_key": {fmt.Sprintf("post_value_%d", i)}} + req := httptest.NewRequest("POST", fmt.Sprintf("http://example.com/request-superglobal.php?get_key=get_value_%d", i), strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + body, _ := testRequest(req, handler, t) + + // Verify $_REQUEST contains both GET and POST data for the current request + assert.Contains(t, body, fmt.Sprintf("'get_key' => 'get_value_%d'", i)) + assert.Contains(t, body, fmt.Sprintf("'post_key' => 'post_value_%d'", i)) + }, opts) +} + +func TestRequestSuperGlobalConditional_worker(t *testing.T) { + // This test verifies that $_REQUEST works correctly when accessed conditionally + // in worker mode. The first request does NOT access $_REQUEST, but subsequent + // requests do. This tests the "re-arm" mechanism for JIT auto globals. + // + // The bug scenario: + // - Request 1 (i=1): includes file, $_REQUEST initialized with val=1 + // - Request 3 (i=3): includes file from cache, $_REQUEST should have val=3 + // If the bug exists, $_REQUEST would still have val=1 from request 1. + phpIni := make(map[string]string) + phpIni["auto_globals_jit"] = "1" + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) { + if i%2 == 0 { + // Even requests: don't use $_REQUEST + body, _ := testGet(fmt.Sprintf("http://example.com/request-superglobal-conditional.php?val=%d", i), handler, t) + assert.Contains(t, body, "SKIPPED") + assert.Contains(t, body, fmt.Sprintf("'val' => '%d'", i)) + } else { + // Odd requests: use $_REQUEST + body, _ := testGet(fmt.Sprintf("http://example.com/request-superglobal-conditional.php?use_request=1&val=%d", i), handler, t) + assert.Contains(t, body, "REQUEST:") + assert.Contains(t, body, "REQUEST_COUNT:2", "$_REQUEST should have ONLY current request's data (2 keys: use_request and val)") + assert.Contains(t, body, fmt.Sprintf("'val' => '%d'", i), "request data is not present") + assert.Contains(t, body, "'use_request' => '1'") + assert.Contains(t, body, "VAL_CHECK:MATCH", "BUG: $_REQUEST contains stale data from previous request! Body: "+body) + } + }, &testOptions{workerScript: "request-superglobal-conditional.php", phpIni: phpIni}) +} + func TestCookies_module(t *testing.T) { testCookies(t, nil) } func TestCookies_worker(t *testing.T) { testCookies(t, &testOptions{workerScript: "cookies.php"}) } func testCookies(t *testing.T, opts *testOptions) { @@ -1078,3 +1129,289 @@ func FuzzRequest(f *testing.F) { }, &testOptions{workerScript: "request-headers.php"}) }) } + +func TestSessionHandlerReset_worker(t *testing.T) { + runTest(t, func(_ func(http.ResponseWriter, *http.Request), ts *httptest.Server, i int) { + // Request 1: Set a custom session handler and start session + resp1, err := http.Get(ts.URL + "/session-handler.php?action=set_handler_and_start&value=test1") + assert.NoError(t, err) + body1, _ := io.ReadAll(resp1.Body) + _ = resp1.Body.Close() + + body1Str := string(body1) + assert.Contains(t, body1Str, "HANDLER_SET_AND_STARTED") + assert.Contains(t, body1Str, "session.save_handler=user") + + // Request 2: Start session without setting a custom handler + // After the fix: session.save_handler should be reset to "files" + // and session_start() should work normally + resp2, err := http.Get(ts.URL + "/session-handler.php?action=start_without_handler") + assert.NoError(t, err) + body2, _ := io.ReadAll(resp2.Body) + _ = resp2.Body.Close() + + body2Str := string(body2) + + // session.save_handler should be reset to "files" (default) + assert.Contains(t, body2Str, "save_handler_before=files", + "session.save_handler INI should be reset to 'files' between requests.\nResponse: %s", body2Str) + + // session_start() should succeed + assert.Contains(t, body2Str, "SESSION_START_RESULT=true", + "session_start() should succeed after INI reset.\nResponse: %s", body2Str) + + // No errors or exceptions should occur + assert.NotContains(t, body2Str, "ERROR:", + "No errors expected.\nResponse: %s", body2Str) + assert.NotContains(t, body2Str, "EXCEPTION:", + "No exceptions expected.\nResponse: %s", body2Str) + + }, &testOptions{ + workerScript: "session-handler.php", + nbWorkers: 1, + nbParallelRequests: 1, + realServer: true, + }) +} + +func TestIniLeakBetweenRequests_worker(t *testing.T) { + runTest(t, func(_ func(http.ResponseWriter, *http.Request), ts *httptest.Server, i int) { + // Request 1: Change INI values + resp1, err := http.Get(ts.URL + "/ini-leak.php?action=change_ini") + assert.NoError(t, err) + body1, _ := io.ReadAll(resp1.Body) + _ = resp1.Body.Close() + + assert.Contains(t, string(body1), "INI_CHANGED") + + // Request 2: Check if INI values leaked from request 1 + resp2, err := http.Get(ts.URL + "/ini-leak.php?action=check_ini") + assert.NoError(t, err) + body2, _ := io.ReadAll(resp2.Body) + _ = resp2.Body.Close() + + body2Str := string(body2) + t.Logf("Response: %s", body2Str) + + // If INI values leak, this test will fail + assert.Contains(t, body2Str, "NO_LEAKS", + "INI values should not leak between requests.\nResponse: %s", body2Str) + assert.NotContains(t, body2Str, "LEAKS_DETECTED", + "INI leaks detected.\nResponse: %s", body2Str) + + }, &testOptions{ + workerScript: "ini-leak.php", + nbWorkers: 1, + nbParallelRequests: 1, + realServer: true, + }) +} + +func TestSessionHandlerPreLoopPreserved_worker(t *testing.T) { + runTest(t, func(_ func(http.ResponseWriter, *http.Request), ts *httptest.Server, i int) { + // Request 1: Check that the pre-loop session handler is preserved + resp1, err := http.Get(ts.URL + "/worker-with-session-handler.php?action=check") + assert.NoError(t, err) + body1, _ := io.ReadAll(resp1.Body) + _ = resp1.Body.Close() + + body1Str := string(body1) + t.Logf("Request 1 response: %s", body1Str) + assert.Contains(t, body1Str, "HANDLER_PRESERVED", + "Session handler set before loop should be preserved") + assert.Contains(t, body1Str, "save_handler=user", + "session.save_handler should remain 'user'") + + // Request 2: Use the session - should work with pre-loop handler + resp2, err := http.Get(ts.URL + "/worker-with-session-handler.php?action=use_session") + assert.NoError(t, err) + body2, _ := io.ReadAll(resp2.Body) + _ = resp2.Body.Close() + + body2Str := string(body2) + t.Logf("Request 2 response: %s", body2Str) + assert.Contains(t, body2Str, "SESSION_OK", + "Session should work with pre-loop handler.\nResponse: %s", body2Str) + assert.NotContains(t, body2Str, "ERROR:", + "No errors expected.\nResponse: %s", body2Str) + assert.NotContains(t, body2Str, "EXCEPTION:", + "No exceptions expected.\nResponse: %s", body2Str) + + // Request 3: Check handler is still preserved after using session + resp3, err := http.Get(ts.URL + "/worker-with-session-handler.php?action=check") + assert.NoError(t, err) + body3, _ := io.ReadAll(resp3.Body) + _ = resp3.Body.Close() + + body3Str := string(body3) + t.Logf("Request 3 response: %s", body3Str) + assert.Contains(t, body3Str, "HANDLER_PRESERVED", + "Session handler should still be preserved after use") + + }, &testOptions{ + workerScript: "worker-with-session-handler.php", + nbWorkers: 1, + nbParallelRequests: 1, + realServer: true, + }) +} + +func TestIniPreLoopPreserved_worker(t *testing.T) { + runTest(t, func(_ func(http.ResponseWriter, *http.Request), ts *httptest.Server, i int) { + // Request 1: Check that pre-loop INI values are present + resp1, err := http.Get(ts.URL + "/worker-with-ini.php?action=check") + assert.NoError(t, err) + body1, _ := io.ReadAll(resp1.Body) + _ = resp1.Body.Close() + + body1Str := string(body1) + t.Logf("Request 1 response: %s", body1Str) + assert.Contains(t, body1Str, "precision=8", + "Pre-loop precision should be 8") + assert.Contains(t, body1Str, "display_errors=0", + "Pre-loop display_errors should be 0") + assert.Contains(t, body1Str, "PRELOOP_INI_PRESERVED", + "Pre-loop INI values should be preserved") + + // Request 2: Change INI values during request + resp2, err := http.Get(ts.URL + "/worker-with-ini.php?action=change_ini") + assert.NoError(t, err) + body2, _ := io.ReadAll(resp2.Body) + _ = resp2.Body.Close() + + body2Str := string(body2) + t.Logf("Request 2 response: %s", body2Str) + assert.Contains(t, body2Str, "INI_CHANGED") + assert.Contains(t, body2Str, "precision=5", + "INI should be changed during request") + + // Request 3: Check that pre-loop INI values are restored + resp3, err := http.Get(ts.URL + "/worker-with-ini.php?action=check") + assert.NoError(t, err) + body3, _ := io.ReadAll(resp3.Body) + _ = resp3.Body.Close() + + body3Str := string(body3) + t.Logf("Request 3 response: %s", body3Str) + assert.Contains(t, body3Str, "precision=8", + "Pre-loop precision should be restored to 8.\nResponse: %s", body3Str) + assert.Contains(t, body3Str, "display_errors=0", + "Pre-loop display_errors should be restored to 0.\nResponse: %s", body3Str) + assert.Contains(t, body3Str, "PRELOOP_INI_PRESERVED", + "Pre-loop INI values should be restored after request changes.\nResponse: %s", body3Str) + + }, &testOptions{ + workerScript: "worker-with-ini.php", + nbWorkers: 1, + nbParallelRequests: 1, + realServer: true, + }) +} + +func TestSessionNoLeakBetweenRequests_worker(t *testing.T) { + runTest(t, func(_ func(http.ResponseWriter, *http.Request), ts *httptest.Server, i int) { + // Client A: Set a secret value in session + clientA := &http.Client{} + resp1, err := clientA.Get(ts.URL + "/session-leak.php?action=set&value=secret_A&client_id=clientA") + assert.NoError(t, err) + body1, _ := io.ReadAll(resp1.Body) + _ = resp1.Body.Close() + + body1Str := string(body1) + t.Logf("Client A set session: %s", body1Str) + assert.Contains(t, body1Str, "SESSION_SET") + assert.Contains(t, body1Str, "secret=secret_A") + + // Client B: Check that session is empty (no cookie, should not see Client A's data) + clientB := &http.Client{} + resp2, err := clientB.Get(ts.URL + "/session-leak.php?action=check_empty") + assert.NoError(t, err) + body2, _ := io.ReadAll(resp2.Body) + _ = resp2.Body.Close() + + body2Str := string(body2) + t.Logf("Client B check empty: %s", body2Str) + assert.Contains(t, body2Str, "SESSION_CHECK") + assert.Contains(t, body2Str, "SESSION_EMPTY=true", + "Client B should have empty session, not see Client A's data.\nResponse: %s", body2Str) + assert.NotContains(t, body2Str, "secret_A", + "Client A's secret should not leak to Client B.\nResponse: %s", body2Str) + + // Client C: Read session without cookie (should also be empty) + clientC := &http.Client{} + resp3, err := clientC.Get(ts.URL + "/session-leak.php?action=get") + assert.NoError(t, err) + body3, _ := io.ReadAll(resp3.Body) + _ = resp3.Body.Close() + + body3Str := string(body3) + t.Logf("Client C get session: %s", body3Str) + assert.Contains(t, body3Str, "SESSION_READ") + assert.Contains(t, body3Str, "secret=NOT_FOUND", + "Client C should not find any secret.\nResponse: %s", body3Str) + assert.Contains(t, body3Str, "client_id=NOT_FOUND", + "Client C should not find any client_id.\nResponse: %s", body3Str) + + }, &testOptions{ + workerScript: "session-leak.php", + nbWorkers: 1, + nbParallelRequests: 1, + realServer: true, + }) +} + +func TestSessionNoLeakAfterExit_worker(t *testing.T) { + runTest(t, func(_ func(http.ResponseWriter, *http.Request), ts *httptest.Server, i int) { + // Client A: Set a secret value in session and call exit(1) + clientA := &http.Client{} + resp1, err := clientA.Get(ts.URL + "/session-leak.php?action=set_and_exit&value=exit_secret&client_id=exitClient") + assert.NoError(t, err) + body1, _ := io.ReadAll(resp1.Body) + _ = resp1.Body.Close() + + body1Str := string(body1) + t.Logf("Client A set and exit: %s", body1Str) + // The response may be incomplete due to exit(1) + assert.Contains(t, body1Str, "BEFORE_EXIT") + + // Client B: Check that session is empty (should not see Client A's data) + // Retry until the worker has restarted after exit(1) + clientB := &http.Client{} + var body2Str string + assert.Eventually(t, func() bool { + resp2, err := clientB.Get(ts.URL + "/session-leak.php?action=check_empty") + if err != nil { + return false + } + body2, _ := io.ReadAll(resp2.Body) + _ = resp2.Body.Close() + body2Str = string(body2) + return strings.Contains(body2Str, "SESSION_CHECK") + }, 2*time.Second, 10*time.Millisecond, "Worker did not restart in time after exit(1)") + + t.Logf("Client B check empty after exit: %s", body2Str) + assert.Contains(t, body2Str, "SESSION_EMPTY=true", + "Client B should have empty session after Client A's exit(1).\nResponse: %s", body2Str) + assert.NotContains(t, body2Str, "exit_secret", + "Client A's secret should not leak to Client B after exit(1).\nResponse: %s", body2Str) + + // Client C: Try to read session (should also be empty) + clientC := &http.Client{} + resp3, err := clientC.Get(ts.URL + "/session-leak.php?action=get") + assert.NoError(t, err) + body3, _ := io.ReadAll(resp3.Body) + _ = resp3.Body.Close() + + body3Str := string(body3) + t.Logf("Client C get session after exit: %s", body3Str) + assert.Contains(t, body3Str, "SESSION_READ") + assert.Contains(t, body3Str, "secret=NOT_FOUND", + "Client C should not find any secret after exit(1).\nResponse: %s", body3Str) + + }, &testOptions{ + workerScript: "session-leak.php", + nbWorkers: 1, + nbParallelRequests: 1, + realServer: true, + }) +} diff --git a/go.mod b/go.mod index 56bb84b591..40798825a7 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,18 @@ module github.com/dunglas/frankenphp -go 1.25.4 +go 1.26.0 retract v1.0.0-rc.1 // Human error require ( github.com/Masterminds/sprig/v3 v3.3.0 - github.com/dunglas/mercure v0.21.4 - github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146 - github.com/maypok86/otter/v2 v2.2.1 + github.com/dunglas/mercure v0.21.8 + github.com/e-dant/watcher v0.0.0-20260202035023-10268e78355f + github.com/maypok86/otter/v2 v2.3.0 github.com/prometheus/client_golang v1.23.2 github.com/stretchr/testify v1.11.1 - golang.org/x/net v0.48.0 + golang.org/x/net v0.50.0 + golang.org/x/text v0.34.0 ) require ( @@ -27,13 +28,14 @@ require ( github.com/dunglas/skipfilter v1.0.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gofrs/uuid/v5 v5.4.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/handlers v1.5.2 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/huandu/xstrings v1.5.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect @@ -42,7 +44,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.4 // indirect + github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/rs/cors v1.11.1 // indirect @@ -58,9 +60,8 @@ require ( go.etcd.io/bbolt v1.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sys v0.41.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index af58b44cb3..0053b53351 100644 --- a/go.sum +++ b/go.sum @@ -18,24 +18,24 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dunglas/mercure v0.21.4 h1:mXPXHfB+4cYfFFCRRDY198mfef5+MQcdCpUnAHBUW2M= -github.com/dunglas/mercure v0.21.4/go.mod h1:l/dglCjp/OQx8/quRyceRPx2hqZQ3CNviwoLMRQiJ/k= +github.com/dunglas/mercure v0.21.8 h1:D+SxSq0VqdB29lfMXrsvDkFvq/cTL94aKCC0R4heKV0= +github.com/dunglas/mercure v0.21.8/go.mod h1:kt4RJpixJOcPN+x9Z53VBhpJYSdyEEzuu9/99vJIocQ= github.com/dunglas/skipfilter v1.0.0 h1:JG9SgGg4n6BlFwuTYzb9RIqjH7PfwszvWehanrYWPF4= github.com/dunglas/skipfilter v1.0.0/go.mod h1:ryhr8j7CAHSjzeN7wI6YEuwoArQ3OQmRqWWVCEAfb9w= -github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146 h1:h3vVM6X45PK0mAk8NqiYNQGXTyhvXy1HQ5GhuQN4eeA= -github.com/e-dant/watcher/watcher-go v0.0.0-20251208164151-f88ec3b7e146/go.mod h1:sVUOkwtftoj71nnJRG2S0oWNfXFdKpz/M9vK0z06nmM= +github.com/e-dant/watcher v0.0.0-20260202035023-10268e78355f h1:UDB5nhFRW7IOOpLk/eP1UGj7URmPimFGV+01/EG9qR8= +github.com/e-dant/watcher v0.0.0-20260202035023-10268e78355f/go.mod h1:PmV4IVmBJVqT2NcfTGN4+sZ+qGe3PA0qkphAtOHeFG0= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofrs/uuid/v5 v5.4.0 h1:EfbpCTjqMuGyq5ZJwxqzn3Cbr2d0rUZU7v5ycAk/e/0= github.com/gofrs/uuid/v5 v5.4.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -46,16 +46,16 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/maypok86/otter/v2 v2.2.1 h1:hnGssisMFkdisYcvQ8L019zpYQcdtPse+g0ps2i7cfI= -github.com/maypok86/otter/v2 v2.2.1/go.mod h1:1NKY9bY+kB5jwCXBJfE59u+zAwOt6C7ni1FTlFFMqVs= +github.com/maypok86/otter/v2 v2.3.0 h1:8H8AVVFUSzJwIegKwv1uF5aGitTY+AIrtktg7OcLs8w= +github.com/maypok86/otter/v2 v2.3.0/go.mod h1:XgIdlpmL6jYz882/CAx1E4C1ukfgDKSaw4mWq59+7l8= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -72,8 +72,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= -github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= @@ -108,16 +108,16 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/install.sh b/install.sh index eea9fe2251..0213ab6c81 100755 --- a/install.sh +++ b/install.sh @@ -3,9 +3,6 @@ set -e SUDO="" -if [ "$(id -u)" -ne 0 ]; then - SUDO="sudo" -fi if [ -z "${BIN_DIR}" ]; then BIN_DIR=$(pwd) @@ -34,12 +31,13 @@ Linux*) if [ "${ARCH}" = "aarch64" ] || [ "${ARCH}" = "x86_64" ]; then if command -v dnf >/dev/null 2>&1; then echo "📦 Detected dnf. Installing FrankenPHP from RPM repository..." - if [ -n "${SUDO}" ]; then + if [ "$(id -u)" -ne 0 ]; then + SUDO="sudo" echo "❗ Enter your password to grant sudo powers for package installation" ${SUDO} -v || true fi - ${SUDO} dnf -y install https://rpm.henderkes.com/static-php-1-0.noarch.rpm - ${SUDO} dnf -y module enable php-zts:static-8.4 || true + ${SUDO} dnf -y install https://rpm.henderkes.com/static-php-1-1.noarch.rpm + ${SUDO} dnf -y module enable php-zts:static-8.5 || true ${SUDO} dnf -y install frankenphp echo echo "🥳 FrankenPHP installed to ${italic}/usr/bin/frankenphp${normal} successfully." @@ -50,24 +48,50 @@ Linux*) exit 0 fi - if command -v apt >/dev/null 2>&1 || command -v apt-get >/dev/null 2>&1; then - echo "📦 Detected apt. Installing FrankenPHP from DEB repository..." - if [ -n "${SUDO}" ]; then + if command -v apt-get >/dev/null 2>&1; then + echo "📦 Detected apt-get. Installing FrankenPHP from DEB repository..." + if [ "$(id -u)" -ne 0 ]; then + SUDO="sudo" + echo "❗ Enter your password to grant sudo powers for package installation" + ${SUDO} -v || true + fi + ${SUDO} sh -c 'curl -fsSL https://pkg.henderkes.com/api/packages/85/debian/repository.key -o /etc/apt/keyrings/static-php85.asc' + ${SUDO} sh -c 'echo "deb [signed-by=/etc/apt/keyrings/static-php85.asc] https://pkg.henderkes.com/api/packages/85/debian php-zts main" | sudo tee -a /etc/apt/sources.list.d/static-php85.list' + ${SUDO} apt-get update + ${SUDO} apt-get -y install frankenphp + echo + echo "🥳 FrankenPHP installed to ${italic}/usr/bin/frankenphp${normal} successfully." + echo "❗ The systemd service uses the Caddyfile in ${italic}/etc/frankenphp/Caddyfile${normal}" + echo "❗ Your php.ini is found in ${italic}/etc/php-zts/php.ini${normal}" + echo + echo "⭐ If you like FrankenPHP, please give it a star on GitHub: ${italic}https://github.com/php/frankenphp${normal}" + exit 0 + fi + + if command -v apk >/dev/null 2>&1; then + echo "📦 Detected apk. Installing FrankenPHP from APK repository..." + if [ "$(id -u)" -ne 0 ]; then + SUDO="sudo" echo "❗ Enter your password to grant sudo powers for package installation" ${SUDO} -v || true fi - ${SUDO} sh -c 'curl -fsSL https://key.henderkes.com/static-php.gpg -o /usr/share/keyrings/static-php.gpg' - ${SUDO} sh -c 'echo "deb [signed-by=/usr/share/keyrings/static-php.gpg] https://deb.henderkes.com/ stable main" > /etc/apt/sources.list.d/static-php.list' - if command -v apt >/dev/null 2>&1; then - ${SUDO} apt update - ${SUDO} apt -y install frankenphp + + KEY_URL="https://pkg.henderkes.com/api/packages/85/alpine/key" + ${SUDO} sh -c "cd /etc/apk/keys && curl -JOsS \"$KEY_URL\" 2>/dev/null || true" + + REPO_URL="https://pkg.henderkes.com/api/packages/85/alpine/main/php-zts" + if grep -q "$REPO_URL" /etc/apk/repositories 2>/dev/null; then + echo "Repository already exists in /etc/apk/repositories" else - ${SUDO} apt-get update - ${SUDO} apt-get -y install frankenphp + ${SUDO} sh -c "echo \"$REPO_URL\" >> /etc/apk/repositories" + ${SUDO} apk update + echo "Repository added to /etc/apk/repositories" fi + + ${SUDO} apk add frankenphp echo echo "🥳 FrankenPHP installed to ${italic}/usr/bin/frankenphp${normal} successfully." - echo "❗ The systemd service uses the Caddyfile in ${italic}/etc/frankenphp/Caddyfile${normal}" + echo "❗ The OpenRC service uses the Caddyfile in ${italic}/etc/frankenphp/Caddyfile${normal}" echo "❗ Your php.ini is found in ${italic}/etc/php-zts/php.ini${normal}" echo echo "⭐ If you like FrankenPHP, please give it a star on GitHub: ${italic}https://github.com/php/frankenphp${normal}" diff --git a/internal/extgen/arginfo.go b/internal/extgen/arginfo.go index 7d6aa08a8a..494b1e0b9b 100644 --- a/internal/extgen/arginfo.go +++ b/internal/extgen/arginfo.go @@ -20,7 +20,7 @@ func (ag *arginfoGenerator) generate() error { } if _, err := os.Stat(genStubPath); err != nil { - return fmt.Errorf(`the PHP "gen_stub.php" file couldn't be found under %q, you can set the "GEN_STUB_SCRIPT" environement variable to set a custom location`, genStubPath) + return fmt.Errorf(`the PHP "gen_stub.php" file couldn't be found under %q, you can set the "GEN_STUB_SCRIPT" environment variable to set a custom location`, genStubPath) } stubFile := ag.generator.BaseName + ".stub.php" diff --git a/internal/extgen/constparser.go b/internal/extgen/constparser.go index 2f304895d9..86f80337cf 100644 --- a/internal/extgen/constparser.go +++ b/internal/extgen/constparser.go @@ -34,6 +34,10 @@ func (cp *ConstantParser) parse(filename string) (constants []phpConstant, err e expectClassConstDecl := false currentClassName := "" currentConstantValue := 0 + inConstBlock := false + exportAllInBlock := false + lastConstValue := "" + lastConstWasIota := false for scanner.Scan() { lineNumber++ @@ -55,7 +59,26 @@ func (cp *ConstantParser) parse(filename string) (constants []phpConstant, err e continue } - if (expectConstDecl || expectClassConstDecl) && strings.HasPrefix(line, "const ") { + if strings.HasPrefix(line, "const (") { + inConstBlock = true + if expectConstDecl || expectClassConstDecl { + exportAllInBlock = true + } + continue + } + + if inConstBlock && line == ")" { + inConstBlock = false + exportAllInBlock = false + expectConstDecl = false + expectClassConstDecl = false + currentClassName = "" + lastConstValue = "" + lastConstWasIota = false + continue + } + + if (expectConstDecl || expectClassConstDecl) && strings.HasPrefix(line, "const ") && !inConstBlock { matches := constDeclRegex.FindStringSubmatch(line) if len(matches) == 3 { name := matches[1] @@ -72,10 +95,11 @@ func (cp *ConstantParser) parse(filename string) (constants []phpConstant, err e constant.PhpType = determineConstantType(value) if constant.IsIota { - // affect a default value because user didn't give one constant.Value = fmt.Sprintf("%d", currentConstantValue) constant.PhpType = phpInt currentConstantValue++ + lastConstWasIota = true + lastConstValue = constant.Value } constants = append(constants, constant) @@ -84,7 +108,65 @@ func (cp *ConstantParser) parse(filename string) (constants []phpConstant, err e } expectConstDecl = false expectClassConstDecl = false - } else if (expectConstDecl || expectClassConstDecl) && !strings.HasPrefix(line, "//") && line != "" { + } else if inConstBlock && (expectConstDecl || expectClassConstDecl || exportAllInBlock) { + constBlockDeclRegex := regexp.MustCompile(`^(\w+)\s*=\s*(.+)$`) + if matches := constBlockDeclRegex.FindStringSubmatch(line); len(matches) == 3 { + name := matches[1] + value := strings.TrimSpace(matches[2]) + + constant := phpConstant{ + Name: name, + Value: value, + IsIota: value == "iota", + lineNumber: lineNumber, + ClassName: currentClassName, + } + + constant.PhpType = determineConstantType(value) + + if constant.IsIota { + constant.Value = fmt.Sprintf("%d", currentConstantValue) + constant.PhpType = phpInt + currentConstantValue++ + lastConstWasIota = true + lastConstValue = constant.Value + } else { + lastConstWasIota = false + lastConstValue = value + } + + constants = append(constants, constant) + expectConstDecl = false + expectClassConstDecl = false + } else { + constNameRegex := regexp.MustCompile(`^(\w+)$`) + if matches := constNameRegex.FindStringSubmatch(line); len(matches) == 2 { + name := matches[1] + + constant := phpConstant{ + Name: name, + Value: "", + IsIota: lastConstWasIota, + lineNumber: lineNumber, + ClassName: currentClassName, + } + + if lastConstWasIota { + constant.Value = fmt.Sprintf("%d", currentConstantValue) + constant.PhpType = phpInt + currentConstantValue++ + lastConstValue = constant.Value + } else { + constant.Value = lastConstValue + constant.PhpType = determineConstantType(lastConstValue) + } + + constants = append(constants, constant) + expectConstDecl = false + expectClassConstDecl = false + } + } + } else if (expectConstDecl || expectClassConstDecl) && !strings.HasPrefix(line, "//") && line != "" && !inConstBlock { // we expected a const declaration but found something else, reset expectConstDecl = false expectClassConstDecl = false diff --git a/internal/extgen/constparser_test.go b/internal/extgen/constparser_test.go index 29f0e38fdf..7c5ce89f27 100644 --- a/internal/extgen/constparser_test.go +++ b/internal/extgen/constparser_test.go @@ -221,7 +221,7 @@ func TestConstantParserIotaSequence(t *testing.T) { //export_php:const const FirstIota = iota -//export_php:const +//export_php:const const SecondIota = iota //export_php:const @@ -244,6 +244,179 @@ const ThirdIota = iota` } } +func TestConstantParserConstBlock(t *testing.T) { + input := `package main + +const ( + // export_php:const + STATUS_PENDING = iota + + // export_php:const + STATUS_PROCESSING + + // export_php:const + STATUS_COMPLETED +)` + + tmpDir := t.TempDir() + fileName := filepath.Join(tmpDir, "test.go") + require.NoError(t, os.WriteFile(fileName, []byte(input), 0644)) + + parser := &ConstantParser{} + constants, err := parser.parse(fileName) + assert.NoError(t, err, "parse() error") + + assert.Len(t, constants, 3, "Expected 3 constants") + + expectedNames := []string{"STATUS_PENDING", "STATUS_PROCESSING", "STATUS_COMPLETED"} + expectedValues := []string{"0", "1", "2"} + + for i, c := range constants { + assert.Equal(t, expectedNames[i], c.Name, "Expected constant %d name to be '%s'", i, expectedNames[i]) + assert.True(t, c.IsIota, "Expected constant %d to be iota", i) + assert.Equal(t, expectedValues[i], c.Value, "Expected constant %d value to be '%s'", i, expectedValues[i]) + assert.Equal(t, phpInt, c.PhpType, "Expected constant %d to be phpInt type", i) + } +} + +func TestConstantParserConstBlockWithBlockLevelDirective(t *testing.T) { + input := `package main + +// export_php:const +const ( + STATUS_PENDING = iota + STATUS_PROCESSING + STATUS_COMPLETED +)` + + tmpDir := t.TempDir() + fileName := filepath.Join(tmpDir, "test.go") + require.NoError(t, os.WriteFile(fileName, []byte(input), 0644)) + + parser := &ConstantParser{} + constants, err := parser.parse(fileName) + assert.NoError(t, err, "parse() error") + + assert.Len(t, constants, 3, "Expected 3 constants") + + expectedNames := []string{"STATUS_PENDING", "STATUS_PROCESSING", "STATUS_COMPLETED"} + expectedValues := []string{"0", "1", "2"} + + for i, c := range constants { + assert.Equal(t, expectedNames[i], c.Name, "Expected constant %d name to be '%s'", i, expectedNames[i]) + assert.True(t, c.IsIota, "Expected constant %d to be iota", i) + assert.Equal(t, expectedValues[i], c.Value, "Expected constant %d value to be '%s'", i, expectedValues[i]) + assert.Equal(t, phpInt, c.PhpType, "Expected constant %d to be phpInt type", i) + } +} + +func TestConstantParserMixedConstBlockAndIndividual(t *testing.T) { + input := `package main + +// export_php:const +const INDIVIDUAL = 42 + +const ( + // export_php:const + BLOCK_ONE = iota + + // export_php:const + BLOCK_TWO +) + +// export_php:const +const ANOTHER_INDIVIDUAL = "test"` + + tmpDir := t.TempDir() + fileName := filepath.Join(tmpDir, "test.go") + require.NoError(t, os.WriteFile(fileName, []byte(input), 0644)) + + parser := &ConstantParser{} + constants, err := parser.parse(fileName) + assert.NoError(t, err, "parse() error") + + assert.Len(t, constants, 4, "Expected 4 constants") + + assert.Equal(t, "INDIVIDUAL", constants[0].Name) + assert.Equal(t, "42", constants[0].Value) + assert.Equal(t, phpInt, constants[0].PhpType) + + assert.Equal(t, "BLOCK_ONE", constants[1].Name) + assert.Equal(t, "0", constants[1].Value) + assert.True(t, constants[1].IsIota) + + assert.Equal(t, "BLOCK_TWO", constants[2].Name) + assert.Equal(t, "1", constants[2].Value) + assert.True(t, constants[2].IsIota) + + assert.Equal(t, "ANOTHER_INDIVIDUAL", constants[3].Name) + assert.Equal(t, `"test"`, constants[3].Value) + assert.Equal(t, phpString, constants[3].PhpType) +} + +func TestConstantParserClassConstBlock(t *testing.T) { + input := `package main + +// export_php:classconst Config +const ( + MODE_DEBUG = 1 + MODE_PRODUCTION = 2 + MODE_TEST = 3 +)` + + tmpDir := t.TempDir() + fileName := filepath.Join(tmpDir, "test.go") + require.NoError(t, os.WriteFile(fileName, []byte(input), 0644)) + + parser := &ConstantParser{} + constants, err := parser.parse(fileName) + assert.NoError(t, err, "parse() error") + + assert.Len(t, constants, 3, "Expected 3 class constants") + + expectedNames := []string{"MODE_DEBUG", "MODE_PRODUCTION", "MODE_TEST"} + expectedValues := []string{"1", "2", "3"} + + for i, c := range constants { + assert.Equal(t, expectedNames[i], c.Name, "Expected constant %d name to be '%s'", i, expectedNames[i]) + assert.Equal(t, "Config", c.ClassName, "Expected constant %d to belong to Config class", i) + assert.Equal(t, expectedValues[i], c.Value, "Expected constant %d value to be '%s'", i, expectedValues[i]) + assert.Equal(t, phpInt, c.PhpType, "Expected constant %d to be phpInt type", i) + } +} + +func TestConstantParserClassConstBlockWithIota(t *testing.T) { + input := `package main + +// export_php:classconst Status +const ( + STATUS_PENDING = iota + STATUS_ACTIVE + STATUS_COMPLETED +)` + + tmpDir := t.TempDir() + fileName := filepath.Join(tmpDir, "test.go") + require.NoError(t, os.WriteFile(fileName, []byte(input), 0644)) + + parser := &ConstantParser{} + constants, err := parser.parse(fileName) + assert.NoError(t, err, "parse() error") + + assert.Len(t, constants, 3, "Expected 3 class constants") + + expectedNames := []string{"STATUS_PENDING", "STATUS_ACTIVE", "STATUS_COMPLETED"} + expectedValues := []string{"0", "1", "2"} + + for i, c := range constants { + assert.Equal(t, expectedNames[i], c.Name, "Expected constant %d name to be '%s'", i, expectedNames[i]) + assert.Equal(t, "Status", c.ClassName, "Expected constant %d to belong to Status class", i) + assert.True(t, c.IsIota, "Expected constant %d to be iota", i) + assert.Equal(t, expectedValues[i], c.Value, "Expected constant %d value to be '%s'", i, expectedValues[i]) + assert.Equal(t, phpInt, c.PhpType, "Expected constant %d to be phpInt type", i) + } +} + func TestConstantParserTypeDetection(t *testing.T) { tests := []struct { name string diff --git a/internal/extgen/gofile.go b/internal/extgen/gofile.go index da015fe461..044696a47f 100644 --- a/internal/extgen/gofile.go +++ b/internal/extgen/gofile.go @@ -4,8 +4,9 @@ import ( "bytes" _ "embed" "fmt" - "os" + "go/format" "path/filepath" + "strings" "text/template" "github.com/Masterminds/sprig/v3" @@ -21,7 +22,7 @@ type GoFileGenerator struct { type goTemplateData struct { PackageName string BaseName string - Imports []string + SanitizedBaseName string Constants []phpConstant Variables []string InternalFunctions []string @@ -30,16 +31,7 @@ type goTemplateData struct { } func (gg *GoFileGenerator) generate() error { - filename := filepath.Join(gg.generator.BuildDir, gg.generator.BaseName+".go") - - if _, err := os.Stat(filename); err == nil { - backupFilename := filename + ".bak" - if err := os.Rename(filename, backupFilename); err != nil { - return fmt.Errorf("backing up existing Go file: %w", err) - } - - gg.generator.SourceFile = backupFilename - } + filename := filepath.Join(gg.generator.BuildDir, gg.generator.BaseName+"_generated.go") content, err := gg.buildContent() if err != nil { @@ -51,38 +43,18 @@ func (gg *GoFileGenerator) generate() error { func (gg *GoFileGenerator) buildContent() (string, error) { sourceAnalyzer := SourceAnalyzer{} - imports, variables, internalFunctions, err := sourceAnalyzer.analyze(gg.generator.SourceFile) + packageName, variables, internalFunctions, err := sourceAnalyzer.analyze(gg.generator.SourceFile) if err != nil { return "", fmt.Errorf("analyzing source file: %w", err) } - filteredImports := make([]string, 0, len(imports)) - for _, imp := range imports { - if imp != `"C"` && imp != `"unsafe"` && imp != `"github.com/dunglas/frankenphp"` && imp != `"runtime/cgo"` { - filteredImports = append(filteredImports, imp) - } - } - classes := make([]phpClass, len(gg.generator.Classes)) copy(classes, gg.generator.Classes) - if len(classes) > 0 { - hasCgo := false - for _, imp := range imports { - if imp == `"runtime/cgo"` { - hasCgo = true - break - } - } - if !hasCgo { - filteredImports = append(filteredImports, `"runtime/cgo"`) - } - } - templateContent, err := gg.getTemplateContent(goTemplateData{ - PackageName: SanitizePackageName(gg.generator.BaseName), + PackageName: packageName, BaseName: gg.generator.BaseName, - Imports: filteredImports, + SanitizedBaseName: SanitizePackageName(gg.generator.BaseName), Constants: gg.generator.Constants, Variables: variables, InternalFunctions: internalFunctions, @@ -94,7 +66,12 @@ func (gg *GoFileGenerator) buildContent() (string, error) { return "", fmt.Errorf("executing template: %w", err) } - return templateContent, nil + fc, err := format.Source([]byte(templateContent)) + if err != nil { + return "", fmt.Errorf("formatting source: %w", err) + } + + return string(fc), nil } func (gg *GoFileGenerator) getTemplateContent(data goTemplateData) (string, error) { @@ -106,6 +83,10 @@ func (gg *GoFileGenerator) getTemplateContent(data goTemplateData) (string, erro funcMap["isVoid"] = func(t phpType) bool { return t == phpVoid } + funcMap["extractGoFunctionName"] = extractGoFunctionName + funcMap["extractGoFunctionSignatureParams"] = extractGoFunctionSignatureParams + funcMap["extractGoFunctionSignatureReturn"] = extractGoFunctionSignatureReturn + funcMap["extractGoFunctionCallParams"] = extractGoFunctionCallParams tmpl := template.Must(template.New("gofile").Funcs(funcMap).Parse(goFileContent)) @@ -128,7 +109,7 @@ type GoParameter struct { Type string } -var phpToGoTypeMap= map[phpType]string{ +var phpToGoTypeMap = map[phpType]string{ phpString: "string", phpInt: "int64", phpFloat: "float64", @@ -146,3 +127,119 @@ func (gg *GoFileGenerator) phpTypeToGoType(phpT phpType) string { return "any" } + +// extractGoFunctionName extracts the Go function name from a Go function signature string. +func extractGoFunctionName(goFunction string) string { + idx := strings.Index(goFunction, "func ") + if idx == -1 { + return "" + } + + start := idx + len("func ") + + end := start + for end < len(goFunction) && goFunction[end] != '(' { + end++ + } + + if end >= len(goFunction) { + return "" + } + + return strings.TrimSpace(goFunction[start:end]) +} + +// extractGoFunctionSignatureParams extracts the parameters from a Go function signature. +func extractGoFunctionSignatureParams(goFunction string) string { + start := strings.IndexByte(goFunction, '(') + if start == -1 { + return "" + } + start++ + + depth := 1 + end := start + for end < len(goFunction) && depth > 0 { + switch goFunction[end] { + case '(': + depth++ + case ')': + depth-- + } + if depth > 0 { + end++ + } + } + + if end >= len(goFunction) { + return "" + } + + return strings.TrimSpace(goFunction[start:end]) +} + +// extractGoFunctionSignatureReturn extracts the return type from a Go function signature. +func extractGoFunctionSignatureReturn(goFunction string) string { + start := strings.IndexByte(goFunction, '(') + if start == -1 { + return "" + } + + depth := 1 + pos := start + 1 + for pos < len(goFunction) && depth > 0 { + switch goFunction[pos] { + case '(': + depth++ + case ')': + depth-- + } + pos++ + } + + if pos >= len(goFunction) { + return "" + } + + end := strings.IndexByte(goFunction[pos:], '{') + if end == -1 { + return "" + } + end += pos + + returnType := strings.TrimSpace(goFunction[pos:end]) + return returnType +} + +// extractGoFunctionCallParams extracts just the parameter names for calling a function. +func extractGoFunctionCallParams(goFunction string) string { + params := extractGoFunctionSignatureParams(goFunction) + if params == "" { + return "" + } + + var names []string + parts := strings.Split(params, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if len(part) == 0 { + continue + } + + words := strings.Fields(part) + if len(words) > 0 { + names = append(names, words[0]) + } + } + + var result strings.Builder + for i, name := range names { + if i > 0 { + result.WriteString(", ") + } + + result.WriteString(name) + } + + return result.String() +} diff --git a/internal/extgen/gofile_test.go b/internal/extgen/gofile_test.go index 754d95c0e1..a64504a422 100644 --- a/internal/extgen/gofile_test.go +++ b/internal/extgen/gofile_test.go @@ -1,9 +1,11 @@ package extgen import ( + "bytes" + "crypto/sha256" + "encoding/hex" "os" "path/filepath" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -69,16 +71,20 @@ func anotherHelper() { goGen := GoFileGenerator{generator} require.NoError(t, goGen.generate()) - expectedFile := filepath.Join(tmpDir, "test.go") - require.FileExists(t, expectedFile) + sourceStillExists := filepath.Join(tmpDir, "test.go") + require.FileExists(t, sourceStillExists) + sourceStillContent, err := readFile(sourceStillExists) + require.NoError(t, err) + assert.Equal(t, sourceContent, sourceStillContent, "Source file should not be modified") + + generatedFile := filepath.Join(tmpDir, "test_generated.go") + require.FileExists(t, generatedFile) - content, err := readFile(expectedFile) + generatedContent, err := readFile(generatedFile) require.NoError(t, err) - testGoFileBasicStructure(t, content, "test") - testGoFileImports(t, content) - testGoFileExportedFunctions(t, content, generator.Functions) - testGoFileInternalFunctions(t, content) + testGeneratedFileBasicStructure(t, generatedContent, "main", "test") + testGeneratedFileWrappers(t, generatedContent, generator.Functions) } func TestGoFileGenerator_BuildContent(t *testing.T) { @@ -87,6 +93,7 @@ func TestGoFileGenerator_BuildContent(t *testing.T) { baseName string sourceFile string functions []phpFunction + classes []phpClass contains []string notContains []string }{ @@ -107,13 +114,14 @@ func test() { }, }, contains: []string{ - "package simple", + "package main", `#include "simple.h"`, `import "C"`, "func init()", "frankenphp.RegisterExtension(", - "//export test", - "func test()", + "//export go_test", + "func go_test()", + "test()", // wrapper calls original function }, }, { @@ -142,12 +150,10 @@ func process(data *go_string) *go_value { }, }, contains: []string{ - "package complex", - `"fmt"`, - `"strings"`, - `"encoding/json"`, - "//export process", + "package main", + "//export go_process", `"C"`, + "process(", // wrapper calls original function }, }, { @@ -173,9 +179,81 @@ func internalFunc2(data string) { }, }, contains: []string{ + "//export go_publicFunc", + "func go_publicFunc()", + "publicFunc()", // wrapper calls original function + }, + notContains: []string{ "func internalFunc1() string", "func internalFunc2(data string)", - "//export publicFunc", + }, + }, + { + name: "runtime/cgo blank import without classes", + baseName: "no_classes", + sourceFile: createTempSourceFile(t, `package main + +//export_php: getValue(): string +func getValue() string { + return "test" +}`), + functions: []phpFunction{ + { + Name: "getValue", + ReturnType: phpString, + GoFunction: `func getValue() string { + return "test" +}`, + }, + }, + classes: nil, + contains: []string{ + `_ "runtime/cgo"`, + "func init()", + "frankenphp.RegisterExtension(", + }, + notContains: []string{ + "cgo.NewHandle", + "registerGoObject", + "getGoObject", + "removeGoObject", + }, + }, + { + name: "runtime/cgo normal import with classes", + baseName: "with_classes", + sourceFile: createTempSourceFile(t, `package main + +//export_php:class TestClass +type TestStruct struct { + value string +} + +//export_php:method TestClass::getValue(): string +func (ts *TestStruct) GetValue() string { + return ts.value +}`), + functions: []phpFunction{}, + classes: []phpClass{ + { + Name: "TestClass", + GoStruct: "TestStruct", + Methods: []phpClassMethod{ + { + Name: "GetValue", + ReturnType: phpString, + }, + }, + }, + }, + contains: []string{ + `"runtime/cgo"`, + "cgo.NewHandle", + "func registerGoObject", + "func getGoObject", + }, + notContains: []string{ + `_ "runtime/cgo"`, }, }, } @@ -186,6 +264,7 @@ func internalFunc2(data string) { BaseName: tt.baseName, SourceFile: tt.sourceFile, Functions: tt.functions, + Classes: tt.classes, } goGen := GoFileGenerator{generator} @@ -195,6 +274,10 @@ func internalFunc2(data string) { for _, expected := range tt.contains { assert.Contains(t, content, expected, "Generated Go content should contain %q", expected) } + + for _, notExpected := range tt.notContains { + assert.NotContains(t, content, notExpected, "Generated Go content should NOT contain %q", notExpected) + } }) } } @@ -204,11 +287,11 @@ func TestGoFileGenerator_PackageNameSanitization(t *testing.T) { baseName string expectedPackage string }{ - {"simple", "simple"}, - {"my-extension", "my_extension"}, - {"ext.with.dots", "ext_with_dots"}, - {"123invalid", "_123invalid"}, - {"valid_name", "valid_name"}, + {"simple", "main"}, + {"my-extension", "main"}, + {"ext.with.dots", "main"}, + {"123invalid", "main"}, + {"valid_name", "main"}, } for _, tt := range tests { @@ -275,57 +358,6 @@ func TestGoFileGenerator_ErrorHandling(t *testing.T) { } } -func TestGoFileGenerator_ImportFiltering(t *testing.T) { - sourceContent := `package main - -import ( - "C" - "fmt" - "strings" - "github.com/dunglas/frankenphp/internal/extensions/types" - "github.com/other/package" - originalPkg "github.com/test/original" -) - -//export_php: test(): void -func test() {}` - - sourceFile := createTempSourceFile(t, sourceContent) - - generator := &Generator{ - BaseName: "importtest", - SourceFile: sourceFile, - Functions: []phpFunction{ - {Name: "test", ReturnType: phpVoid, GoFunction: "func test() {}"}, - }, - } - - goGen := GoFileGenerator{generator} - content, err := goGen.buildContent() - require.NoError(t, err) - - expectedImports := []string{ - `"fmt"`, - `"strings"`, - `"github.com/other/package"`, - } - - for _, imp := range expectedImports { - assert.Contains(t, content, imp, "Generated content should contain import: %s", imp) - } - - forbiddenImports := []string{ - `"C"`, - } - - cImportCount := strings.Count(content, `"C"`) - assert.Equal(t, 1, cImportCount, "Expected exactly 1 occurrence of 'import \"C\"'") - - for _, imp := range forbiddenImports[1:] { - assert.NotContains(t, content, imp, "Generated content should NOT contain import: %s", imp) - } -} - func TestGoFileGenerator_ComplexScenario(t *testing.T) { sourceContent := `package example @@ -398,26 +430,12 @@ func debugPrint(msg string) { goGen := GoFileGenerator{generator} content, err := goGen.buildContent() require.NoError(t, err) - assert.Contains(t, content, "package complex_example", "Package name should be sanitized") - - internalFuncs := []string{ - "func internalProcess(data string) string", - "func validateFormat(input string) bool", - "func jsonHelper(data any) ([]byte, error)", - "func debugPrint(msg string)", - } - - for _, fn := range internalFuncs { - assert.Contains(t, content, fn, "Generated content should contain internal function: %s", fn) - } + assert.Contains(t, content, "package example", "Package name should match source package") for _, fn := range functions { - exportDirective := "//export " + fn.Name + exportDirective := "//export go_" + fn.Name assert.Contains(t, content, exportDirective, "Generated content should contain export directive: %s", exportDirective) } - - assert.False(t, strings.Contains(content, "types.Array") || strings.Contains(content, "types.Bool"), "Types should be replaced (types.* should not appear)") - assert.True(t, strings.Contains(content, "return Array(") && strings.Contains(content, "return Bool("), "Replaced types should appear without types prefix") } func TestGoFileGenerator_MethodWrapperWithNullableParams(t *testing.T) { @@ -602,6 +620,434 @@ func (as *ArrayStruct) FilterData(data frankenphp.AssociativeArray, filter strin assert.Contains(t, content, "//export FilterData_wrapper", "Generated content should contain FilterData export directive") } +func TestGoFileGenerator_Idempotency(t *testing.T) { + tmpDir := t.TempDir() + + sourceContent := `package main + +import ( + "fmt" + "strings" +) + +//export_php: greet(name string): string +func greet(name *go_string) *go_value { + return String("Hello " + CStringToGoString(name)) +} + +func internalHelper(data string) string { + return strings.ToUpper(data) +}` + + sourceFile := filepath.Join(tmpDir, "test.go") + require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644)) + + generator := &Generator{ + BaseName: "test", + SourceFile: sourceFile, + BuildDir: tmpDir, + Functions: []phpFunction{ + { + Name: "greet", + ReturnType: phpString, + GoFunction: `func greet(name *go_string) *go_value { + return String("Hello " + CStringToGoString(name)) +}`, + }, + }, + } + + goGen := GoFileGenerator{generator} + require.NoError(t, goGen.generate(), "First generation should succeed") + + generatedFile := filepath.Join(tmpDir, "test_generated.go") + require.FileExists(t, generatedFile, "Generated file should exist after first run") + + firstRunContent, err := os.ReadFile(generatedFile) + require.NoError(t, err) + firstRunSourceContent, err := os.ReadFile(sourceFile) + require.NoError(t, err) + + require.NoError(t, goGen.generate(), "Second generation should succeed") + + secondRunContent, err := os.ReadFile(generatedFile) + require.NoError(t, err) + secondRunSourceContent, err := os.ReadFile(sourceFile) + require.NoError(t, err) + + assert.True(t, bytes.Equal(firstRunContent, secondRunContent), "Generated file content should be identical between runs") + assert.True(t, bytes.Equal(firstRunSourceContent, secondRunSourceContent), "Source file should remain unchanged after both runs") + assert.Equal(t, sourceContent, string(secondRunSourceContent), "Source file content should match original") +} + +func TestGoFileGenerator_HeaderComments(t *testing.T) { + tmpDir := t.TempDir() + + sourceContent := `package main + +//export_php: test(): void +func test() { + // simple function +}` + + sourceFile := filepath.Join(tmpDir, "test.go") + require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644)) + + generator := &Generator{ + BaseName: "test", + SourceFile: sourceFile, + BuildDir: tmpDir, + Functions: []phpFunction{ + { + Name: "test", + ReturnType: phpVoid, + GoFunction: "func test() {\n\t// simple function\n}", + }, + }, + } + + goGen := GoFileGenerator{generator} + require.NoError(t, goGen.generate()) + + generatedFile := filepath.Join(tmpDir, "test_generated.go") + require.FileExists(t, generatedFile) + + assertContainsHeaderComment(t, generatedFile) +} + +func TestExtractGoFunctionName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple function", + input: "func test() {}", + expected: "test", + }, + { + name: "function with params", + input: "func calculate(a int, b int) int {}", + expected: "calculate", + }, + { + name: "function with complex params", + input: "func process(data *go_string, opts *go_nullable) *go_value {}", + expected: "process", + }, + { + name: "function with whitespace", + input: "func spacedName () {}", + expected: "spacedName", + }, + { + name: "no func keyword", + input: "test() {}", + expected: "", + }, + { + name: "empty string", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractGoFunctionName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractGoFunctionSignatureParams(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "no parameters", + input: "func test() {}", + expected: "", + }, + { + name: "single parameter", + input: "func test(name string) {}", + expected: "name string", + }, + { + name: "multiple parameters", + input: "func test(a int, b string, c bool) {}", + expected: "a int, b string, c bool", + }, + { + name: "pointer parameters", + input: "func test(data *go_string) {}", + expected: "data *go_string", + }, + { + name: "nested parentheses", + input: "func test(fn func(int) string) {}", + expected: "fn func(int) string", + }, + { + name: "variadic parameters", + input: "func test(args ...string) {}", + expected: "args ...string", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractGoFunctionSignatureParams(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractGoFunctionSignatureReturn(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "no return type", + input: "func test() {}", + expected: "", + }, + { + name: "single return type", + input: "func test() string {}", + expected: "string", + }, + { + name: "pointer return type", + input: "func test() *go_value {}", + expected: "*go_value", + }, + { + name: "multiple return types", + input: "func test() (string, error) {}", + expected: "(string, error)", + }, + { + name: "named return values", + input: "func test() (result string, err error) {}", + expected: "(result string, err error)", + }, + { + name: "complex return type", + input: "func test() unsafe.Pointer {}", + expected: "unsafe.Pointer", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractGoFunctionSignatureReturn(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExtractGoFunctionCallParams(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "no parameters", + input: "func test() {}", + expected: "", + }, + { + name: "single parameter", + input: "func test(name string) {}", + expected: "name", + }, + { + name: "multiple parameters", + input: "func test(a int, b string, c bool) {}", + expected: "a, b, c", + }, + { + name: "pointer parameters", + input: "func test(data *go_string, opts *go_nullable) {}", + expected: "data, opts", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractGoFunctionCallParams(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGoFileGenerator_SourceFilePreservation(t *testing.T) { + tmpDir := t.TempDir() + + sourceContent := `package main + +import "fmt" + +//export_php: greet(name string): string +func greet(name *go_string) *go_value { + return String(fmt.Sprintf("Hello, %s!", CStringToGoString(name))) +} + +func internalHelper() { + fmt.Println("internal") +}` + + sourceFile := filepath.Join(tmpDir, "test.go") + require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644)) + + hashBefore := computeFileHash(t, sourceFile) + + generator := &Generator{ + BaseName: "test", + SourceFile: sourceFile, + BuildDir: tmpDir, + Functions: []phpFunction{ + { + Name: "greet", + ReturnType: phpString, + GoFunction: `func greet(name *go_string) *go_value { + return String(fmt.Sprintf("Hello, %s!", CStringToGoString(name))) +}`, + }, + }, + } + + goGen := GoFileGenerator{generator} + require.NoError(t, goGen.generate()) + + hashAfter := computeFileHash(t, sourceFile) + + assert.Equal(t, hashBefore, hashAfter, "Source file hash should remain unchanged after generation") + + contentAfter, err := os.ReadFile(sourceFile) + require.NoError(t, err) + assert.Equal(t, sourceContent, string(contentAfter), "Source file content should be byte-for-byte identical") +} + +func TestGoFileGenerator_WrapperParameterForwarding(t *testing.T) { + tmpDir := t.TempDir() + + sourceContent := `package main + +import "fmt" + +//export_php: process(name string, count int): string +func process(name *go_string, count long) *go_value { + n := CStringToGoString(name) + return String(fmt.Sprintf("%s: %d", n, count)) +} + +//export_php: simple(): void +func simple() {}` + + sourceFile := filepath.Join(tmpDir, "test.go") + require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644)) + + functions := []phpFunction{ + { + Name: "process", + ReturnType: phpString, + GoFunction: `func process(name *go_string, count long) *go_value { + n := CStringToGoString(name) + return String(fmt.Sprintf("%s: %d", n, count)) +}`, + }, + { + Name: "simple", + ReturnType: phpVoid, + GoFunction: "func simple() {}", + }, + } + + generator := &Generator{ + BaseName: "wrapper_test", + SourceFile: sourceFile, + BuildDir: tmpDir, + Functions: functions, + } + + goGen := GoFileGenerator{generator} + content, err := goGen.buildContent() + require.NoError(t, err) + + assert.Contains(t, content, "//export go_process", "Should have wrapper export directive") + assert.Contains(t, content, "func go_process(", "Should have wrapper function") + assert.Contains(t, content, "process(", "Wrapper should call original function") + + assert.Contains(t, content, "//export go_simple", "Should have simple wrapper export directive") + assert.Contains(t, content, "func go_simple()", "Should have simple wrapper function") + assert.Contains(t, content, "simple()", "Simple wrapper should call original function") +} + +func TestGoFileGenerator_MalformedSource(t *testing.T) { + tests := []struct { + name string + sourceContent string + expectError bool + }{ + { + name: "missing package declaration", + sourceContent: "func test() {}", + expectError: true, + }, + { + name: "syntax error", + sourceContent: "package main\nfunc test( {}", + expectError: true, + }, + { + name: "incomplete function", + sourceContent: "package main\nfunc test() {", + expectError: true, + }, + { + name: "valid minimal source", + sourceContent: "package main\nfunc test() {}", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir := t.TempDir() + sourceFile := filepath.Join(tmpDir, "test.go") + require.NoError(t, os.WriteFile(sourceFile, []byte(tt.sourceContent), 0644)) + + generator := &Generator{ + BaseName: "test", + SourceFile: sourceFile, + BuildDir: tmpDir, + } + + goGen := GoFileGenerator{generator} + _, err := goGen.buildContent() + + if tt.expectError { + assert.Error(t, err, "Expected error for malformed source") + } else { + assert.NoError(t, err, "Should not error for valid source") + } + + contentAfter, readErr := os.ReadFile(sourceFile) + require.NoError(t, readErr) + assert.Equal(t, tt.sourceContent, string(contentAfter), "Source file should remain unchanged even on error") + }) + } +} + func TestGoFileGenerator_MethodWrapperWithNullableArrayParams(t *testing.T) { tmpDir := t.TempDir() @@ -672,37 +1118,6 @@ func createTempSourceFile(t *testing.T, content string) string { return tmpFile } -func testGoFileBasicStructure(t *testing.T, content, baseName string) { - requiredElements := []string{ - "package " + SanitizePackageName(baseName), - "// #include ", - `// #include "` + baseName + `.h"`, - `import "C"`, - "func init() {", - "frankenphp.RegisterExtension(", - "}", - } - - for _, element := range requiredElements { - assert.Contains(t, content, element, "Go file should contain: %s", element) - } -} - -func testGoFileImports(t *testing.T, content string) { - cImportCount := strings.Count(content, `"C"`) - assert.Equal(t, 1, cImportCount, "Expected exactly 1 C import") -} - -func testGoFileExportedFunctions(t *testing.T, content string, functions []phpFunction) { - for _, fn := range functions { - exportDirective := "//export " + fn.Name - assert.Contains(t, content, exportDirective, "Go file should contain export directive: %s", exportDirective) - - funcStart := "func " + fn.Name + "(" - assert.Contains(t, content, funcStart, "Go file should contain function definition: %s", funcStart) - } -} - func TestGoFileGenerator_MethodWrapperWithCallableParams(t *testing.T) { tmpDir := t.TempDir() @@ -822,22 +1237,54 @@ func TestGoFileGenerator_phpTypeToGoType(t *testing.T) { }) } -func testGoFileInternalFunctions(t *testing.T, content string) { - internalIndicators := []string{ - "func internalHelper", - "func anotherHelper", +func testGeneratedFileBasicStructure(t *testing.T, content, expectedPackage, baseName string) { + requiredElements := []string{ + "package " + expectedPackage, + "// #include ", + `// #include "` + baseName + `.h"`, + `import "C"`, + "func init() {", + "frankenphp.RegisterExtension(", + "}", } - foundInternal := false - for _, indicator := range internalIndicators { - if strings.Contains(content, indicator) { - foundInternal = true + for _, element := range requiredElements { + assert.Contains(t, content, element, "Generated file should contain: %s", element) + } - break + assert.NotContains(t, content, "func internalHelper", "Generated file should not contain internal functions from source") + assert.NotContains(t, content, "func anotherHelper", "Generated file should not contain internal functions from source") +} + +func testGeneratedFileWrappers(t *testing.T, content string, functions []phpFunction) { + for _, fn := range functions { + exportDirective := "//export go_" + fn.Name + assert.Contains(t, content, exportDirective, "Generated file should contain export directive: %s", exportDirective) + + wrapperFunc := "func go_" + fn.Name + "(" + assert.Contains(t, content, wrapperFunc, "Generated file should contain wrapper function: %s", wrapperFunc) + + funcName := extractGoFunctionName(fn.GoFunction) + if funcName != "" { + assert.Contains(t, content, funcName+"(", "Generated wrapper should call original function: %s", funcName) } } +} - if !foundInternal { - t.Log("No internal functions found (this may be expected)") - } +// computeFileHash returns SHA256 hash of file +func computeFileHash(t *testing.T, filename string) string { + content, err := os.ReadFile(filename) + require.NoError(t, err) + hash := sha256.Sum256(content) + return hex.EncodeToString(hash[:]) +} + +// assertContainsHeaderComment verifies file has autogenerated header +func assertContainsHeaderComment(t *testing.T, filename string) { + content, err := os.ReadFile(filename) + require.NoError(t, err) + + headerSection := string(content[:min(len(content), 500)]) + assert.Contains(t, headerSection, "AUTOGENERATED FILE - DO NOT EDIT", "File should contain autogenerated header comment") + assert.Contains(t, headerSection, "FrankenPHP extension generator", "File should mention FrankenPHP extension generator") } diff --git a/internal/extgen/integration_test.go b/internal/extgen/integration_test.go index 86723fc395..c8cdd90c9a 100644 --- a/internal/extgen/integration_test.go +++ b/internal/extgen/integration_test.go @@ -75,7 +75,7 @@ func (s *IntegrationTestSuite) createGoModule(sourceFile string) (string, error) goModContent := fmt.Sprintf(`module %s -go 1.25 +go 1.26 require github.com/dunglas/frankenphp v0.0.0 @@ -480,6 +480,7 @@ func TestConstants(t *testing.T) { []string{ "TEST_MAX_RETRIES", "TEST_API_VERSION", "TEST_ENABLED", "TEST_PI", "STATUS_PENDING", "STATUS_PROCESSING", "STATUS_COMPLETED", + "ONE", "TWO", }, ) require.NoError(t, err, "all constants, functions, and classes should be accessible from PHP") diff --git a/internal/extgen/paramparser.go b/internal/extgen/paramparser.go index bd7bb3532a..6dcc365e3a 100644 --- a/internal/extgen/paramparser.go +++ b/internal/extgen/paramparser.go @@ -88,13 +88,11 @@ func (pp *ParameterParser) getDefaultValue(param phpParameter, fallback string) func (pp *ParameterParser) generateParamParsing(params []phpParameter, requiredCount int) string { if len(params) == 0 { - return ` if (zend_parse_parameters_none() == FAILURE) { - RETURN_THROWS(); - }` + return ` ZEND_PARSE_PARAMETERS_NONE();` } var builder strings.Builder - builder.WriteString(fmt.Sprintf(" ZEND_PARSE_PARAMETERS_START(%d, %d)", requiredCount, len(params))) + _, _ = fmt.Fprintf(&builder, " ZEND_PARSE_PARAMETERS_START(%d, %d)", requiredCount, len(params)) optionalStarted := false for _, param := range params { diff --git a/internal/extgen/paramparser_test.go b/internal/extgen/paramparser_test.go index b0cdf6b9bb..8d19e53f00 100644 --- a/internal/extgen/paramparser_test.go +++ b/internal/extgen/paramparser_test.go @@ -223,9 +223,7 @@ func TestParameterParser_GenerateParamParsing(t *testing.T) { name: "no parameters", params: []phpParameter{}, requiredCount: 0, - expected: ` if (zend_parse_parameters_none() == FAILURE) { - RETURN_THROWS(); - }`, + expected: ` ZEND_PARSE_PARAMETERS_NONE();`, }, { name: "single required string parameter", diff --git a/internal/extgen/phpfunc.go b/internal/extgen/phpfunc.go index 13cad8206b..66a0123eeb 100644 --- a/internal/extgen/phpfunc.go +++ b/internal/extgen/phpfunc.go @@ -16,7 +16,7 @@ func (pfg *PHPFuncGenerator) generate(fn phpFunction) string { paramInfo := pfg.paramParser.analyzeParameters(fn.Params) funcName := NamespacedName(pfg.namespace, fn.Name) - builder.WriteString(fmt.Sprintf("PHP_FUNCTION(%s)\n{\n", funcName)) + _, _ = fmt.Fprintf(&builder, "PHP_FUNCTION(%s)\n{\n", funcName) if decl := pfg.paramParser.generateParamDeclarations(fn.Params); decl != "" { builder.WriteString(decl + "\n") @@ -37,24 +37,25 @@ func (pfg *PHPFuncGenerator) generate(fn phpFunction) string { func (pfg *PHPFuncGenerator) generateGoCall(fn phpFunction) string { callParams := pfg.paramParser.generateGoCallParams(fn.Params) + goFuncName := "go_" + fn.Name if fn.ReturnType == phpVoid { - return fmt.Sprintf(" %s(%s);", fn.Name, callParams) + return fmt.Sprintf(" %s(%s);", goFuncName, callParams) } if fn.ReturnType == phpString { - return fmt.Sprintf(" zend_string *result = %s(%s);", fn.Name, callParams) + return fmt.Sprintf(" zend_string *result = %s(%s);", goFuncName, callParams) } if fn.ReturnType == phpArray { - return fmt.Sprintf(" zend_array *result = %s(%s);", fn.Name, callParams) + return fmt.Sprintf(" zend_array *result = %s(%s);", goFuncName, callParams) } if fn.ReturnType == phpMixed { - return fmt.Sprintf(" zval *result = %s(%s);", fn.Name, callParams) + return fmt.Sprintf(" zval *result = %s(%s);", goFuncName, callParams) } - return fmt.Sprintf(" %s result = %s(%s);", pfg.getCReturnType(fn.ReturnType), fn.Name, callParams) + return fmt.Sprintf(" %s result = %s(%s);", pfg.getCReturnType(fn.ReturnType), goFuncName, callParams) } func (pfg *PHPFuncGenerator) getCReturnType(returnType phpType) string { diff --git a/internal/extgen/phpfunc_test.go b/internal/extgen/phpfunc_test.go index 9725dc6bdc..3a0365ccd5 100644 --- a/internal/extgen/phpfunc_test.go +++ b/internal/extgen/phpfunc_test.go @@ -26,7 +26,7 @@ func TestPHPFunctionGenerator_Generate(t *testing.T) { "PHP_FUNCTION(greet)", "zend_string *name = NULL;", "Z_PARAM_STR(name)", - "zend_string *result = greet(name);", + "zend_string *result = go_greet(name);", "RETURN_STR(result)", }, }, @@ -61,7 +61,7 @@ func TestPHPFunctionGenerator_Generate(t *testing.T) { }, contains: []string{ "PHP_FUNCTION(doSomething)", - "doSomething(action);", + "go_doSomething(action);", }, }, { @@ -109,7 +109,7 @@ func TestPHPFunctionGenerator_Generate(t *testing.T) { "PHP_FUNCTION(process_array)", "zend_array *input = NULL;", "Z_PARAM_ARRAY_HT(input)", - "zend_array *result = process_array(input);", + "zend_array *result = go_process_array(input);", "RETURN_ARR(result)", }, }, diff --git a/internal/extgen/srcanalyzer.go b/internal/extgen/srcanalyzer.go index a7f4b1cf4e..32ebff4040 100644 --- a/internal/extgen/srcanalyzer.go +++ b/internal/extgen/srcanalyzer.go @@ -10,33 +10,24 @@ import ( type SourceAnalyzer struct{} -func (sa *SourceAnalyzer) analyze(filename string) (imports []string, variables []string, internalFunctions []string, err error) { +func (sa *SourceAnalyzer) analyze(filename string) (packageName string, variables []string, internalFunctions []string, err error) { fset := token.NewFileSet() node, err := parser.ParseFile(fset, filename, nil, parser.ParseComments) if err != nil { - return nil, nil, nil, fmt.Errorf("parsing file: %w", err) + return "", nil, nil, fmt.Errorf("parsing file: %w", err) } - for _, imp := range node.Imports { - if imp.Path != nil { - importPath := imp.Path.Value - if imp.Name != nil { - imports = append(imports, fmt.Sprintf("%s %s", imp.Name.Name, importPath)) - } else { - imports = append(imports, importPath) - } - } - } + packageName = node.Name.Name sourceContent, err := os.ReadFile(filename) if err != nil { - return nil, nil, nil, fmt.Errorf("reading source file: %w", err) + return "", nil, nil, fmt.Errorf("reading source file: %w", err) } variables = sa.extractVariables(string(sourceContent)) internalFunctions = sa.extractInternalFunctions(string(sourceContent)) - return imports, variables, internalFunctions, nil + return packageName, variables, internalFunctions, nil } func (sa *SourceAnalyzer) extractVariables(content string) []string { diff --git a/internal/extgen/srcanalyzer_test.go b/internal/extgen/srcanalyzer_test.go index 717f99b5f9..74207b1b7c 100644 --- a/internal/extgen/srcanalyzer_test.go +++ b/internal/extgen/srcanalyzer_test.go @@ -227,7 +227,7 @@ func testFunction() { require.NoError(t, os.WriteFile(filename, []byte(tt.sourceContent), 0644)) analyzer := &SourceAnalyzer{} - imports, variables, functions, err := analyzer.analyze(filename) + _, variables, functions, err := analyzer.analyze(filename) if tt.expectError { assert.Error(t, err, "expected error") @@ -236,10 +236,6 @@ func testFunction() { assert.NoError(t, err, "unexpected error") - if len(imports) != 0 && len(tt.expectedImports) != 0 { - assert.Equal(t, tt.expectedImports, imports, "imports mismatch") - } - assert.Equal(t, tt.expectedVariables, variables, "variables mismatch") assert.Len(t, functions, len(tt.expectedFunctions), "function count mismatch") @@ -385,6 +381,110 @@ var x = 10`, } } +func TestSourceAnalyzer_InternalFunctionPreservation(t *testing.T) { + tmpDir := t.TempDir() + + sourceContent := `package main + +import ( + "fmt" + "strings" +) + +//export_php: exported1(): string +func exported1() *go_value { + return String(internal1()) +} + +func internal1() string { + return "helper1" +} + +//export_php: exported2(): void +func exported2() { + internal2() +} + +func internal2() { + fmt.Println("helper2") +} + +func internal3(data string) string { + return strings.ToUpper(data) +}` + + sourceFile := filepath.Join(tmpDir, "test.go") + require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644)) + + analyzer := &SourceAnalyzer{} + packageName, variables, internalFuncs, err := analyzer.analyze(sourceFile) + require.NoError(t, err) + + assert.Equal(t, "main", packageName) + + assert.Len(t, internalFuncs, 3, "Should extract exactly 3 internal functions") + + expectedInternalFuncs := []string{ + `func internal1() string { + return "helper1" +}`, + `func internal2() { + fmt.Println("helper2") +}`, + `func internal3(data string) string { + return strings.ToUpper(data) +}`, + } + + for i, expected := range expectedInternalFuncs { + assert.Equal(t, expected, internalFuncs[i], "Internal function %d should match", i) + } + + assert.Empty(t, variables, "Should not have variables") +} + +func TestSourceAnalyzer_VariableBlockPreservation(t *testing.T) { + tmpDir := t.TempDir() + + sourceContent := `package main + +import ( + "sync" +) + +var ( + mu sync.RWMutex + cache = make(map[string]string) +) + +var globalCounter int = 0 + +//export_php: test(): void +func test() {}` + + sourceFile := filepath.Join(tmpDir, "test.go") + require.NoError(t, os.WriteFile(sourceFile, []byte(sourceContent), 0644)) + + analyzer := &SourceAnalyzer{} + packageName, variables, internalFuncs, err := analyzer.analyze(sourceFile) + require.NoError(t, err) + + assert.Equal(t, "main", packageName) + + assert.Len(t, variables, 2, "Should extract exactly 2 variable declarations") + + expectedVar1 := `var ( + mu sync.RWMutex + cache = make(map[string]string) +)` + expectedVar2 := `var globalCounter int = 0` + + assert.Equal(t, expectedVar1, variables[0], "First variable block should match") + assert.Equal(t, expectedVar2, variables[1], "Second variable declaration should match") + + assert.Empty(t, internalFuncs, "Should not have internal functions (only exported function)") +} + func BenchmarkSourceAnalyzer_Analyze(b *testing.B) { content := `package main diff --git a/internal/extgen/templates/extension.c.tpl b/internal/extgen/templates/extension.c.tpl index f511a1baf0..87b67475bb 100644 --- a/internal/extgen/templates/extension.c.tpl +++ b/internal/extgen/templates/extension.c.tpl @@ -1,3 +1,12 @@ +// AUTOGENERATED FILE - DO NOT EDIT. +// +// This file has been automatically generated by FrankenPHP extension generator +// and should not be edited as it will be overwritten when running the +// extension generator again. +// +// You may edit the file and remove this comment if you plan to manually maintain +// this file going forward. + #include #include #include diff --git a/internal/extgen/templates/extension.go.tpl b/internal/extgen/templates/extension.go.tpl index 24b665700e..313ec46f3e 100644 --- a/internal/extgen/templates/extension.go.tpl +++ b/internal/extgen/templates/extension.go.tpl @@ -1,43 +1,32 @@ package {{.PackageName}} +// AUTOGENERATED FILE - DO NOT EDIT. +// +// This file has been automatically generated by FrankenPHP extension generator +// and should not be edited as it will be overwritten when running the +// extension generator again. +// +// You may edit the file and remove this comment if you plan to manually maintain +// this file going forward. + // #include // #include "{{.BaseName}}.h" import "C" import ( + {{if not .Classes}}_ {{end}}"runtime/cgo" "unsafe" "github.com/dunglas/frankenphp" -{{- range .Imports}} - {{.}} -{{- end}} ) func init() { - frankenphp.RegisterExtension(unsafe.Pointer(&C.{{.BaseName}}_module_entry)) + frankenphp.RegisterExtension(unsafe.Pointer(&C.{{.SanitizedBaseName}}_module_entry)) } -{{ range .Constants}} -const {{.Name}} = {{.Value}} - -{{- end}} -{{- range .Variables}} - -{{.}} -{{- end}} -{{- range .InternalFunctions}} -{{.}} - -{{- end}} {{- range .Functions}} -//export {{.Name}} -{{.GoFunction}} - -{{- end}} -{{- range .Classes}} -type {{.GoStruct}} struct { -{{- range .Properties}} - {{.Name}} {{.GoType}} -{{- end}} +//export go_{{.Name}} +func go_{{.Name}}({{extractGoFunctionSignatureParams .GoFunction}}) {{extractGoFunctionSignatureReturn .GoFunction}} { + {{if not (isVoid .ReturnType)}}return {{end}}{{extractGoFunctionName .GoFunction}}({{extractGoFunctionCallParams .GoFunction}}) } {{- end}} @@ -68,12 +57,6 @@ func create_{{.GoStruct}}_object() C.uintptr_t { return registerGoObject(obj) } -{{- range .Methods}} -{{- if .GoFunction}} -{{.GoFunction}} -{{- end}} - -{{- end}} {{- range .Methods}} //export {{.Name}}_wrapper func {{.Name}}_wrapper(handle C.uintptr_t{{range .Params}}{{if eq .PhpType "string"}}, {{.Name}} *C.zend_string{{else if eq .PhpType "array"}}, {{.Name}} *C.zval{{else if eq .PhpType "callable"}}, {{.Name}} *C.zval{{else}}, {{.Name}} {{if .IsNullable}}*{{end}}{{phpTypeToGoType .PhpType}}{{end}}{{end}}){{if not (isVoid .ReturnType)}}{{if isStringOrArray .ReturnType}} unsafe.Pointer{{else}} {{phpTypeToGoType .ReturnType}}{{end}}{{end}} { diff --git a/internal/extgen/templates/extension.h.tpl b/internal/extgen/templates/extension.h.tpl index 2607185222..eca5b92508 100644 --- a/internal/extgen/templates/extension.h.tpl +++ b/internal/extgen/templates/extension.h.tpl @@ -1,3 +1,12 @@ +// AUTOGENERATED FILE - DO NOT EDIT. +// +// This file has been automatically generated by FrankenPHP extension generator +// and should not be edited as it will be overwritten when running the +// extension generator again. +// +// You may edit the file and remove this comment if you plan to manually maintain +// this file going forward. + #ifndef _{{.HeaderGuard}} #define _{{.HeaderGuard}} diff --git a/internal/extgen/templates/stub.php.tpl b/internal/extgen/templates/stub.php.tpl index cd16115d99..0fe0637c19 100644 --- a/internal/extgen/templates/stub.php.tpl +++ b/internal/extgen/templates/stub.php.tpl @@ -1,6 +1,15 @@ /dev/null; then + deluser frankenphp +fi + +if getent group frankenphp >/dev/null; then + delgroup frankenphp +fi + +rmdir /var/lib/frankenphp 2>/dev/null || true + +exit 0 diff --git a/package/alpine/post-install.sh b/package/alpine/post-install.sh new file mode 100755 index 0000000000..03bd879209 --- /dev/null +++ b/package/alpine/post-install.sh @@ -0,0 +1,43 @@ +#!/bin/sh + +if ! getent group frankenphp >/dev/null; then + addgroup -S frankenphp +fi + +if ! getent passwd frankenphp >/dev/null; then + adduser -S -h /var/lib/frankenphp -s /sbin/nologin -G frankenphp -g "FrankenPHP web server" frankenphp +fi + +chown -R frankenphp:frankenphp /var/lib/frankenphp +chmod 755 /var/lib/frankenphp + +# allow binding to privileged ports +if command -v setcap >/dev/null 2>&1; then + setcap cap_net_bind_service=+ep /usr/bin/frankenphp || true +fi + +# check if 0.0.0.0:2019 or 127.0.0.1:2019 are in use +port_in_use() { + port_hex=$(printf '%04X' "$1") + grep -qE "(00000000|0100007F):${port_hex}" /proc/net/tcp 2>/dev/null +} + +# trust frankenphp certificates if the admin api can start +if [ -x /usr/bin/frankenphp ]; then + if ! port_in_use 2019; then + HOME=/var/lib/frankenphp /usr/bin/frankenphp run >/dev/null 2>&1 & + FRANKENPHP_PID=$! + sleep 2 + HOME=/var/lib/frankenphp /usr/bin/frankenphp trust || true + kill -TERM $FRANKENPHP_PID 2>/dev/null || true + + chown -R frankenphp:frankenphp /var/lib/frankenphp + fi +fi + +if command -v rc-update >/dev/null 2>&1; then + rc-update add frankenphp default + rc-service frankenphp start +fi + +exit 0 diff --git a/package/alpine/pre-deinstall.sh b/package/alpine/pre-deinstall.sh new file mode 100755 index 0000000000..59713ea01c --- /dev/null +++ b/package/alpine/pre-deinstall.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +if command -v rc-service >/dev/null 2>&1; then + rc-service frankenphp stop || true +fi + +if command -v rc-update >/dev/null 2>&1; then + rc-update del frankenphp default || true +fi + +exit 0 diff --git a/package/debian/frankenphp.service b/package/debian/frankenphp.service index ead2f7da6f..f74303de1a 100644 --- a/package/debian/frankenphp.service +++ b/package/debian/frankenphp.service @@ -1,5 +1,5 @@ [Unit] -Description=FrankenPHP +Description=FrankenPHP - The modern PHP app server Documentation=https://frankenphp.dev/docs/ After=network.target network-online.target Requires=network-online.target @@ -8,12 +8,17 @@ Requires=network-online.target Type=notify User=frankenphp Group=frankenphp +ExecStartPre=/usr/bin/frankenphp validate --config /etc/frankenphp/Caddyfile ExecStart=/usr/bin/frankenphp run --environ --config /etc/frankenphp/Caddyfile -ExecReload=/usr/bin/frankenphp reload --config /etc/frankenphp/Caddyfile --force +ExecReload=/usr/bin/frankenphp reload --config /etc/frankenphp/Caddyfile +WorkingDirectory=/var/lib/frankenphp +Restart=on-failure +RestartSec=3s TimeoutStopSec=5s LimitNOFILE=1048576 LimitNPROC=512 PrivateTmp=true +ProtectHome=true ProtectSystem=full AmbientCapabilities=CAP_NET_BIND_SERVICE diff --git a/package/debian/postinst.sh b/package/debian/postinst.sh index 97c6f26482..e6c7a154a6 100755 --- a/package/debian/postinst.sh +++ b/package/debian/postinst.sh @@ -19,17 +19,29 @@ if [ "$1" = "configure" ]; then usermod -aG www-data frankenphp fi + # trust frankenphp certificates before starting the systemd service + if [ -z "$2" ] && [ -x /usr/bin/frankenphp ]; then + HOME=/var/lib/frankenphp /usr/bin/frankenphp run --config /dev/null & + FRANKENPHP_PID=$! + sleep 2 + HOME=/var/lib/frankenphp /usr/bin/frankenphp trust || true + kill "$FRANKENPHP_PID" || true + wait "$FRANKENPHP_PID" 2>/dev/null || true + + chown -R frankenphp:frankenphp /var/lib/frankenphp + fi + # Handle cases where package was installed and then purged; # user and group will still exist but with no home dir if [ ! -d /var/lib/frankenphp ]; then mkdir -p /var/lib/frankenphp - chown frankenphp:frankenphp /var/lib/frankenphp + chown -R frankenphp:frankenphp /var/lib/frankenphp fi # Add log directory with correct permissions if [ ! -d /var/log/frankenphp ]; then mkdir -p /var/log/frankenphp - chown frankenphp:frankenphp /var/log/frankenphp + chown -R frankenphp:frankenphp /var/log/frankenphp fi fi @@ -61,11 +73,3 @@ fi if command -v setcap >/dev/null 2>&1; then setcap cap_net_bind_service=+ep /usr/bin/frankenphp || true fi - -if [ -x /usr/bin/frankenphp ]; then - HOME=/var/lib/frankenphp /usr/bin/frankenphp run --config /dev/null & - FRANKENPHP_PID=$! - HOME=/var/lib/frankenphp /usr/bin/frankenphp trust || true - kill "$FRANKENPHP_PID" || true - wait "$FRANKENPHP_PID" 2>/dev/null || true -fi diff --git a/package/rhel/frankenphp.service b/package/rhel/frankenphp.service index 40311d6a89..f74303de1a 100644 --- a/package/rhel/frankenphp.service +++ b/package/rhel/frankenphp.service @@ -1,6 +1,8 @@ [Unit] -Description=FrankenPHP server -After=network.target +Description=FrankenPHP - The modern PHP app server +Documentation=https://frankenphp.dev/docs/ +After=network.target network-online.target +Requires=network-online.target [Service] Type=notify @@ -10,6 +12,8 @@ ExecStartPre=/usr/bin/frankenphp validate --config /etc/frankenphp/Caddyfile ExecStart=/usr/bin/frankenphp run --environ --config /etc/frankenphp/Caddyfile ExecReload=/usr/bin/frankenphp reload --config /etc/frankenphp/Caddyfile WorkingDirectory=/var/lib/frankenphp +Restart=on-failure +RestartSec=3s TimeoutStopSec=5s LimitNOFILE=1048576 LimitNPROC=512 diff --git a/package/rhel/postinstall.sh b/package/rhel/postinstall.sh index 5905e29599..551523a8fd 100755 --- a/package/rhel/postinstall.sh +++ b/package/rhel/postinstall.sh @@ -32,10 +32,22 @@ if command -v setcap >/dev/null 2>&1; then setcap cap_net_bind_service=+ep /usr/bin/frankenphp || : fi -if [ -x /usr/bin/frankenphp ]; then - HOME=/var/lib/frankenphp /usr/bin/frankenphp run --config /dev/null & - FRANKENPHP_PID=$! - HOME=/var/lib/frankenphp /usr/bin/frankenphp trust || : - kill "$FRANKENPHP_PID" || : - wait "$FRANKENPHP_PID" 2>/dev/null || : +# check if 0.0.0.0:2019 or 127.0.0.1:2019 are in use +port_in_use() { + port_hex=$(printf '%04X' "$1") + grep -qE "(00000000|0100007F):${port_hex}" /proc/net/tcp 2>/dev/null +} + +# trust frankenphp certificates if the admin api can start +if [ "$1" -eq 1 ] && [ -x /usr/bin/frankenphp ]; then + if ! port_in_use 2019; then + HOME=/var/lib/frankenphp /usr/bin/frankenphp run --config /dev/null & + FRANKENPHP_PID=$! + sleep 2 + HOME=/var/lib/frankenphp /usr/bin/frankenphp trust || : + kill "$FRANKENPHP_PID" || : + wait "$FRANKENPHP_PID" 2>/dev/null || : + + chown -R frankenphp:frankenphp /var/lib/frankenphp + fi fi diff --git a/phpmainthread.go b/phpmainthread.go index cecadc1653..1ba7dc3d44 100644 --- a/phpmainthread.go +++ b/phpmainthread.go @@ -79,6 +79,9 @@ func initPHPThreads(numThreads int, numMaxThreads int, phpIni map[string]string) } func drainPHPThreads() { + if mainThread == nil { + return // mainThread was never initialized + } doneWG := sync.WaitGroup{} doneWG.Add(len(phpThreads)) mainThread.state.Set(state.ShuttingDown) @@ -195,6 +198,9 @@ func go_get_custom_php_ini(disableTimeouts C.bool) *C.char { // Pass the php.ini overrides to PHP before startup // TODO: if needed this would also be possible on a per-thread basis var overrides strings.Builder + + // 32 is an over-estimate for php.ini settings + overrides.Grow(len(mainThread.phpIni) * 32) for k, v := range mainThread.phpIni { overrides.WriteString(k) overrides.WriteByte('=') diff --git a/phpmainthread_test.go b/phpmainthread_test.go index 7e6bf32c1e..337fbe17f1 100644 --- a/phpmainthread_test.go +++ b/phpmainthread_test.go @@ -96,7 +96,7 @@ func TestTransitionThreadsWhileDoingRequests(t *testing.T) { var ( isDone atomic.Bool - wg sync.WaitGroup + wg sync.WaitGroup ) numThreads := 10 @@ -185,8 +185,12 @@ func TestFinishBootingAWorkerScript(t *testing.T) { func TestReturnAnErrorIf2WorkersHaveTheSameFileName(t *testing.T) { workers = []*worker{} + workersByName = map[string]*worker{} + workersByPath = map[string]*worker{} w, err1 := newWorker(workerOpt{fileName: testDataPath + "/index.php"}) workers = append(workers, w) + workersByName[w.name] = w + workersByPath[w.fileName] = w _, err2 := newWorker(workerOpt{fileName: testDataPath + "/index.php"}) assert.NoError(t, err1) @@ -195,8 +199,12 @@ func TestReturnAnErrorIf2WorkersHaveTheSameFileName(t *testing.T) { func TestReturnAnErrorIf2ModuleWorkersHaveTheSameName(t *testing.T) { workers = []*worker{} + workersByName = map[string]*worker{} + workersByPath = map[string]*worker{} w, err1 := newWorker(workerOpt{fileName: testDataPath + "/index.php", name: "workername"}) workers = append(workers, w) + workersByName[w.name] = w + workersByPath[w.fileName] = w _, err2 := newWorker(workerOpt{fileName: testDataPath + "/hello.php", name: "workername"}) assert.NoError(t, err1) @@ -242,9 +250,9 @@ func allPossibleTransitions(worker1Path string, worker2Path string) []func(*phpT thread.boot() } }, - func(thread *phpThread) { convertToWorkerThread(thread, getWorkerByPath(worker1Path)) }, + func(thread *phpThread) { convertToWorkerThread(thread, workersByPath[worker1Path]) }, convertToInactiveThread, - func(thread *phpThread) { convertToWorkerThread(thread, getWorkerByPath(worker2Path)) }, + func(thread *phpThread) { convertToWorkerThread(thread, workersByPath[worker2Path]) }, convertToInactiveThread, } } diff --git a/phpthread.go b/phpthread.go index 1726cf9d18..90007cbe17 100644 --- a/phpthread.go +++ b/phpthread.go @@ -19,13 +19,13 @@ type phpThread struct { threadIndex int requestChan chan contextHolder drainChan chan struct{} - handlerMu sync.Mutex + handlerMu sync.RWMutex handler threadHandler state *state.ThreadState sandboxedEnv map[string]*C.zend_string } -// interface that defines how the callbacks from the C thread should be handled +// threadHandler defines how the callbacks from the C thread should be handled type threadHandler interface { name() string beforeScriptExecution() string @@ -66,9 +66,12 @@ func (thread *phpThread) boot() { // shutdown the underlying PHP thread func (thread *phpThread) shutdown() { if !thread.state.RequestSafeStateChange(state.ShuttingDown) { - // already shutting down or done + // already shutting down or done, wait for the C thread to finish + thread.state.WaitFor(state.Done, state.Reserved) + return } + close(thread.drainChan) thread.state.WaitFor(state.Done) thread.drainChan = make(chan struct{}) @@ -79,17 +82,19 @@ func (thread *phpThread) shutdown() { } } -// change the thread handler safely +// setHandler changes the thread handler safely // must be called from outside the PHP thread func (thread *phpThread) setHandler(handler threadHandler) { thread.handlerMu.Lock() defer thread.handlerMu.Unlock() + if !thread.state.RequestSafeStateChange(state.TransitionRequested) { // no state change allowed == shutdown or done return } close(thread.drainChan) + thread.state.WaitFor(state.TransitionInProgress) thread.handler = handler thread.drainChan = make(chan struct{}) @@ -120,9 +125,10 @@ func (thread *phpThread) context() context.Context { } func (thread *phpThread) name() string { - thread.handlerMu.Lock() + thread.handlerMu.RLock() name := thread.handler.name() - thread.handlerMu.Unlock() + thread.handlerMu.RUnlock() + return name } @@ -133,6 +139,7 @@ func (thread *phpThread) pinString(s string) *C.char { if sData == nil { return nil } + thread.Pin(sData) return (*C.char)(unsafe.Pointer(sData)) diff --git a/request_options.go b/requestoptions.go similarity index 84% rename from request_options.go rename to requestoptions.go index 2227b06eed..42cc3cf7c0 100644 --- a/request_options.go +++ b/requestoptions.go @@ -1,11 +1,14 @@ package frankenphp import ( + "errors" "log/slog" "net/http" "path/filepath" + "strings" "sync" "sync/atomic" + "unicode/utf8" "github.com/dunglas/frankenphp/internal/fastabs" ) @@ -14,6 +17,8 @@ import ( type RequestOption func(h *frankenPHPContext) error var ( + ErrInvalidSplitPath = errors.New("split path contains non-ASCII characters") + documentRootCache sync.Map documentRootCacheLen atomic.Uint32 ) @@ -71,15 +76,40 @@ func WithRequestResolvedDocumentRoot(documentRoot string) RequestOption { // actual resource (CGI script) name, and the second piece will be set to // PATH_INFO for the CGI script to use. // +// Split paths can only contain ASCII characters. +// Comparison is case-insensitive. +// // Future enhancements should be careful to avoid CVE-2019-11043, // which can be mitigated with use of a try_files-like behavior // that 404s if the FastCGI path info is not found. -func WithRequestSplitPath(splitPath []string) RequestOption { +func WithRequestSplitPath(splitPath []string) (RequestOption, error) { + var b strings.Builder + + for i, split := range splitPath { + b.Grow(len(split)) + + for j := 0; j < len(split); j++ { + c := split[j] + if c >= utf8.RuneSelf { + return nil, ErrInvalidSplitPath + } + + if 'A' <= c && c <= 'Z' { + b.WriteByte(c + 'a' - 'A') + } else { + b.WriteByte(c) + } + } + + splitPath[i] = b.String() + b.Reset() + } + return func(o *frankenPHPContext) error { o.splitPath = splitPath return nil - } + }, nil } type PreparedEnv = map[string]string @@ -128,7 +158,7 @@ func WithRequestLogger(logger *slog.Logger) RequestOption { func WithWorkerName(name string) RequestOption { return func(o *frankenPHPContext) error { if name != "" { - o.worker = getWorkerByName(name) + o.worker = workersByName[name] } return nil diff --git a/requestoptions_test.go b/requestoptions_test.go new file mode 100644 index 0000000000..b98e8bfeff --- /dev/null +++ b/requestoptions_test.go @@ -0,0 +1,73 @@ +package frankenphp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWithRequestSplitPath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + splitPath []string + wantErr error + wantSplitPath []string + }{ + { + name: "valid lowercase split path", + splitPath: []string{".php"}, + wantErr: nil, + wantSplitPath: []string{".php"}, + }, + { + name: "valid uppercase split path normalized", + splitPath: []string{".PHP"}, + wantErr: nil, + wantSplitPath: []string{".php"}, + }, + { + name: "valid mixed case split path normalized", + splitPath: []string{".PhP", ".PHTML"}, + wantErr: nil, + wantSplitPath: []string{".php", ".phtml"}, + }, + { + name: "empty split path", + splitPath: []string{}, + wantErr: nil, + wantSplitPath: []string{}, + }, + { + name: "non-ASCII character in split path rejected", + splitPath: []string{".php", ".Ⱥphp"}, + wantErr: ErrInvalidSplitPath, + }, + { + name: "unicode character in split path rejected", + splitPath: []string{".phpⱥ"}, + wantErr: ErrInvalidSplitPath, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := &frankenPHPContext{} + opt, err := WithRequestSplitPath(tt.splitPath) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + require.NoError(t, opt(ctx)) + assert.Equal(t, tt.wantSplitPath, ctx.splitPath) + }) + } +} diff --git a/scaling.go b/scaling.go index 37e081abb9..5ac07fe73b 100644 --- a/scaling.go +++ b/scaling.go @@ -84,6 +84,11 @@ func addWorkerThread(worker *worker) (*phpThread, error) { // scaleWorkerThread adds a worker PHP thread automatically func scaleWorkerThread(worker *worker) { + // probe CPU usage before acquiring the lock (avoids holding lock during 120ms sleep) + if !cpu.ProbeCPUs(cpuProbeTime, maxCpuUsageForScaling, mainThread.done) { + return + } + scalingMu.Lock() defer scalingMu.Unlock() @@ -91,11 +96,6 @@ func scaleWorkerThread(worker *worker) { return } - // probe CPU usage before scaling - if !cpu.ProbeCPUs(cpuProbeTime, maxCpuUsageForScaling, mainThread.done) { - return - } - thread, err := addWorkerThread(worker) if err != nil { if globalLogger.Enabled(globalCtx, slog.LevelWarn) { @@ -114,6 +114,11 @@ func scaleWorkerThread(worker *worker) { // scaleRegularThread adds a regular PHP thread automatically func scaleRegularThread() { + // probe CPU usage before acquiring the lock (avoids holding lock during 120ms sleep) + if !cpu.ProbeCPUs(cpuProbeTime, maxCpuUsageForScaling, mainThread.done) { + return + } + scalingMu.Lock() defer scalingMu.Unlock() @@ -121,11 +126,6 @@ func scaleRegularThread() { return } - // probe CPU usage before scaling - if !cpu.ProbeCPUs(cpuProbeTime, maxCpuUsageForScaling, mainThread.done) { - return - } - thread, err := addRegularThread() if err != nil { if globalLogger.Enabled(globalCtx, slog.LevelWarn) { diff --git a/scaling_test.go b/scaling_test.go index f7ecc05e05..5092a0178e 100644 --- a/scaling_test.go +++ b/scaling_test.go @@ -47,7 +47,7 @@ func TestScaleAWorkerThreadUpAndDown(t *testing.T) { autoScaledThread := phpThreads[2] // scale up - scaleWorkerThread(getWorkerByPath(workerPath)) + scaleWorkerThread(workersByPath[workerPath]) assert.Equal(t, state.Ready, autoScaledThread.state.Get()) // on down-scale, the thread will be marked as inactive diff --git a/testdata/ini-leak.php b/testdata/ini-leak.php new file mode 100644 index 0000000000..286f56aa9d --- /dev/null +++ b/testdata/ini-leak.php @@ -0,0 +1,67 @@ +getMessage(); + } + + restore_error_handler(); + + // Now output everything + $output[] = "save_handler_before=" . $saveHandlerBefore; + $output[] = "SESSION_START_RESULT=" . ($result ? "true" : "false"); + if ($error) { + $output[] = "ERROR:" . $error; + } + if ($exception) { + $output[] = "EXCEPTION:" . $exception; + } + break; + + case 'just_start': + // Simple session start without any custom handler + // This should always work + session_id('test-session-id-3'); + session_start(); + $_SESSION['test'] = 'value'; + session_write_close(); + $output[] = "SESSION_STARTED_OK"; + break; + + default: + $output[] = "UNKNOWN_ACTION"; + } + + echo implode("\n", $output); +}; diff --git a/testdata/session-leak.php b/testdata/session-leak.php new file mode 100644 index 0000000000..d28292fae1 --- /dev/null +++ b/testdata/session-leak.php @@ -0,0 +1,62 @@ +getMessage(); + } + + restore_error_handler(); + + if ($error) { + $output[] = "ERROR:" . $error; + } + break; + + case 'check': + default: + $saveHandler = ini_get('session.save_handler'); + $output[] = "save_handler=$saveHandler"; + if ($saveHandler === 'user') { + $output[] = "HANDLER_PRESERVED"; + } else { + $output[] = "HANDLER_LOST"; + } + } + + echo implode("\n", $output); + }); +} while ($ok); diff --git a/threadworker.go b/threadworker.go index ae7e4545f2..e309340a7b 100644 --- a/threadworker.go +++ b/threadworker.go @@ -101,10 +101,6 @@ func (handler *workerThread) name() string { func setupWorkerScript(handler *workerThread, worker *worker) { metrics.StartWorker(worker.name) - if handler.state.Is(state.Ready) { - metrics.ReadyWorker(handler.worker.name) - } - // Create a dummy request to set up the worker fc, err := newDummyContext( filepath.Base(worker.fileName), @@ -152,7 +148,11 @@ func tearDownWorkerScript(handler *workerThread, exitStatus int) { } // worker has thrown a fatal error or has not reached frankenphp_handle_request - metrics.StopWorker(worker.name, StopReasonCrash) + if handler.isBootingScript { + metrics.StopWorker(worker.name, StopReasonBootFailure) + } else { + metrics.StopWorker(worker.name, StopReasonCrash) + } if !handler.isBootingScript { // fatal error (could be due to exit(1), timeouts, etc.) @@ -207,13 +207,12 @@ func (handler *workerThread) waitForWorkerRequest() (bool, any) { if !C.frankenphp_shutdown_dummy_request() { panic("Not in CGI context") } + + // worker is truly ready only after reaching frankenphp_handle_request() + metrics.ReadyWorker(handler.worker.name) } - // worker threads are 'ready' after they first reach frankenphp_handle_request() - // 'state.TransitionComplete' is only true on the first boot of the worker script, - // while 'isBootingScript' is true on every boot of the worker script if handler.state.Is(state.TransitionComplete) { - metrics.ReadyWorker(handler.worker.name) handler.state.Set(state.Ready) } diff --git a/types.go b/types.go index 17a2d55060..30764ecba5 100644 --- a/types.go +++ b/types.go @@ -257,7 +257,7 @@ func PHPPackedArray[T any](slice []T) unsafe.Pointer { // EXPERIMENTAL: GoValue converts a PHP zval to a Go value // // Zval having the null, bool, long, double, string and array types are currently supported. -// Arrays can curently only be converted to any[] and AssociativeArray[any]. +// Arrays can currently only be converted to any[] and AssociativeArray[any]. // Any other type will cause an error. // More types may be supported in the future. func GoValue[T any](zval unsafe.Pointer) (T, error) { diff --git a/watcher.go b/watcher.go index eb2b09e29f..cfe133e5ab 100644 --- a/watcher.go +++ b/watcher.go @@ -10,7 +10,7 @@ import ( ) type hotReloadOpt struct { - hotReload []*watcher.PatternGroup + hotReload []*watcher.PatternGroup } var restartWorkers atomic.Bool diff --git a/worker.go b/worker.go index e2c5453638..d87848bad2 100644 --- a/worker.go +++ b/worker.go @@ -37,6 +37,8 @@ type worker struct { var ( workers []*worker + workersByName map[string]*worker + workersByPath map[string]*worker watcherIsEnabled bool startupFailChan chan error ) @@ -52,6 +54,8 @@ func initWorkers(opt []workerOpt) error { ) workers = make([]*worker, 0, len(opt)) + workersByName = make(map[string]*worker, len(opt)) + workersByPath = make(map[string]*worker, len(opt)) for _, o := range opt { w, err := newWorker(o) @@ -61,6 +65,10 @@ func initWorkers(opt []workerOpt) error { totalThreadsToStart += w.num workers = append(workers, w) + workersByName[w.name] = w + if w.allowPathMatching { + workersByPath[w.fileName] = w + } } startupFailChan = make(chan error, totalThreadsToStart) @@ -90,28 +98,16 @@ func initWorkers(opt []workerOpt) error { return nil } -func getWorkerByName(name string) *worker { - for _, w := range workers { - if w.name == name { - return w - } - } - - return nil -} - -func getWorkerByPath(path string) *worker { - for _, w := range workers { - if w.fileName == path && w.allowPathMatching { - return w - } +func newWorker(o workerOpt) (*worker, error) { + // Order is important! + // This order ensures that FrankenPHP started from inside a symlinked directory will properly resolve any paths. + // If it is started from outside a symlinked directory, it is resolved to the same path that we use in the Caddy module. + absFileName, err := filepath.EvalSymlinks(o.fileName) + if err != nil { + return nil, fmt.Errorf("worker filename is invalid %q: %w", o.fileName, err) } - return nil -} - -func newWorker(o workerOpt) (*worker, error) { - absFileName, err := fastabs.FastAbs(o.fileName) + absFileName, err = fastabs.FastAbs(absFileName) if err != nil { return nil, fmt.Errorf("worker filename is invalid %q: %w", o.fileName, err) } @@ -128,10 +124,10 @@ func newWorker(o workerOpt) (*worker, error) { // they can only be matched by their name, not by their path allowPathMatching := !strings.HasPrefix(o.name, "m#") - if w := getWorkerByPath(absFileName); w != nil && allowPathMatching { + if w := workersByPath[absFileName]; w != nil && allowPathMatching { return w, fmt.Errorf("two workers cannot have the same filename: %q", absFileName) } - if w := getWorkerByName(o.name); w != nil { + if w := workersByName[o.name]; w != nil { return w, fmt.Errorf("two workers cannot have the same name: %q", o.name) } diff --git a/worker_test.go b/worker_test.go index 37ba192068..75b82426f0 100644 --- a/worker_test.go +++ b/worker_test.go @@ -1,6 +1,7 @@ package frankenphp_test import ( + "context" "fmt" "io" "log" @@ -157,3 +158,19 @@ func TestWorkerHasOSEnvironmentVariableInSERVER(t *testing.T) { assert.Contains(t, string(body), "custom_env_variable_value") }, &testOptions{workerScript: "worker.php", nbWorkers: 1, nbParallelRequests: 1}) } + +func TestKeepRunningOnConnectionAbort(t *testing.T) { + runTest(t, func(handler func(http.ResponseWriter, *http.Request), _ *httptest.Server, i int) { + req := httptest.NewRequest("GET", "http://example.com/worker-with-counter.php", nil) + + ctx, cancel := context.WithCancel(req.Context()) + req = req.WithContext(ctx) + cancel() + body1, _ := testRequest(req, handler, t) + + assert.Equal(t, "requests:1", body1, "should have handled exactly one request") + body2, _ := testGet("http://example.com/worker-with-counter.php", handler, t) + + assert.Equal(t, "requests:2", body2, "should not have stopped execution after the first request was aborted") + }, &testOptions{workerScript: "worker-with-counter.php", nbWorkers: 1, nbParallelRequests: 1}) +}