diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 6f13031b9..2b72b95b6 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -1,15 +1,14 @@ # Security Policy -The Caddy project would like to make sure that it stays on top of all practically-exploitable vulnerabilities. +The Caddy project would like to make sure that it stays on top of all relevant and practically-exploitable vulnerabilities. ## Supported Versions -| Version | Supported | -| -------- | ----------| -| 2.latest | ✔️ | -| 1.x | :x: | -| < 1.x | :x: | +| Version | Supported | +| ----------- | ----------| +| 2.latest | ✔️ | +| <= 2.latest | :x: | ## Acceptable Scope @@ -18,7 +17,7 @@ A security report must demonstrate a security bug in the source code from this r Some security problems are the result of interplay between different components of the Web, rather than a vulnerability in the web server itself. Please only report vulnerabilities in the web server itself, as we cannot coerce the rest of the Web to be fixed (for example, we do not consider IP spoofing, BGP hijacks, or missing/misconfigured HTTP headers a vulnerability in the Caddy web server). -Vulnerabilities caused by misconfigurations are out of scope. Yes, it is entirely possible to craft and use a configuration that is unsafe, just like with every other web server; we recommend against doing that. +Vulnerabilities caused by misconfigurations are out of scope. Yes, it is entirely possible to craft and use a configuration that is unsafe, just like with every other web server; we recommend against doing that. Similarly, external misconfigurations are out of scope. For example, an open or forwarded port from a public network to a Caddy instance intended to serve only internal clients is not a vulnerability in Caddy. We do not accept reports if the steps imply or require a compromised system or third-party software, as we cannot control those. We expect that users secure their own systems and keep all their software patched. For example, if untrusted users are able to upload/write/host arbitrary files in the web root directory, it is NOT a security bug in Caddy if those files get served to clients; however, it _would_ be a valid report if a bug in Caddy's source code unintentionally gave unauthorized users the ability to upload unsafe files or delete files without relying on an unpatched system or piece of software. @@ -26,6 +25,8 @@ Client-side exploits are out of scope. In other words, it is not a bug in Caddy Security bugs in code dependencies (including Go's standard library) are out of scope. Instead, if a dependency has patched a relevant security bug, please feel free to open a public issue or pull request to update that dependency in our code. +We accept security reports and patches, but do not assign CVEs, for code that has not been released with a non-prerelease tag. + ## Reporting a Vulnerability @@ -33,6 +34,8 @@ We get a lot of difficult reports that turn out to be invalid. Clear, obvious re First please ensure your report falls within the accepted scope of security bugs (above). +:warning: **YOU MUST DISCLOSE WHETHER YOU USED LLMs ("AI") IN ANY WAY.** Whether you are using AI for discovery, as part of writing the report or its replies, and/or testing or validating proofs and changes, we require you to mention the extent of it. **FAILURE TO INCLUDE A DISCLOSURE EVEN IF YOU DO NOT USE AI MAY LEAD TO IMMEDIATE DISMISSAL OF YOUR REPORT AND POTENTIAL BLOCKLISTING.** We will not waste our time chatting with bots. But if you're a human, pull up a chair and we'll drink some chocolate milk. + We'll need enough information to verify the bug and make a patch. To speed things up, please include: - Most minimal possible config (without redactions!) diff --git a/.github/workflows/ai.yml b/.github/workflows/ai.yml index 0008febba..458f8d537 100644 --- a/.github/workflows/ai.yml +++ b/.github/workflows/ai.yml @@ -16,8 +16,8 @@ jobs: models: read contents: read steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - - uses: github/ai-moderator@6bcdb2a79c2e564db8d76d7d4439d91a044c4eb6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: github/ai-moderator@81159c370785e295c97461ade67d7c33576e9319 with: token: ${{ secrets.GITHUB_TOKEN }} spam-label: 'spam' diff --git a/.github/workflows/auto-release-pr.yml b/.github/workflows/auto-release-pr.yml new file mode 100644 index 000000000..c8440d32c --- /dev/null +++ b/.github/workflows/auto-release-pr.yml @@ -0,0 +1,221 @@ +name: Release Proposal Approval Tracker + +on: + pull_request_review: + types: [submitted, dismissed] + pull_request: + types: [labeled, unlabeled, synchronize, closed] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + check-approvals: + name: Track Maintainer Approvals + runs-on: ubuntu-latest + # Only run on PRs with release-proposal label + if: contains(github.event.pull_request.labels.*.name, 'release-proposal') && github.event.pull_request.state == 'open' + + steps: + - name: Check approvals and update PR + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + MAINTAINER_LOGINS: ${{ secrets.MAINTAINER_LOGINS }} + with: + script: | + const pr = context.payload.pull_request; + + // Extract version from PR title (e.g., "Release Proposal: v1.2.3") + const versionMatch = pr.title.match(/Release Proposal:\s*(v[\d.]+(?:-[\w.]+)?)/); + const commitMatch = pr.body.match(/\*\*Target Commit:\*\*\s*`([a-f0-9]+)`/); + + if (!versionMatch || !commitMatch) { + console.log('Could not extract version from title or commit from body'); + return; + } + + const version = versionMatch[1]; + const targetCommit = commitMatch[1]; + + console.log(`Version: ${version}, Target Commit: ${targetCommit}`); + + // Get all reviews + const reviews = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number + }); + + // Get list of maintainers + const maintainerLoginsRaw = process.env.MAINTAINER_LOGINS || ''; + const maintainerLogins = maintainerLoginsRaw + .split(/[,;]/) + .map(login => login.trim()) + .filter(login => login.length > 0); + + console.log(`Maintainer logins: ${maintainerLogins.join(', ')}`); + + // Get the latest review from each user + const latestReviewsByUser = {}; + reviews.data.forEach(review => { + const username = review.user.login; + if (!latestReviewsByUser[username] || new Date(review.submitted_at) > new Date(latestReviewsByUser[username].submitted_at)) { + latestReviewsByUser[username] = review; + } + }); + + // Count approvals from maintainers + const maintainerApprovals = Object.entries(latestReviewsByUser) + .filter(([username, review]) => + maintainerLogins.includes(username) && + review.state === 'APPROVED' + ) + .map(([username, review]) => username); + + const approvalCount = maintainerApprovals.length; + console.log(`Found ${approvalCount} maintainer approvals from: ${maintainerApprovals.join(', ')}`); + + // Get current labels + const currentLabels = pr.labels.map(label => label.name); + const hasApprovedLabel = currentLabels.includes('approved'); + const hasAwaitingApprovalLabel = currentLabels.includes('awaiting-approval'); + + if (approvalCount >= 2 && !hasApprovedLabel) { + console.log('✅ Quorum reached! Updating PR...'); + + // Remove awaiting-approval label if present + if (hasAwaitingApprovalLabel) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name: 'awaiting-approval' + }).catch(e => console.log('Label not found:', e.message)); + } + + // Add approved label + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: ['approved'] + }); + + // Add comment with tagging instructions + const approversList = maintainerApprovals.map(u => `@${u}`).join(', '); + const commentBody = [ + '## ✅ Approval Quorum Reached', + '', + `This release proposal has been approved by ${approvalCount} maintainers: ${approversList}`, + '', + '### Tagging Instructions', + '', + 'A maintainer should now create and push the signed tag:', + '', + '```bash', + `git checkout ${targetCommit}`, + `git tag -s ${version} -m "Release ${version}"`, + `git push origin ${version}`, + `git checkout -`, + '```', + '', + 'The release workflow will automatically start when the tag is pushed.' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: commentBody + }); + + console.log('Posted tagging instructions'); + } else if (approvalCount < 2 && hasApprovedLabel) { + console.log('⚠️ Approval count dropped below quorum, removing approved label'); + + // Remove approved label + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name: 'approved' + }).catch(e => console.log('Label not found:', e.message)); + + // Add awaiting-approval label + if (!hasAwaitingApprovalLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: ['awaiting-approval'] + }); + } + } else { + console.log(`⏳ Waiting for more approvals (${approvalCount}/2 required)`); + } + + handle-pr-closed: + name: Handle PR Closed Without Tag + runs-on: ubuntu-latest + if: | + contains(github.event.pull_request.labels.*.name, 'release-proposal') && + github.event.action == 'closed' && !contains(github.event.pull_request.labels.*.name, 'released') + + steps: + - name: Add cancelled label and comment + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const pr = context.payload.pull_request; + + // Check if the release-in-progress label is present + const hasReleaseInProgress = pr.labels.some(label => label.name === 'release-in-progress'); + + if (hasReleaseInProgress) { + // PR was closed while release was in progress - this is unusual + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: '⚠️ **Warning:** This PR was closed while a release was in progress. This may indicate an error. Please verify the release status.' + }); + } else { + // PR was closed before tag was created - this is normal cancellation + const versionMatch = pr.title.match(/Release Proposal:\s*(v[\d.]+(?:-[\w.]+)?)/); + const version = versionMatch ? versionMatch[1] : 'unknown'; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: `## 🚫 Release Proposal Cancelled\n\nThis release proposal for ${version} was closed without creating the tag.\n\nIf you want to proceed with this release later, you can create a new release proposal.` + }); + } + + // Add cancelled label + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: ['cancelled'] + }); + + // Remove other workflow labels if present + const labelsToRemove = ['awaiting-approval', 'approved', 'release-in-progress']; + for (const label of labelsToRemove) { + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + name: label + }); + } catch (e) { + console.log(`Label ${label} not found or already removed`); + } + } + + console.log('Added cancelled label and cleaned up workflow labels'); + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50501a0f1..2c5052723 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,13 +31,13 @@ jobs: - mac - windows go: - - '1.25' + - '1.26' include: # Set the minimum Go patch version for the given Go minor # Usable via ${{ matrix.GO_SEMVER }} - - go: '1.25' - GO_SEMVER: '~1.25.0' + - go: '1.26' + GO_SEMVER: '~1.26.0' # Set some variables per OS, usable via ${{ matrix.VAR }} # OS_LABEL: the VM label from GitHub Actions (see https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories) @@ -65,15 +65,15 @@ jobs: actions: write # to allow uploading artifacts and cache steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 with: egress-policy: audit - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: ${{ matrix.GO_SEMVER }} check-latest: true @@ -120,7 +120,7 @@ jobs: ./caddy stop - name: Publish Build Artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: caddy_${{ runner.os }}_go${{ matrix.go }}_${{ steps.vars.outputs.short_sha }} path: ${{ matrix.CADDY_BIN_PATH }} @@ -162,13 +162,13 @@ jobs: continue-on-error: true # August 2020: s390x VM is down due to weather and power issues steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 with: egress-policy: audit allowed-endpoints: ci-s390x.caddyserver.com:22 - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Run Tests run: | set +e @@ -221,27 +221,27 @@ jobs: if: github.event.pull_request.head.repo.full_name == 'caddyserver/caddy' && github.actor != 'dependabot[bot]' steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 with: egress-policy: audit - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 with: version: latest args: check - name: Install Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: - go-version: "~1.25" + go-version: "~1.26" check-latest: true - name: Install xcaddy run: | go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest xcaddy version - - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 with: version: latest args: build --single-target --snapshot diff --git a/.github/workflows/cross-build.yml b/.github/workflows/cross-build.yml index 8aa9eaf59..018a46d15 100644 --- a/.github/workflows/cross-build.yml +++ b/.github/workflows/cross-build.yml @@ -36,13 +36,13 @@ jobs: - 'darwin' - 'netbsd' go: - - '1.25' + - '1.26' include: # Set the minimum Go patch version for the given Go minor # Usable via ${{ matrix.GO_SEMVER }} - - go: '1.25' - GO_SEMVER: '~1.25.0' + - go: '1.26' + GO_SEMVER: '~1.26.0' runs-on: ubuntu-latest permissions: @@ -51,15 +51,15 @@ jobs: continue-on-error: true steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 with: egress-policy: audit - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: ${{ matrix.GO_SEMVER }} check-latest: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 849188c64..5a33c5399 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -45,18 +45,18 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 with: egress-policy: audit - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: - go-version: '~1.25' + go-version: '~1.26' check-latest: true - name: golangci-lint - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: version: latest @@ -73,14 +73,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 with: egress-policy: audit - name: govulncheck uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 with: - go-version-input: '~1.25.0' + go-version-input: '~1.26.0' check-latest: true dependency-review: @@ -90,14 +90,14 @@ jobs: pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 with: egress-policy: audit - name: 'Checkout Repository' - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: 'Dependency Review' - uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0 + uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3 with: comment-summary-in-pr: on-failure # https://github.com/actions/dependency-review-action/issues/430#issuecomment-1468975566 diff --git a/.github/workflows/release-proposal.yml b/.github/workflows/release-proposal.yml new file mode 100644 index 000000000..0b9a09136 --- /dev/null +++ b/.github/workflows/release-proposal.yml @@ -0,0 +1,249 @@ +name: Release Proposal + +# This workflow creates a release proposal as a PR that requires approval from maintainers +# Triggered manually by maintainers when ready to prepare a release +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., v2.8.0)' + required: true + type: string + commit_hash: + description: 'Commit hash to release from' + required: true + type: string + +permissions: + contents: read + +jobs: + create-proposal: + name: Create Release Proposal + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 + with: + egress-policy: audit + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Trim and validate inputs + id: inputs + run: | + # Trim whitespace from inputs + VERSION=$(echo "${{ inputs.version }}" | xargs) + COMMIT_HASH=$(echo "${{ inputs.commit_hash }}" | xargs) + + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "commit_hash=$COMMIT_HASH" >> $GITHUB_OUTPUT + + # Validate version format + if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "Error: Version must follow semver format (e.g., v2.8.0 or v2.8.0-beta.1)" + exit 1 + fi + + # Validate commit hash format + if [[ ! "$COMMIT_HASH" =~ ^[a-f0-9]{7,40}$ ]]; then + echo "Error: Commit hash must be a valid SHA (7-40 characters)" + exit 1 + fi + + # Check if commit exists + if ! git cat-file -e "$COMMIT_HASH"; then + echo "Error: Commit $COMMIT_HASH does not exist" + exit 1 + fi + + - name: Check if tag already exists + run: | + if git rev-parse "${{ steps.inputs.outputs.version }}" >/dev/null 2>&1; then + echo "Error: Tag ${{ steps.inputs.outputs.version }} already exists" + exit 1 + fi + + - name: Check for existing proposal PR + id: check_existing + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const version = '${{ steps.inputs.outputs.version }}'; + + // Search for existing open PRs with release-proposal label that match this version + const openPRs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + sort: 'updated', + direction: 'desc' + }); + + const existingOpenPR = openPRs.data.find(pr => + pr.title.includes(version) && + pr.labels.some(label => label.name === 'release-proposal') + ); + + if (existingOpenPR) { + const hasReleased = existingOpenPR.labels.some(label => label.name === 'released'); + const hasReleaseInProgress = existingOpenPR.labels.some(label => label.name === 'release-in-progress'); + + if (hasReleased || hasReleaseInProgress) { + core.setFailed(`A release for ${version} is already in progress or completed: ${existingOpenPR.html_url}`); + } else { + core.setFailed(`An open release proposal already exists for ${version}: ${existingOpenPR.html_url}\n\nPlease use the existing PR or close it first.`); + } + return; + } + + // Check for closed PRs with this version that were cancelled + const closedPRs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'closed', + sort: 'updated', + direction: 'desc' + }); + + const cancelledPR = closedPRs.data.find(pr => + pr.title.includes(version) && + pr.labels.some(label => label.name === 'release-proposal') && + pr.labels.some(label => label.name === 'cancelled') + ); + + if (cancelledPR) { + console.log(`Found previously cancelled proposal for ${version}: ${cancelledPR.html_url}`); + console.log('Creating new proposal to replace cancelled one...'); + } else { + console.log(`No existing proposal found for ${version}, proceeding...`); + } + + - name: Generate changelog and create branch + id: setup + run: | + VERSION="${{ steps.inputs.outputs.version }}" + COMMIT_HASH="${{ steps.inputs.outputs.commit_hash }}" + + # Create a new branch for the release proposal + BRANCH_NAME="release_proposal-$VERSION" + git checkout -b "$BRANCH_NAME" + + # Calculate how many commits behind HEAD + COMMITS_BEHIND=$(git rev-list --count ${COMMIT_HASH}..HEAD) + + if [ "$COMMITS_BEHIND" -eq 0 ]; then + BEHIND_INFO="This is the latest commit (HEAD)" + else + BEHIND_INFO="This commit is **${COMMITS_BEHIND} commits behind HEAD**" + fi + + echo "commits_behind=$COMMITS_BEHIND" >> $GITHUB_OUTPUT + echo "behind_info=$BEHIND_INFO" >> $GITHUB_OUTPUT + + # Get the last tag + LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + + if [ -z "$LAST_TAG" ]; then + echo "No previous tag found, generating full changelog" + COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "$COMMIT_HASH") + else + echo "Generating changelog since $LAST_TAG" + COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "${LAST_TAG}..$COMMIT_HASH") + fi + + # Store changelog for PR body + CLEANSED_COMMITS=$(echo "$COMMITS" | sed 's/`/\\`/g') + echo "changelog<> $GITHUB_OUTPUT + echo "$CLEANSED_COMMITS" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + # Create empty commit for the PR + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit --allow-empty -m "Release proposal for $VERSION" + + # Push the branch + git push origin "$BRANCH_NAME" + + echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT + + - name: Create release proposal PR + id: create_pr + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const changelog = `${{ steps.setup.outputs.changelog }}`; + + const pr = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `Release Proposal: ${{ steps.inputs.outputs.version }}`, + head: '${{ steps.setup.outputs.branch_name }}', + base: 'master', + body: `## Release Proposal: ${{ steps.inputs.outputs.version }} + + **Target Commit:** \`${{ steps.inputs.outputs.commit_hash }}\` + **Requested by:** @${{ github.actor }} + **Commit Status:** ${{ steps.setup.outputs.behind_info }} + + This PR proposes creating release tag \`${{ steps.inputs.outputs.version }}\` at commit \`${{ steps.inputs.outputs.commit_hash }}\`. + + ### Approval Process + + This PR requires **approval from 2+ maintainers** before the tag can be created. + + ### What happens next? + + 1. Maintainers review this proposal + 2. When 2+ maintainer approvals are received, an automated workflow will post tagging instructions + 3. A maintainer manually creates and pushes the signed tag + 4. The release workflow is triggered automatically by the tag push + 5. Upon release completion, this PR is closed and the branch is deleted + + ### Changes Since Last Release + + ${changelog} + + ### Release Checklist + + - [ ] All tests pass + - [ ] Security review completed + - [ ] Documentation updated + - [ ] Breaking changes documented + + --- + + **Note:** Tag creation is manual and requires a signed tag from a maintainer.`, + draft: true + }); + + // Add labels + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.data.number, + labels: ['release-proposal', 'awaiting-approval'] + }); + + console.log(`Created PR: ${pr.data.html_url}`); + + return { number: pr.data.number, url: pr.data.html_url }; + result-encoding: json + + - name: Post summary + run: | + echo "## Release Proposal PR Created! 🚀" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Version: **${{ steps.inputs.outputs.version }}**" >> $GITHUB_STEP_SUMMARY + echo "Commit: **${{ steps.inputs.outputs.commit_hash }}**" >> $GITHUB_STEP_SUMMARY + echo "Status: ${{ steps.setup.outputs.behind_info }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "PR: ${{ fromJson(steps.create_pr.outputs.result).url }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 397df5ea2..2cddde610 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,20 +13,334 @@ permissions: contents: read jobs: + verify-tag: + name: Verify Tag Signature and Approvals + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + issues: write + + outputs: + verification_passed: ${{ steps.verify.outputs.passed }} + tag_version: ${{ steps.info.outputs.version }} + proposal_issue_number: ${{ steps.find_proposal.outputs.result && fromJson(steps.find_proposal.outputs.result).number || '' }} + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + # Force fetch upstream tags -- because 65 minutes + # tl;dr: actions/checkout@v3 runs this line: + # git -c protocol.version=2 fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +ebc278ec98bb24f2852b61fde2a9bf2e3d83818b:refs/tags/ + # which makes its own local lightweight tag, losing all the annotations in the process. Our earlier script ran: + # git fetch --prune --unshallow + # which doesn't overwrite that tag because that would be destructive. + # Credit to @francislavoie for the investigation. + # https://github.com/actions/checkout/issues/290#issuecomment-680260080 + - name: Force fetch upstream tags + run: git fetch --tags --force + + - name: Get tag info + id: info + run: | + echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + + # https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027 + - name: Print Go version and environment + id: vars + run: | + printf "Using go at: $(which go)\n" + printf "Go version: $(go version)\n" + printf "\n\nGo environment:\n\n" + go env + printf "\n\nSystem environment:\n\n" + env + echo "version_tag=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT + echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + # Add "pip install" CLI tools to PATH + echo ~/.local/bin >> $GITHUB_PATH + + # Parse semver + TAG=${GITHUB_REF/refs\/tags\//} + SEMVER_RE='[^0-9]*\([0-9]*\)[.]\([0-9]*\)[.]\([0-9]*\)\([0-9A-Za-z\.-]*\)' + TAG_MAJOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\1#"` + TAG_MINOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\2#"` + TAG_PATCH=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\3#"` + TAG_SPECIAL=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\4#"` + echo "tag_major=${TAG_MAJOR}" >> $GITHUB_OUTPUT + echo "tag_minor=${TAG_MINOR}" >> $GITHUB_OUTPUT + echo "tag_patch=${TAG_PATCH}" >> $GITHUB_OUTPUT + echo "tag_special=${TAG_SPECIAL}" >> $GITHUB_OUTPUT + + - name: Validate commits and tag signatures + id: verify + env: + signing_keys: ${{ secrets.SIGNING_KEYS }} + run: | + # Read the string into an array, splitting by IFS + IFS=";" read -ra keys_collection <<< "$signing_keys" + + # ref: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#example-usage-of-the-runner-context + touch "${{ runner.temp }}/allowed_signers" + + # Iterate and print the split elements + for item in "${keys_collection[@]}"; do + + # trim leading whitespaces + item="${item##*( )}" + + # trim trailing whitespaces + item="${item%%*( )}" + + IFS=" " read -ra key_components <<< "$item" + # git wants it in format: email address, type, public key + # ssh has it in format: type, public key, email address + echo "${key_components[2]} namespaces=\"git\" ${key_components[0]} ${key_components[1]}" >> "${{ runner.temp }}/allowed_signers" + done + + git config set --global gpg.ssh.allowedSignersFile "${{ runner.temp }}/allowed_signers" + + echo "Verifying the tag: ${{ steps.vars.outputs.version_tag }}" + + # Verify the tag is signed + if ! git verify-tag -v "${{ steps.vars.outputs.version_tag }}" 2>&1; then + echo "❌ Tag verification failed!" + echo "passed=false" >> $GITHUB_OUTPUT + git push --delete origin "${{ steps.vars.outputs.version_tag }}" + exit 1 + fi + # Run it again to capture the output + git verify-tag -v "${{ steps.vars.outputs.version_tag }}" 2>&1 | tee /tmp/verify-output.txt; + + # SSH verification output typically includes the key fingerprint + # Use GNU grep with Perl regex for cleaner extraction (Linux environment) + KEY_SHA256=$(grep -oP "SHA256:[\"']?\K[A-Za-z0-9+/=]+(?=[\"']?)" /tmp/verify-output.txt | head -1 || echo "") + + if [ -z "$KEY_SHA256" ]; then + # Try alternative pattern with "key" prefix + KEY_SHA256=$(grep -oP "key SHA256:[\"']?\K[A-Za-z0-9+/=]+(?=[\"']?)" /tmp/verify-output.txt | head -1 || echo "") + fi + + if [ -z "$KEY_SHA256" ]; then + # Fallback: extract any base64-like string (40+ chars) + KEY_SHA256=$(grep -oP '[A-Za-z0-9+/]{40,}=?' /tmp/verify-output.txt | head -1 || echo "") + fi + + if [ -z "$KEY_SHA256" ]; then + echo "Somehow could not extract SSH key fingerprint from git verify-tag output" + echo "Cancelling flow and deleting tag" + echo "passed=false" >> $GITHUB_OUTPUT + git push --delete origin "${{ steps.vars.outputs.version_tag }}" + exit 1 + fi + + echo "✅ Tag verification succeeded!" + echo "passed=true" >> $GITHUB_OUTPUT + echo "key_id=$KEY_SHA256" >> $GITHUB_OUTPUT + + - name: Find related release proposal + id: find_proposal + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const version = '${{ steps.vars.outputs.version_tag }}'; + + // Search for PRs with release-proposal label that match this version + const prs = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', // Changed to 'all' to find both open and closed PRs + sort: 'updated', + direction: 'desc' + }); + + // Find the most recent PR for this version + const proposal = prs.data.find(pr => + pr.title.includes(version) && + pr.labels.some(label => label.name === 'release-proposal') + ); + + if (!proposal) { + console.log(`⚠️ No release proposal PR found for ${version}`); + console.log('This might be a hotfix or emergency release'); + return { number: null, approved: true, approvals: 0, proposedCommit: null }; + } + + console.log(`Found proposal PR #${proposal.number} for version ${version}`); + + // Extract commit hash from PR body + const commitMatch = proposal.body.match(/\*\*Target Commit:\*\*\s*`([a-f0-9]+)`/); + const proposedCommit = commitMatch ? commitMatch[1] : null; + + if (proposedCommit) { + console.log(`Proposal was for commit: ${proposedCommit}`); + } else { + console.log('⚠️ No target commit hash found in PR body'); + } + + // Get PR reviews to extract approvers + let approvers = 'Validated by automation'; + let approvalCount = 2; // Minimum required + + try { + const reviews = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: proposal.number + }); + + // Get latest review per user and filter for approvals + const latestReviewsByUser = {}; + reviews.data.forEach(review => { + const username = review.user.login; + if (!latestReviewsByUser[username] || new Date(review.submitted_at) > new Date(latestReviewsByUser[username].submitted_at)) { + latestReviewsByUser[username] = review; + } + }); + + const approvalReviews = Object.values(latestReviewsByUser).filter(review => + review.state === 'APPROVED' + ); + + if (approvalReviews.length > 0) { + approvers = approvalReviews.map(r => '@' + r.user.login).join(', '); + approvalCount = approvalReviews.length; + console.log(`Found ${approvalCount} approvals from: ${approvers}`); + } + } catch (error) { + console.log(`Could not fetch reviews: ${error.message}`); + } + + return { + number: proposal.number, + approved: true, + approvals: approvalCount, + approvers: approvers, + proposedCommit: proposedCommit + }; + result-encoding: json + + - name: Verify proposal commit + run: | + APPROVALS='${{ steps.find_proposal.outputs.result }}' + + # Parse JSON + PROPOSED_COMMIT=$(echo "$APPROVALS" | jq -r '.proposedCommit') + CURRENT_COMMIT="${{ steps.info.outputs.sha }}" + + echo "Proposed commit: $PROPOSED_COMMIT" + echo "Current commit: $CURRENT_COMMIT" + + # Check if commits match (if proposal had a target commit) + if [ "$PROPOSED_COMMIT" != "null" ] && [ -n "$PROPOSED_COMMIT" ]; then + # Normalize both commits to full SHA for comparison + PROPOSED_FULL=$(git rev-parse "$PROPOSED_COMMIT" 2>/dev/null || echo "") + CURRENT_FULL=$(git rev-parse "$CURRENT_COMMIT" 2>/dev/null || echo "") + + if [ -z "$PROPOSED_FULL" ]; then + echo "⚠️ Could not resolve proposed commit: $PROPOSED_COMMIT" + elif [ "$PROPOSED_FULL" != "$CURRENT_FULL" ]; then + echo "❌ Commit mismatch!" + echo "The tag points to commit $CURRENT_FULL but the proposal was for $PROPOSED_FULL" + echo "This indicates an error in tag creation." + # Delete the tag remotely + git push --delete origin "${{ steps.vars.outputs.version_tag }}" + echo "Tag ${{steps.vars.outputs.version_tag}} has been deleted" + exit 1 + else + echo "✅ Commit hash matches proposal" + fi + else + echo "⚠️ No target commit found in proposal (might be legacy release)" + fi + + echo "✅ Tag verification completed" + + - name: Update release proposal PR + if: fromJson(steps.find_proposal.outputs.result).number != null + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const result = ${{ steps.find_proposal.outputs.result }}; + + if (result.number) { + // Add in-progress label + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: result.number, + labels: ['release-in-progress'] + }); + + // Remove approved label if present + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: result.number, + name: 'approved' + }); + } catch (e) { + console.log('Approved label not found:', e.message); + } + + const commentBody = [ + '## 🚀 Release Workflow Started', + '', + '- **Tag:** ${{ steps.info.outputs.version }}', + '- **Signed by key:** ${{ steps.verify.outputs.key_id }}', + '- **Commit:** ${{ steps.info.outputs.sha }}', + '- **Approved by:** ' + result.approvers, + '', + 'Release workflow is now running. This PR will be updated when the release is published.' + ].join('\n'); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: result.number, + body: commentBody + }); + } + + - name: Summary + run: | + APPROVALS='${{ steps.find_proposal.outputs.result }}' + PROPOSED_COMMIT=$(echo "$APPROVALS" | jq -r '.proposedCommit // "N/A"') + APPROVERS=$(echo "$APPROVALS" | jq -r '.approvers // "N/A"') + + echo "## Tag Verification Summary 🔐" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Tag:** ${{ steps.info.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "- **Commit:** ${{ steps.info.outputs.sha }}" >> $GITHUB_STEP_SUMMARY + echo "- **Proposed Commit:** $PROPOSED_COMMIT" >> $GITHUB_STEP_SUMMARY + echo "- **Signature:** ✅ Verified" >> $GITHUB_STEP_SUMMARY + echo "- **Signed by:** ${{ steps.verify.outputs.key_id }}" >> $GITHUB_STEP_SUMMARY + echo "- **Approvals:** ✅ Sufficient" >> $GITHUB_STEP_SUMMARY + echo "- **Approved by:** $APPROVERS" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Proceeding with release build..." >> $GITHUB_STEP_SUMMARY + release: name: Release + needs: verify-tag + if: ${{ needs.verify-tag.outputs.verification_passed == 'true' }} strategy: matrix: os: - ubuntu-latest go: - - '1.25' + - '1.26' include: # Set the minimum Go patch version for the given Go minor # Usable via ${{ matrix.GO_SEMVER }} - - go: '1.25' - GO_SEMVER: '~1.25.0' + - go: '1.26' + GO_SEMVER: '~1.26.0' runs-on: ${{ matrix.os }} # https://github.com/sigstore/cosign/issues/1258#issuecomment-1002251233 @@ -36,26 +350,28 @@ jobs: # https://docs.github.com/en/rest/overview/permissions-required-for-github-apps#permission-on-contents # "Releases" is part of `contents`, so it needs the `write` contents: write + issues: write + pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 with: egress-policy: audit - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Install Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: ${{ matrix.GO_SEMVER }} check-latest: true # Force fetch upstream tags -- because 65 minutes - # tl;dr: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.2.2 runs this line: + # tl;dr: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2 runs this line: # git -c protocol.version=2 fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +ebc278ec98bb24f2852b61fde2a9bf2e3d83818b:refs/tags/ # which makes its own local lightweight tag, losing all the annotations in the process. Our earlier script ran: # git fetch --prune --unshallow @@ -98,22 +414,12 @@ jobs: - name: Install Cloudsmith CLI run: pip install --upgrade cloudsmith-cli - - name: Validate commits and tag signatures - run: | - - # Import Matt Holt's key - curl 'https://github.com/mholt.gpg' | gpg --import - - echo "Verifying the tag: ${{ steps.vars.outputs.version_tag }}" - # tags are only accepted if signed by Matt's key - git verify-tag "${{ steps.vars.outputs.version_tag }}" || exit 1 - - name: Install Cosign uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # main - name: Cosign version run: cosign version - name: Install Syft - uses: anchore/sbom-action/download-syft@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # main + uses: anchore/sbom-action/download-syft@17ae1740179002c89186b61233e0f892c3118b11 # main - name: Syft version run: syft version - name: Install xcaddy @@ -122,7 +428,7 @@ jobs: xcaddy version # GoReleaser will take care of publishing those artifacts into the release - name: Run GoReleaser - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 with: version: latest args: release --clean --timeout 60m @@ -188,3 +494,72 @@ jobs: echo "Pushing $filename to 'testing'" cloudsmith push deb caddy/testing/any-distro/any-version $filename done + + - name: Update release proposal PR + if: needs.verify-tag.outputs.proposal_issue_number != '' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const prNumber = parseInt('${{ needs.verify-tag.outputs.proposal_issue_number }}'); + + if (prNumber) { + // Get PR details to find the branch + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + const branchName = pr.data.head.ref; + + // Remove in-progress label + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: 'release-in-progress' + }); + } catch (e) { + console.log('Label not found:', e.message); + } + + // Add released label + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['released'] + }); + + // Add final comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: '## ✅ Release Published\n\nThe release has been successfully published and is now available.' + }); + + // Close the PR if it's still open + if (pr.data.state === 'open') { + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + state: 'closed' + }); + console.log(`Closed PR #${prNumber}`); + } + + // Delete the branch + try { + await github.rest.git.deleteRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `heads/${branchName}` + }); + console.log(`Deleted branch: ${branchName}`); + } catch (e) { + console.log(`Could not delete branch ${branchName}: ${e.message}`); + } + } diff --git a/.github/workflows/release_published.yml b/.github/workflows/release_published.yml index 8afc5c35e..10a90cb9b 100644 --- a/.github/workflows/release_published.yml +++ b/.github/workflows/release_published.yml @@ -24,12 +24,12 @@ jobs: # See https://github.com/peter-evans/repository-dispatch - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 with: egress-policy: audit - name: Trigger event on caddyserver/dist - uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ secrets.REPO_DISPATCH_TOKEN }} repository: caddyserver/dist @@ -37,7 +37,7 @@ jobs: client-payload: '{"tag": "${{ github.event.release.tag_name }}"}' - name: Trigger event on caddyserver/caddy-docker - uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 with: token: ${{ secrets.REPO_DISPATCH_TOKEN }} repository: caddyserver/caddy-docker diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index bb49f935d..132803f0e 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -37,12 +37,12 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 + uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0 with: egress-policy: audit - name: "Checkout code" - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false @@ -72,7 +72,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: SARIF file path: results.sarif @@ -81,6 +81,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.29.5 + uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v3.29.5 with: sarif_file: results.sarif diff --git a/.golangci.yml b/.golangci.yml index 4f4545054..e800788f5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -32,6 +32,7 @@ linters: - importas - ineffassign - misspell + - modernize - prealloc - promlinter - sloglint diff --git a/.goreleaser.yml b/.goreleaser.yml index c7ed4b365..3c87131bd 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -13,7 +13,7 @@ before: - cp cmd/caddy/main.go caddy-build/main.go - /bin/sh -c 'cd ./caddy-build && go mod init caddy' # prepare syso files for windows embedding - - /bin/sh -c 'for a in amd64 arm arm64; do XCADDY_SKIP_BUILD=1 GOOS=windows GOARCH=$a xcaddy build {{.Env.TAG}}; done' + - /bin/sh -c 'for a in amd64 arm64; do XCADDY_SKIP_BUILD=1 GOOS=windows GOARCH=$a xcaddy build {{.Env.TAG}}; done' - /bin/sh -c 'mv /tmp/buildenv_*/*.syso caddy-build' # GoReleaser doesn't seem to offer {{.Tag}} at this stage, so we have to embed it into the env # so we run: TAG=$(git describe --abbrev=0) goreleaser release --rm-dist --skip-publish --skip-validate @@ -67,6 +67,8 @@ builds: goarch: s390x - goos: windows goarch: riscv64 + - goos: windows + goarch: arm - goos: freebsd goarch: ppc64le - goos: freebsd diff --git a/README.md b/README.md index 4c091f714..508352c56 100644 --- a/README.md +++ b/README.md @@ -12,24 +12,52 @@

Every site on HTTPS

Caddy is an extensible server platform that uses TLS by default.

-

- - - -
- @caddyserver on Twitter - Caddy Forum -
- Caddy on Sourcegraph - Cloudsmith -

Releases · Documentation · Get Help

+

+ +   + +   + +   + @caddyserver on Twitter +   + Caddy Forum +
+ Caddy on Sourcegraph +   + Cloudsmith +

+

+ Powered by +
+ + + + + CertMagic + + +

+ +
+
+ Special thanks to: +
+ + Warp sponsorship + +### [Warp, built for coding with multiple AI agents](https://go.warp.dev/caddy) +[Available for MacOS, Linux, & Windows](https://go.warp.dev/caddy)
+
+ +
### Menu @@ -44,18 +72,6 @@ - [Getting help](#getting-help) - [About](#about) -

- Powered by -
- - - - - CertMagic - - -

- ## [Features](https://caddyserver.com/features) @@ -117,11 +133,18 @@ username ALL=(ALL:ALL) NOPASSWD: /usr/sbin/setcap replacing `username` with your actual username. Please be careful and only do this if you know what you are doing! We are only qualified to document how to use Caddy, not Go tooling or your computer, and we are providing these instructions for convenience only; please learn how to use your own computer at your own risk and make any needful adjustments. +Then you can run the tests in all modules or a specific one: + +```bash +$ go test ./... +$ go test ./modules/caddyhttp/tracing/ +``` + ### With version information and/or plugins Using [our builder tool, `xcaddy`](https://github.com/caddyserver/xcaddy)... -``` +```bash $ xcaddy build ``` @@ -197,6 +220,6 @@ Matthew Holt began developing Caddy in 2014 while studying computer science at B - _Project on X: [@caddyserver](https://x.com/caddyserver)_ - _Author on X: [@mholt6](https://x.com/mholt6)_ -Caddy is a project of [ZeroSSL](https://zerossl.com), a Stack Holdings company. +Caddy is a project of [ZeroSSL](https://zerossl.com), an HID Global company. Debian package repository hosting is graciously provided by [Cloudsmith](https://cloudsmith.com). Cloudsmith is the only fully hosted, cloud-native, universal package management solution, that enables your organization to create, store and share packages in any format, to any place, with total confidence. diff --git a/admin.go b/admin.go index 6ccec41e7..5ceb3daeb 100644 --- a/admin.go +++ b/admin.go @@ -47,6 +47,12 @@ import ( "go.uber.org/zap/zapcore" ) +// testCertMagicStorageOverride is a package-level test hook. Tests may set +// this variable to provide a temporary certmagic.Storage so that cert +// management in tests does not hit the real default storage on disk. +// This must NOT be set in production code. +var testCertMagicStorageOverride certmagic.Storage + func init() { // The hard-coded default `DefaultAdminListen` can be overridden // by setting the `CADDY_ADMIN` environment variable. @@ -633,8 +639,19 @@ func (ident *IdentityConfig) certmagicConfig(logger *zap.Logger, makeCache bool) // certmagic config, although it'll be mostly useless for remote management ident = new(IdentityConfig) } + // Choose storage: prefer the package-level test override when present, + // otherwise use the configured DefaultStorage. Tests may set an override + // to divert storage into a temporary location. Otherwise, in production + // we use the DefaultStorage since we don't want to act as part of a + // cluster; this storage is for the server's local identity only. + var storage certmagic.Storage + if testCertMagicStorageOverride != nil { + storage = testCertMagicStorageOverride + } else { + storage = DefaultStorage + } template := certmagic.Config{ - Storage: DefaultStorage, // do not act as part of a cluster (this is for the server's local identity) + Storage: storage, Logger: logger, Issuers: ident.issuers, } @@ -732,10 +749,14 @@ func stopAdminServer(srv *http.Server) error { if srv == nil { return fmt.Errorf("no admin server") } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + timeout := 10 * time.Second + ctx, cancel := context.WithTimeoutCause(context.Background(), timeout, fmt.Errorf("stopping admin server: %ds timeout", int(timeout.Seconds()))) defer cancel() err := srv.Shutdown(ctx) if err != nil { + if cause := context.Cause(ctx); cause != nil && errors.Is(err, context.DeadlineExceeded) { + err = cause + } return fmt.Errorf("shutting down admin server: %v", err) } Log().Named("admin").Info("stopped previous server", zap.String("address", srv.Addr)) @@ -807,13 +828,38 @@ func (h adminHandler) serveHTTP(w http.ResponseWriter, r *http.Request) { } } + // common mitigations in browser contexts if strings.Contains(r.Header.Get("Upgrade"), "websocket") { // I've never been able demonstrate a vulnerability myself, but apparently // WebSocket connections originating from browsers aren't subject to CORS // restrictions, so we'll just be on the safe side - h.handleError(w, r, fmt.Errorf("websocket connections aren't allowed")) + h.handleError(w, r, APIError{ + HTTPStatus: http.StatusBadRequest, + Err: errors.New("websocket connections aren't allowed"), + Message: "WebSocket connections aren't allowed.", + }) return } + if strings.Contains(r.Header.Get("Sec-Fetch-Mode"), "no-cors") { + // turns out web pages can just disable the same-origin policy (!???!?) + // but at least browsers let us know that's the case, holy heck + h.handleError(w, r, APIError{ + HTTPStatus: http.StatusBadRequest, + Err: errors.New("client attempted to make request by disabling same-origin policy using no-cors mode"), + Message: "Disabling same-origin restrictions is not allowed.", + }) + return + } + if r.Header.Get("Origin") == "null" { + // bug in Firefox in certain cross-origin situations (yikes?) + // (not strictly a security vuln on its own, but it's red flaggy, + // since it seems to manifest in cross-origin contexts) + h.handleError(w, r, APIError{ + HTTPStatus: http.StatusBadRequest, + Err: errors.New("invalid origin 'null'"), + Message: "Buggy browser is sending null Origin header.", + }) + } if h.enforceHost { // DNS rebinding mitigation @@ -824,7 +870,9 @@ func (h adminHandler) serveHTTP(w http.ResponseWriter, r *http.Request) { } } - if h.enforceOrigin { + _, hasOriginHeader := r.Header["Origin"] + _, hasSecHeader := r.Header["Sec-Fetch-Mode"] + if h.enforceOrigin || hasOriginHeader || hasSecHeader { // cross-site mitigation origin, err := h.checkOrigin(r) if err != nil { @@ -1110,7 +1158,10 @@ func unsyncedConfigAccess(method, path string, body []byte, out io.Writer) error if len(body) > 0 { err = json.Unmarshal(body, &val) if err != nil { - return fmt.Errorf("decoding request body: %v", err) + if jsonErr, ok := err.(*json.SyntaxError); ok { + return fmt.Errorf("decoding request body: %w, at offset %d", jsonErr, jsonErr.Offset) + } + return fmt.Errorf("decoding request body: %w", err) } } diff --git a/admin_test.go b/admin_test.go index 92dd43a5c..97dc76f4d 100644 --- a/admin_test.go +++ b/admin_test.go @@ -22,9 +22,11 @@ import ( "maps" "net/http" "net/http/httptest" + "os" "reflect" "sync" "testing" + "time" "github.com/caddyserver/certmagic" "github.com/prometheus/client_golang/prometheus" @@ -275,13 +277,12 @@ func TestAdminHandlerBuiltinRouteErrors(t *testing.T) { }, } - err := replaceLocalAdminServer(cfg, Context{}) + // Build the admin handler directly (no listener active) + addr, err := ParseNetworkAddress("localhost:2019") if err != nil { - t.Fatalf("setting up admin server: %v", err) + t.Fatalf("Failed to parse address: %v", err) } - defer func() { - stopAdminServer(localAdminServer) - }() + handler := cfg.Admin.newAdminHandler(addr, false, Context{}) tests := []struct { name string @@ -314,7 +315,7 @@ func TestAdminHandlerBuiltinRouteErrors(t *testing.T) { req := httptest.NewRequest(test.method, fmt.Sprintf("http://localhost:2019%s", test.path), nil) rr := httptest.NewRecorder() - localAdminServer.Handler.ServeHTTP(rr, req) + handler.ServeHTTP(rr, req) if rr.Code != test.expectedStatus { t.Errorf("expected status %d but got %d", test.expectedStatus, rr.Code) @@ -799,8 +800,24 @@ MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRS0LmTwUT0iwP ... -----END PRIVATE KEY-----`) - testStorage := certmagic.FileStorage{Path: t.TempDir()} - err := testStorage.Store(context.Background(), "localhost/localhost.crt", certPEM) + tmpDir, err := os.MkdirTemp("", "TestManageIdentity-") + if err != nil { + t.Fatal(err) + } + testStorage := certmagic.FileStorage{Path: tmpDir} + // Clean up the temp dir after the test finishes. Ensure any background + // certificate maintenance is stopped first to avoid RemoveAll races. + t.Cleanup(func() { + if identityCertCache != nil { + identityCertCache.Stop() + identityCertCache = nil + } + // Give goroutines a moment to exit and release file handles. + time.Sleep(50 * time.Millisecond) + _ = os.RemoveAll(tmpDir) + }) + + err = testStorage.Store(context.Background(), "localhost/localhost.crt", certPEM) if err != nil { t.Fatal(err) } @@ -862,7 +879,7 @@ MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRS0LmTwUT0iwP }, }, }, - storage: &certmagic.FileStorage{Path: "testdata"}, + storage: &testStorage, }, checkState: func(t *testing.T, cfg *Config) { if len(cfg.Admin.Identity.issuers) != 1 { @@ -900,6 +917,13 @@ MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRS0LmTwUT0iwP identityCertCache.Stop() identityCertCache = nil } + // Ensure any cache started by manageIdentity is stopped at the end + defer func() { + if identityCertCache != nil { + identityCertCache.Stop() + identityCertCache = nil + } + }() ctx := Context{ Context: context.Background(), @@ -907,6 +931,13 @@ MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRS0LmTwUT0iwP moduleInstances: make(map[string][]Module), } + // If this test provided a FileStorage, set the package-level + // testCertMagicStorageOverride so certmagicConfig will use it. + if test.cfg != nil && test.cfg.storage != nil { + testCertMagicStorageOverride = test.cfg.storage + defer func() { testCertMagicStorageOverride = nil }() + } + err := manageIdentity(ctx, test.cfg) if test.wantErr { diff --git a/caddy.go b/caddy.go index 5f71d8e8b..c27ae4a68 100644 --- a/caddy.go +++ b/caddy.go @@ -88,7 +88,7 @@ type Config struct { storage certmagic.Storage eventEmitter eventEmitter - cancelFunc context.CancelFunc + cancelFunc context.CancelCauseFunc // fileSystems is a dict of fileSystems that will later be loaded from and added to. fileSystems FileSystems @@ -147,8 +147,8 @@ func Load(cfgJSON []byte, forceReload bool) error { // the new value (if applicable; i.e. "DELETE" doesn't have an input). // If the resulting config is the same as the previous, no reload will // occur unless forceReload is true. If the config is unchanged and not -// forcefully reloaded, then errConfigUnchanged This function is safe for -// concurrent use. +// forcefully reloaded, then errConfigUnchanged is returned. This function +// is safe for concurrent use. // The ifMatchHeader can optionally be given a string of the format: // // " " @@ -227,8 +227,18 @@ func changeConfig(method, path string, input []byte, ifMatchHeader string, force idx := make(map[string]string) err = indexConfigObjects(rawCfg[rawConfigKey], "/"+rawConfigKey, idx) if err != nil { + if len(rawCfgJSON) > 0 { + var oldCfg any + err2 := json.Unmarshal(rawCfgJSON, &oldCfg) + if err2 != nil { + err = fmt.Errorf("%v; additionally, restoring old config: %v", err, err2) + } + rawCfg[rawConfigKey] = oldCfg + } else { + rawCfg[rawConfigKey] = nil + } return APIError{ - HTTPStatus: http.StatusInternalServerError, + HTTPStatus: http.StatusBadRequest, Err: fmt.Errorf("indexing config: %v", err), } } @@ -248,6 +258,8 @@ func changeConfig(method, path string, input []byte, ifMatchHeader string, force err = fmt.Errorf("%v; additionally, restoring old config: %v", err, err2) } rawCfg[rawConfigKey] = oldCfg + } else { + rawCfg[rawConfigKey] = nil } return fmt.Errorf("loading new config: %v", err) @@ -281,14 +293,19 @@ func indexConfigObjects(ptr any, configPath string, index map[string]string) err case map[string]any: for k, v := range val { if k == idKey { + var idStr string switch idVal := v.(type) { case string: - index[idVal] = configPath + idStr = idVal case float64: // all JSON numbers decode as float64 - index[fmt.Sprintf("%v", idVal)] = configPath + idStr = fmt.Sprintf("%v", idVal) default: return fmt.Errorf("%s: %s field must be a string or number", configPath, idKey) } + if existingPath, ok := index[idStr]; ok { + return fmt.Errorf("duplicate ID '%s' found at %s and %s", idStr, existingPath, configPath) + } + index[idStr] = configPath continue } // traverse this object property recursively @@ -416,7 +433,7 @@ func run(newCfg *Config, start bool) (Context, error) { // partially copied from provisionContext if err != nil { globalMetrics.configSuccess.Set(0) - ctx.cfg.cancelFunc() + ctx.cfg.cancelFunc(fmt.Errorf("configuration start error: %w", err)) if currentCtx.cfg != nil { certmagic.Default.Storage = currentCtx.cfg.storage @@ -492,7 +509,7 @@ func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error) // cleanup occurs when we return if there // was an error; if no error, it will get // cleaned up on next config cycle - ctx, cancel := NewContext(Context{Context: context.Background(), cfg: newCfg}) + ctx, cancelCause := NewContextWithCause(Context{Context: context.Background(), cfg: newCfg}) defer func() { if err != nil { globalMetrics.configSuccess.Set(0) @@ -501,7 +518,7 @@ func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error) // since the associated config won't be used; // this will cause all modules that were newly // provisioned to clean themselves up - cancel() + cancelCause(fmt.Errorf("configuration error: %w", err)) // also undo any other state changes we made if currentCtx.cfg != nil { @@ -509,7 +526,7 @@ func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error) } } }() - newCfg.cancelFunc = cancel // clean up later + newCfg.cancelFunc = cancelCause // clean up later // set up logging before anything bad happens if newCfg.Logging == nil { @@ -729,7 +746,7 @@ func unsyncedStop(ctx Context) { } // clean up all modules - ctx.cfg.cancelFunc() + ctx.cfg.cancelFunc(fmt.Errorf("stopping apps")) } // Validate loads, provisions, and validates @@ -737,7 +754,7 @@ func unsyncedStop(ctx Context) { func Validate(cfg *Config) error { _, err := run(cfg, false) if err == nil { - cfg.cancelFunc() // call Cleanup on all modules + cfg.cancelFunc(fmt.Errorf("validation complete")) // call Cleanup on all modules } return err } @@ -945,6 +962,34 @@ func InstanceID() (uuid.UUID, error) { // for example. var CustomVersion string +// CustomBinaryName is an optional string that overrides the root +// command name from the default of "caddy". This is useful for +// downstream projects that embed Caddy but use a different binary +// name. Shell completions and help text will use this name instead +// of "caddy". +// +// Set this variable during `go build` with `-ldflags`: +// +// -ldflags '-X github.com/caddyserver/caddy/v2.CustomBinaryName=my_custom_caddy' +// +// for example. +var CustomBinaryName string + +// CustomLongDescription is an optional string that overrides the +// long description of the root Cobra command. This is useful for +// downstream projects that embed Caddy but want different help +// output. +// +// Set this variable in an init() function of a package that is +// imported by your main: +// +// func init() { +// caddy.CustomLongDescription = "My custom server based on Caddy..." +// } +// +// for example. +var CustomLongDescription string + // Version returns the Caddy version in a simple/short form, and // a full version string. The short form will not have spaces and // is intended for User-Agent strings and similar, but may be @@ -1092,7 +1137,7 @@ type Event struct { } // NewEvent creates a new event, but does not emit the event. To emit an -// event, call Emit() on the current instance of the caddyevents app insteaad. +// event, call Emit() on the current instance of the caddyevents app instead. // // EXPERIMENTAL: Subject to change. func NewEvent(ctx Context, name string, data map[string]any) (Event, error) { @@ -1250,10 +1295,10 @@ func getLastConfig() (file, adapter string, fn reloadFromSourceFunc) { // lastConfigMatches returns true if the provided source file and/or adapter // matches the recorded last-config. Matching rules (in priority order): -// 1. If srcAdapter is provided and differs from the recorded adapter, no match. -// 2. If srcFile exactly equals the recorded file, match. -// 3. If both sides can be made absolute and equal, match. -// 4. If basenames are equal, match. +// 1. If srcAdapter is provided and differs from the recorded adapter, no match. +// 2. If srcFile exactly equals the recorded file, match. +// 3. If both sides can be made absolute and equal, match. +// 4. If basenames are equal, match. func lastConfigMatches(srcFile, srcAdapter string) bool { lf, la, _ := getLastConfig() diff --git a/caddyconfig/caddyfile/dispenser.go b/caddyconfig/caddyfile/dispenser.go index d95196e48..66b9b1087 100644 --- a/caddyconfig/caddyfile/dispenser.go +++ b/caddyconfig/caddyfile/dispenser.go @@ -270,7 +270,7 @@ func (d *Dispenser) File() string { // targets are left unchanged. If all the targets are filled, // then true is returned. func (d *Dispenser) Args(targets ...*string) bool { - for i := 0; i < len(targets); i++ { + for i := range targets { if !d.NextArg() { return false } diff --git a/caddyconfig/caddyfile/formatter.go b/caddyconfig/caddyfile/formatter.go index 8a757ea58..dfd316b16 100644 --- a/caddyconfig/caddyfile/formatter.go +++ b/caddyconfig/caddyfile/formatter.go @@ -18,6 +18,7 @@ import ( "bytes" "io" "slices" + "strings" "unicode" ) @@ -52,17 +53,16 @@ func Format(input []byte) []byte { newLines int // count of newlines consumed - comment bool // whether we're in a comment - quoted bool // whether we're in a quoted segment - escaped bool // whether current char is escaped + comment bool // whether we're in a comment + quotes string // encountered quotes ('', '`', '"', '"`', '`"') + escaped bool // whether current char is escaped heredoc heredocState // whether we're in a heredoc heredocEscaped bool // whether heredoc is escaped heredocMarker []rune heredocClosingMarker []rune - nesting int // indentation level - withinBackquote bool + nesting int // indentation level ) write := func(ch rune) { @@ -89,12 +89,8 @@ func Format(input []byte) []byte { } panic(err) } - if ch == '`' { - withinBackquote = !withinBackquote - } - // detect whether we have the start of a heredoc - if !quoted && (heredoc == heredocClosed && !heredocEscaped) && + if quotes == "" && (heredoc == heredocClosed && !heredocEscaped) && space && last == '<' && ch == '<' { write(ch) heredoc = heredocOpening @@ -180,16 +176,47 @@ func Format(input []byte) []byte { continue } - if quoted { + if ch == '`' { + switch quotes { + case "\"`": + quotes = "\"" + case "`": + quotes = "" + case "\"": + quotes = "\"`" + default: + quotes = "`" + } + } + + if quotes == "\"" { if ch == '"' { - quoted = false + quotes = "" } write(ch) continue } - if space && ch == '"' { - quoted = true + if ch == '"' { + switch quotes { + case "": + if space { + quotes = "\"" + } + case "`\"": + quotes = "`" + case "\"`": + quotes = "" + } + } + + if strings.Contains(quotes, "`") { + if ch == '`' && space && !beginningOfLine { + write(' ') + } + write(ch) + space = false + continue } if unicode.IsSpace(ch) { @@ -245,7 +272,7 @@ func Format(input []byte) []byte { write(' ') } openBraceWritten = false - if withinBackquote { + if quotes == "`" { write('{') openBraceWritten = true continue @@ -253,7 +280,7 @@ func Format(input []byte) []byte { continue case ch == '}' && (spacePrior || !openBrace): - if withinBackquote { + if quotes == "`" { write('}') continue } diff --git a/caddyconfig/caddyfile/formatter_test.go b/caddyconfig/caddyfile/formatter_test.go index 29b910ff1..6ab293615 100644 --- a/caddyconfig/caddyfile/formatter_test.go +++ b/caddyconfig/caddyfile/formatter_test.go @@ -444,6 +444,11 @@ block2 { input: "block {respond \"All braces should remain: {{now | date `2006`}}\"}", expect: "block {respond \"All braces should remain: {{now | date `2006`}}\"}", }, + { + description: "Preserve quoted backticks and backticked quotes", + input: "block { respond \"`\" } block { respond `\"`}", + expect: "block {\n\trespond \"`\"\n}\n\nblock {\n\trespond `\"`\n}", + }, { description: "No trailing space on line before env variable", input: `{ @@ -459,6 +464,17 @@ block2 { } `, }, + { + description: "issue #7425: multiline backticked string indentation", + input: `https://localhost:8953 { + respond ` + "`" + `Here are some random numbers: + +{{randNumeric 16}} + +Hope this helps.` + "`" + ` +}`, + expect: "https://localhost:8953 {\n\trespond `Here are some random numbers:\n\n{{randNumeric 16}}\n\nHope this helps.`\n}", + }, } { // the formatter should output a trailing newline, // even if the tests aren't written to expect that diff --git a/caddyconfig/caddyfile/parse.go b/caddyconfig/caddyfile/parse.go index 8439f3731..e9f27dfbf 100644 --- a/caddyconfig/caddyfile/parse.go +++ b/caddyconfig/caddyfile/parse.go @@ -507,7 +507,7 @@ func (p *parser) doImport(nesting int) error { // format, won't check for nesting correctness or any other error, that's what parser does. if !maybeSnippet && nesting == 0 { // first of the line - if i == 0 || isNextOnNewLine(tokensCopy[i-1], token) { + if i == 0 || isNextOnNewLine(tokensCopy[len(tokensCopy)-1], token) { index = 0 } else { index++ @@ -616,7 +616,7 @@ func (p *parser) doSingleImport(importFile string) ([]Token, error) { if err != nil { return nil, p.Errf("Failed to get absolute path of file: %s: %v", importFile, err) } - for i := 0; i < len(importedTokens); i++ { + for i := range importedTokens { importedTokens[i].File = filename } @@ -761,7 +761,7 @@ type ServerBlock struct { } func (sb ServerBlock) GetKeysText() []string { - res := []string{} + res := make([]string, 0, len(sb.Keys)) for _, k := range sb.Keys { res = append(res, k.Text) } diff --git a/caddyconfig/configadapters.go b/caddyconfig/configadapters.go index 0ca3c3af1..8a5a37f08 100644 --- a/caddyconfig/configadapters.go +++ b/caddyconfig/configadapters.go @@ -81,7 +81,11 @@ func JSONModuleObject(val any, fieldName, fieldVal string, warnings *[]Warning) err = json.Unmarshal(enc, &tmp) if err != nil { if warnings != nil { - *warnings = append(*warnings, Warning{Message: err.Error()}) + message := err.Error() + if jsonErr, ok := err.(*json.SyntaxError); ok { + message = fmt.Sprintf("%v, at offset %d", jsonErr.Error(), jsonErr.Offset) + } + *warnings = append(*warnings, Warning{Message: message}) } return nil } diff --git a/caddyconfig/httpcaddyfile/builtins.go b/caddyconfig/httpcaddyfile/builtins.go index 061aaa48b..a7bb3b1de 100644 --- a/caddyconfig/httpcaddyfile/builtins.go +++ b/caddyconfig/httpcaddyfile/builtins.go @@ -113,6 +113,7 @@ func parseBind(h Helper) ([]ConfigValue, error) { // issuer [...] // get_certificate [...] // insecure_secrets_log +// renewal_window_ratio // } func parseTLS(h Helper) ([]ConfigValue, error) { h.Next() // consume directive name @@ -129,6 +130,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) { var onDemand bool var reusePrivateKeys bool var forceAutomate bool + var renewalWindowRatio float64 // Track which DNS challenge options are set var dnsOptionsSet []string @@ -473,6 +475,20 @@ func parseTLS(h Helper) ([]ConfigValue, error) { } cp.InsecureSecretsLog = h.Val() + case "renewal_window_ratio": + arg := h.RemainingArgs() + if len(arg) != 1 { + return nil, h.ArgErr() + } + ratio, err := strconv.ParseFloat(arg[0], 64) + if err != nil { + return nil, h.Errf("parsing renewal_window_ratio: %v", err) + } + if ratio <= 0 || ratio >= 1 { + return nil, h.Errf("renewal_window_ratio must be between 0 and 1 (exclusive)") + } + renewalWindowRatio = ratio + default: return nil, h.Errf("unknown subdirective: %s", h.Val()) } @@ -597,6 +613,14 @@ func parseTLS(h Helper) ([]ConfigValue, error) { }) } + // renewal window ratio + if renewalWindowRatio > 0 { + configVals = append(configVals, ConfigValue{ + Class: "tls.renewal_window_ratio", + Value: renewalWindowRatio, + }) + } + // if enabled, the names in the site addresses will be // added to the automation policies if forceAutomate { @@ -930,6 +954,7 @@ func parseLogHelper(h Helper, globalLogNames map[string]struct{}) ([]ConfigValue // modifications to the parsing behavior. parseAsGlobalOption := globalLogNames != nil + // nolint:prealloc var configValues []ConfigValue // Logic below expects that a name is always present when a diff --git a/caddyconfig/httpcaddyfile/httptype.go b/caddyconfig/httpcaddyfile/httptype.go index 3dcd3ea5b..1b9c625fe 100644 --- a/caddyconfig/httpcaddyfile/httptype.go +++ b/caddyconfig/httpcaddyfile/httptype.go @@ -822,7 +822,7 @@ func (st *ServerType) serversFromPairings( // https://caddy.community/t/making-sense-of-auto-https-and-why-disabling-it-still-serves-https-instead-of-http/9761 createdTLSConnPolicies, ok := sblock.pile["tls.connection_policy"] hasTLSEnabled := (ok && len(createdTLSConnPolicies) > 0) || - (addr.Host != "" && srv.AutoHTTPS != nil && !slices.Contains(srv.AutoHTTPS.Skip, addr.Host)) + (addr.Host != "" && (srv.AutoHTTPS == nil || !slices.Contains(srv.AutoHTTPS.Skip, addr.Host))) // we'll need to remember if the address qualifies for auto-HTTPS, so we // can add a TLS conn policy if necessary @@ -851,6 +851,20 @@ func (st *ServerType) serversFromPairings( srv.ListenerWrappersRaw = append(srv.ListenerWrappersRaw, jsonListenerWrapper) } + // Look for any config values that provide packet conn wrappers on the server block + for _, listenerConfig := range sblock.pile["packet_conn_wrapper"] { + packetConnWrapper, ok := listenerConfig.Value.(caddy.PacketConnWrapper) + if !ok { + return nil, fmt.Errorf("config for a packet conn wrapper did not provide a value that implements caddy.PacketConnWrapper") + } + jsonPacketConnWrapper := caddyconfig.JSONModuleObject( + packetConnWrapper, + "wrapper", + packetConnWrapper.(caddy.Module).CaddyModule().ID.Name(), + warnings) + srv.PacketConnWrappersRaw = append(srv.PacketConnWrappersRaw, jsonPacketConnWrapper) + } + // set up each handler directive, making sure to honor directive order dirRoutes := sblock.pile["route"] siteSubroute, err := buildSubroute(dirRoutes, groupCounter, true) diff --git a/caddyconfig/httpcaddyfile/httptype_test.go b/caddyconfig/httpcaddyfile/httptype_test.go index 69f55501c..2436efcd9 100644 --- a/caddyconfig/httpcaddyfile/httptype_test.go +++ b/caddyconfig/httpcaddyfile/httptype_test.go @@ -1,9 +1,11 @@ package httpcaddyfile import ( + "encoding/json" "testing" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" ) func TestMatcherSyntax(t *testing.T) { @@ -209,3 +211,53 @@ func TestGlobalOptions(t *testing.T) { } } } + +func TestDefaultSNIWithoutHTTPS(t *testing.T) { + caddyfileStr := `{ + default_sni my-sni.com + } + example.com { + }` + + adapter := caddyfile.Adapter{ + ServerType: ServerType{}, + } + + result, _, err := adapter.Adapt([]byte(caddyfileStr), nil) + if err != nil { + t.Fatalf("Failed to adapt Caddyfile: %v", err) + } + + var config struct { + Apps struct { + HTTP struct { + Servers map[string]*caddyhttp.Server `json:"servers"` + } `json:"http"` + } `json:"apps"` + } + + if err := json.Unmarshal(result, &config); err != nil { + t.Fatalf("Failed to unmarshal JSON config: %v", err) + } + + server, ok := config.Apps.HTTP.Servers["srv0"] + if !ok { + t.Fatalf("Expected server 'srv0' to be created") + } + + if len(server.TLSConnPolicies) == 0 { + t.Fatalf("Expected TLS connection policies to be generated, got none") + } + + found := false + for _, policy := range server.TLSConnPolicies { + if policy.DefaultSNI == "my-sni.com" { + found = true + break + } + } + + if !found { + t.Errorf("Expected default_sni 'my-sni.com' in TLS connection policies, but it was missing. Generated JSON: %s", string(result)) + } +} diff --git a/caddyconfig/httpcaddyfile/options.go b/caddyconfig/httpcaddyfile/options.go index 336c6999f..ffe43ff7e 100644 --- a/caddyconfig/httpcaddyfile/options.go +++ b/caddyconfig/httpcaddyfile/options.go @@ -64,7 +64,9 @@ func init() { RegisterGlobalOption("preferred_chains", parseOptPreferredChains) RegisterGlobalOption("persist_config", parseOptPersistConfig) RegisterGlobalOption("dns", parseOptDNS) + RegisterGlobalOption("tls_resolvers", parseOptTLSResolvers) RegisterGlobalOption("ech", parseOptECH) + RegisterGlobalOption("renewal_window_ratio", parseOptRenewalWindowRatio) } func parseOptTrue(d *caddyfile.Dispenser, _ any) (any, error) { return true, nil } @@ -305,6 +307,15 @@ func parseOptSingleString(d *caddyfile.Dispenser, _ any) (any, error) { return val, nil } +func parseOptTLSResolvers(d *caddyfile.Dispenser, _ any) (any, error) { + d.Next() // consume option name + resolvers := d.RemainingArgs() + if len(resolvers) == 0 { + return nil, d.ArgErr() + } + return resolvers, nil +} + func parseOptDefaultBind(d *caddyfile.Dispenser, _ any) (any, error) { d.Next() // consume option name @@ -457,9 +468,8 @@ func parseOptAutoHTTPS(d *caddyfile.Dispenser, _ any) (any, error) { case "disable_redirects": case "disable_certs": case "ignore_loaded_certs": - case "prefer_wildcard": default: - return "", d.Errf("auto_https must be one of 'off', 'disable_redirects', 'disable_certs', 'ignore_loaded_certs', or 'prefer_wildcard'") + return "", d.Errf("auto_https must be one of 'off', 'disable_redirects', 'disable_certs', or 'ignore_loaded_certs'") } } return val, nil @@ -472,6 +482,8 @@ func unmarshalCaddyfileMetricsOptions(d *caddyfile.Dispenser) (any, error) { switch d.Val() { case "per_host": metrics.PerHost = true + case "observe_catchall_hosts": + metrics.ObserveCatchallHosts = true default: return nil, d.Errf("unrecognized servers option '%s'", d.Val()) } @@ -623,3 +635,22 @@ func parseOptECH(d *caddyfile.Dispenser, _ any) (any, error) { return ech, nil } + +func parseOptRenewalWindowRatio(d *caddyfile.Dispenser, _ any) (any, error) { + d.Next() // consume option name + if !d.Next() { + return 0, d.ArgErr() + } + val := d.Val() + ratio, err := strconv.ParseFloat(val, 64) + if err != nil { + return 0, d.Errf("parsing renewal_window_ratio: %v", err) + } + if ratio <= 0 || ratio >= 1 { + return 0, d.Errf("renewal_window_ratio must be between 0 and 1 (exclusive)") + } + if d.Next() { + return 0, d.ArgErr() + } + return ratio, nil +} diff --git a/caddyconfig/httpcaddyfile/options_test.go b/caddyconfig/httpcaddyfile/options_test.go index bc9e88134..524187f30 100644 --- a/caddyconfig/httpcaddyfile/options_test.go +++ b/caddyconfig/httpcaddyfile/options_test.go @@ -1,9 +1,11 @@ package httpcaddyfile import ( + "encoding/json" "testing" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "github.com/caddyserver/caddy/v2/modules/caddytls" _ "github.com/caddyserver/caddy/v2/modules/logging" ) @@ -62,3 +64,105 @@ func TestGlobalLogOptionSyntax(t *testing.T) { } } } + +func TestGlobalResolversOption(t *testing.T) { + tests := []struct { + name string + input string + expectResolvers []string + expectError bool + }{ + { + name: "single resolver", + input: `{ + tls_resolvers 1.1.1.1 + } + example.com { + }`, + expectResolvers: []string{"1.1.1.1"}, + expectError: false, + }, + { + name: "two resolvers", + input: `{ + tls_resolvers 1.1.1.1 8.8.8.8 + } + example.com { + }`, + expectResolvers: []string{"1.1.1.1", "8.8.8.8"}, + expectError: false, + }, + { + name: "multiple resolvers", + input: `{ + tls_resolvers 1.1.1.1 8.8.8.8 9.9.9.9 + } + example.com { + }`, + expectResolvers: []string{"1.1.1.1", "8.8.8.8", "9.9.9.9"}, + expectError: false, + }, + { + name: "no resolvers specified", + input: `{ + } + example.com { + }`, + expectResolvers: nil, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + adapter := caddyfile.Adapter{ + ServerType: ServerType{}, + } + + out, _, err := adapter.Adapt([]byte(tc.input), nil) + + if (err != nil) != tc.expectError { + t.Errorf("error expectation failed. Expected error: %v, got: %v", tc.expectError, err) + return + } + + if tc.expectError { + return + } + + // Parse the output JSON to check resolvers + var config struct { + Apps struct { + TLS *caddytls.TLS `json:"tls"` + } `json:"apps"` + } + + if err := json.Unmarshal(out, &config); err != nil { + t.Errorf("failed to unmarshal output: %v", err) + return + } + + // Check if resolvers match expected + if config.Apps.TLS == nil { + if tc.expectResolvers != nil { + t.Errorf("Expected TLS config with resolvers %v, but TLS config is nil", tc.expectResolvers) + } + return + } + + actualResolvers := config.Apps.TLS.Resolvers + if len(tc.expectResolvers) == 0 && len(actualResolvers) == 0 { + return // Both empty, ok + } + if len(actualResolvers) != len(tc.expectResolvers) { + t.Errorf("Expected %d resolvers, got %d. Expected: %v, got: %v", len(tc.expectResolvers), len(actualResolvers), tc.expectResolvers, actualResolvers) + return + } + for j, expected := range tc.expectResolvers { + if actualResolvers[j] != expected { + t.Errorf("Resolver %d mismatch. Expected: %s, got: %s", j, expected, actualResolvers[j]) + } + } + }) + } +} diff --git a/caddyconfig/httpcaddyfile/pkiapp.go b/caddyconfig/httpcaddyfile/pkiapp.go index 25b6c221c..3f856ff36 100644 --- a/caddyconfig/httpcaddyfile/pkiapp.go +++ b/caddyconfig/httpcaddyfile/pkiapp.go @@ -16,6 +16,7 @@ package httpcaddyfile import ( "slices" + "strconv" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig" @@ -27,14 +28,16 @@ func init() { RegisterGlobalOption("pki", parsePKIApp) } -// parsePKIApp parses the global log option. Syntax: +// parsePKIApp parses the global pki option. Syntax: // // pki { // ca [] { -// name -// root_cn -// intermediate_cn -// intermediate_lifetime +// name +// root_cn +// intermediate_cn +// intermediate_lifetime +// maintenance_interval +// renewal_window_ratio // root { // cert // key @@ -99,6 +102,26 @@ func parsePKIApp(d *caddyfile.Dispenser, existingVal any) (any, error) { } pkiCa.IntermediateLifetime = caddy.Duration(dur) + case "maintenance_interval": + if !d.NextArg() { + return nil, d.ArgErr() + } + dur, err := caddy.ParseDuration(d.Val()) + if err != nil { + return nil, err + } + pkiCa.MaintenanceInterval = caddy.Duration(dur) + + case "renewal_window_ratio": + if !d.NextArg() { + return nil, d.ArgErr() + } + ratio, err := strconv.ParseFloat(d.Val(), 64) + if err != nil || ratio <= 0 || ratio > 1 { + return nil, d.Errf("renewal_window_ratio must be a number in (0, 1], got %s", d.Val()) + } + pkiCa.RenewalWindowRatio = ratio + case "root": if pkiCa.Root == nil { pkiCa.Root = new(caddypki.KeyPair) diff --git a/caddyconfig/httpcaddyfile/pkiapp_test.go b/caddyconfig/httpcaddyfile/pkiapp_test.go new file mode 100644 index 000000000..57662f71e --- /dev/null +++ b/caddyconfig/httpcaddyfile/pkiapp_test.go @@ -0,0 +1,86 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package httpcaddyfile + +import ( + "encoding/json" + "testing" + "time" + + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" +) + +func TestParsePKIApp_maintenanceIntervalAndRenewalWindowRatio(t *testing.T) { + input := `{ + pki { + ca local { + maintenance_interval 5m + renewal_window_ratio 0.15 + } + } + } + :8080 { + } + ` + adapter := caddyfile.Adapter{ServerType: ServerType{}} + out, _, err := adapter.Adapt([]byte(input), nil) + if err != nil { + t.Fatalf("Adapt failed: %v", err) + } + + var cfg struct { + Apps struct { + PKI struct { + CertificateAuthorities map[string]struct { + MaintenanceInterval int64 `json:"maintenance_interval,omitempty"` + RenewalWindowRatio float64 `json:"renewal_window_ratio,omitempty"` + } `json:"certificate_authorities,omitempty"` + } `json:"pki,omitempty"` + } `json:"apps"` + } + if err := json.Unmarshal(out, &cfg); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + + ca, ok := cfg.Apps.PKI.CertificateAuthorities["local"] + if !ok { + t.Fatal("expected certificate_authorities.local to exist") + } + wantInterval := 5 * time.Minute.Nanoseconds() + if ca.MaintenanceInterval != wantInterval { + t.Errorf("maintenance_interval = %d, want %d (5m)", ca.MaintenanceInterval, wantInterval) + } + if ca.RenewalWindowRatio != 0.15 { + t.Errorf("renewal_window_ratio = %v, want 0.15", ca.RenewalWindowRatio) + } +} + +func TestParsePKIApp_renewalWindowRatioInvalid(t *testing.T) { + input := `{ + pki { + ca local { + renewal_window_ratio 1.5 + } + } + } + :8080 { + } + ` + adapter := caddyfile.Adapter{ServerType: ServerType{}} + _, _, err := adapter.Adapt([]byte(input), nil) + if err == nil { + t.Error("expected error for renewal_window_ratio > 1") + } +} diff --git a/caddyconfig/httpcaddyfile/serveroptions.go b/caddyconfig/httpcaddyfile/serveroptions.go index 9431f1aed..1febf4097 100644 --- a/caddyconfig/httpcaddyfile/serveroptions.go +++ b/caddyconfig/httpcaddyfile/serveroptions.go @@ -36,26 +36,30 @@ type serverOptions struct { ListenerAddress string // These will all map 1:1 to the caddyhttp.Server struct - Name string - ListenerWrappersRaw []json.RawMessage - ReadTimeout caddy.Duration - ReadHeaderTimeout caddy.Duration - WriteTimeout caddy.Duration - IdleTimeout caddy.Duration - KeepAliveInterval caddy.Duration - KeepAliveIdle caddy.Duration - KeepAliveCount int - MaxHeaderBytes int - EnableFullDuplex bool - Protocols []string - StrictSNIHost *bool - TrustedProxiesRaw json.RawMessage - TrustedProxiesStrict int - TrustedProxiesUnix bool - ClientIPHeaders []string - ShouldLogCredentials bool - Metrics *caddyhttp.Metrics - Trace bool // TODO: EXPERIMENTAL + Name string + ListenerWrappersRaw []json.RawMessage + PacketConnWrappersRaw []json.RawMessage + ReadTimeout caddy.Duration + ReadHeaderTimeout caddy.Duration + WriteTimeout caddy.Duration + IdleTimeout caddy.Duration + KeepAliveInterval caddy.Duration + KeepAliveIdle caddy.Duration + KeepAliveCount int + MaxHeaderBytes int + EnableFullDuplex bool + Protocols []string + StrictSNIHost *bool + TrustedProxiesRaw json.RawMessage + TrustedProxiesStrict int + TrustedProxiesUnix bool + ClientIPHeaders []string + ShouldLogCredentials bool + Metrics *caddyhttp.Metrics + Trace bool // TODO: EXPERIMENTAL + // If set, overrides whether QUIC listeners allow 0-RTT (early data). + // If nil, the default behavior is used (currently allowed). + Allow0RTT *bool } func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) { @@ -99,6 +103,26 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) { serverOpts.ListenerWrappersRaw = append(serverOpts.ListenerWrappersRaw, jsonListenerWrapper) } + case "packet_conn_wrappers": + for nesting := d.Nesting(); d.NextBlock(nesting); { + modID := "caddy.packetconns." + d.Val() + unm, err := caddyfile.UnmarshalModule(d, modID) + if err != nil { + return nil, err + } + packetConnWrapper, ok := unm.(caddy.PacketConnWrapper) + if !ok { + return nil, fmt.Errorf("module %s (%T) is not a packet conn wrapper", modID, unm) + } + jsonPacketConnWrapper := caddyconfig.JSONModuleObject( + packetConnWrapper, + "wrapper", + packetConnWrapper.(caddy.Module).CaddyModule().ID.Name(), + nil, + ) + serverOpts.PacketConnWrappersRaw = append(serverOpts.PacketConnWrappersRaw, jsonPacketConnWrapper) + } + case "timeouts": for nesting := d.Nesting(); d.NextBlock(nesting); { switch d.Val() { @@ -288,6 +312,17 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) { } serverOpts.Trace = true + case "0rtt": + // only supports "off" for now + if !d.NextArg() { + return nil, d.ArgErr() + } + if d.Val() != "off" { + return nil, d.Errf("unsupported 0rtt argument '%s' (only 'off' is supported)", d.Val()) + } + boolVal := false + serverOpts.Allow0RTT = &boolVal + default: return nil, d.Errf("unrecognized servers option '%s'", d.Val()) } @@ -335,6 +370,7 @@ func applyServerOptions( // set all the options server.ListenerWrappersRaw = opts.ListenerWrappersRaw + server.PacketConnWrappersRaw = opts.PacketConnWrappersRaw server.ReadTimeout = opts.ReadTimeout server.ReadHeaderTimeout = opts.ReadHeaderTimeout server.WriteTimeout = opts.WriteTimeout @@ -351,6 +387,7 @@ func applyServerOptions( server.TrustedProxiesStrict = opts.TrustedProxiesStrict server.TrustedProxiesUnix = opts.TrustedProxiesUnix server.Metrics = opts.Metrics + server.Allow0RTT = opts.Allow0RTT if opts.ShouldLogCredentials { if server.Logs == nil { server.Logs = new(caddyhttp.ServerLogConfig) diff --git a/caddyconfig/httpcaddyfile/tlsapp.go b/caddyconfig/httpcaddyfile/tlsapp.go index 30948f84f..ddec0b941 100644 --- a/caddyconfig/httpcaddyfile/tlsapp.go +++ b/caddyconfig/httpcaddyfile/tlsapp.go @@ -92,26 +92,8 @@ func (st ServerType) buildTLSApp( tlsApp.Automation.Policies = append(tlsApp.Automation.Policies, catchAllAP) } - var wildcardHosts []string // collect all hosts that have a wildcard in them, and aren't HTTP forcedAutomatedNames := make(map[string]struct{}) // explicitly configured to be automated, even if covered by a wildcard - for _, p := range pairings { - var addresses []string - for _, addressWithProtocols := range p.addressesWithProtocols { - addresses = append(addresses, addressWithProtocols.address) - } - if !listenersUseAnyPortOtherThan(addresses, httpPort) { - continue - } - for _, sblock := range p.serverBlocks { - for _, addr := range sblock.parsedKeys { - if strings.HasPrefix(addr.Host, "*.") { - wildcardHosts = append(wildcardHosts, addr.Host[2:]) - } - } - } - } - for _, p := range pairings { // avoid setting up TLS automation policies for a server that is HTTP-only var addresses []string @@ -135,12 +117,6 @@ func (st ServerType) buildTLSApp( return nil, warnings, err } - // make a plain copy so we can compare whether we made any changes - apCopy, err := newBaseAutomationPolicy(options, warnings, true) - if err != nil { - return nil, warnings, err - } - sblockHosts := sblock.hostsFromKeys(false) if len(sblockHosts) == 0 && catchAllAP != nil { ap = catchAllAP @@ -167,6 +143,12 @@ func (st ServerType) buildTLSApp( ap.KeyType = keyTypeVals[0].Value.(string) } + if renewalWindowRatioVals, ok := sblock.pile["tls.renewal_window_ratio"]; ok { + ap.RenewalWindowRatio = renewalWindowRatioVals[0].Value.(float64) + } else if globalRenewalWindowRatio, ok := options["renewal_window_ratio"]; ok { + ap.RenewalWindowRatio = globalRenewalWindowRatio.(float64) + } + // certificate issuers if issuerVals, ok := sblock.pile["tls.cert_issuer"]; ok { var issuers []certmagic.Issuer @@ -253,16 +235,6 @@ func (st ServerType) buildTLSApp( hostsNotHTTP := sblock.hostsFromKeysNotHTTP(httpPort) sort.Strings(hostsNotHTTP) // solely for deterministic test results - // if the we prefer wildcards and the AP is unchanged, - // then we can skip this AP because it should be covered - // by an AP with a wildcard - if slices.Contains(autoHTTPS, "prefer_wildcard") { - if hostsCoveredByWildcard(hostsNotHTTP, wildcardHosts) && - reflect.DeepEqual(ap, apCopy) { - continue - } - } - // associate our new automation policy with this server block's hosts ap.SubjectsRaw = hostsNotHTTP @@ -362,6 +334,11 @@ func (st ServerType) buildTLSApp( tlsApp.DNSRaw = caddyconfig.JSONModuleObject(globalDNS, "name", globalDNS.(caddy.Module).CaddyModule().ID.Name(), nil) } + // set up "global" (to the TLS app) DNS resolvers config + if globalResolvers, ok := options["tls_resolvers"]; ok && globalResolvers != nil { + tlsApp.Resolvers = globalResolvers.([]string) + } + // set up ECH from Caddyfile options if ech, ok := options["ech"].(*caddytls.ECH); ok { tlsApp.EncryptedClientHello = ech @@ -576,9 +553,8 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e if acmeIssuer.Challenges.DNS == nil { acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig) } - // If global `dns` is set, do NOT set provider in issuer, just set empty dns config - if globalDNS == nil && acmeIssuer.Challenges.DNS.ProviderRaw == nil { - // Set a global DNS provider if `acme_dns` is set and `dns` is NOT set + if globalACMEDNS != nil && acmeIssuer.Challenges.DNS.ProviderRaw == nil { + // Set a global DNS provider if `acme_dns` is set acmeIssuer.Challenges.DNS.ProviderRaw = caddyconfig.JSONModuleObject(globalACMEDNS, "name", globalACMEDNS.(caddy.Module).CaddyModule().ID.Name(), nil) } } @@ -624,6 +600,15 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e if globalCertLifetime != nil && acmeIssuer.CertificateLifetime == 0 { acmeIssuer.CertificateLifetime = globalCertLifetime.(caddy.Duration) } + // apply global resolvers if DNS challenge is configured and resolvers are not already set + globalResolvers := options["tls_resolvers"] + if globalResolvers != nil && acmeIssuer.Challenges != nil && acmeIssuer.Challenges.DNS != nil { + // Check if DNS challenge is actually configured + hasDNSChallenge := globalACMEDNSok || acmeIssuer.Challenges.DNS.ProviderRaw != nil + if hasDNSChallenge && len(acmeIssuer.Challenges.DNS.Resolvers) == 0 { + acmeIssuer.Challenges.DNS.Resolvers = globalResolvers.([]string) + } + } return nil } @@ -641,7 +626,8 @@ func newBaseAutomationPolicy( _, hasLocalCerts := options["local_certs"] keyType, hasKeyType := options["key_type"] ocspStapling, hasOCSPStapling := options["ocsp_stapling"] - hasGlobalAutomationOpts := hasIssuers || hasLocalCerts || hasKeyType || hasOCSPStapling + renewalWindowRatio, hasRenewalWindowRatio := options["renewal_window_ratio"] + hasGlobalAutomationOpts := hasIssuers || hasLocalCerts || hasKeyType || hasOCSPStapling || hasRenewalWindowRatio globalACMECA := options["acme_ca"] globalACMECARoot := options["acme_ca_root"] @@ -688,6 +674,10 @@ func newBaseAutomationPolicy( ap.OCSPOverrides = ocspConfig.ResponderOverrides } + if hasRenewalWindowRatio { + ap.RenewalWindowRatio = renewalWindowRatio.(float64) + } + return ap, nil } @@ -849,20 +839,3 @@ func automationPolicyHasAllPublicNames(ap *caddytls.AutomationPolicy) bool { func isTailscaleDomain(name string) bool { return strings.HasSuffix(strings.ToLower(name), ".ts.net") } - -func hostsCoveredByWildcard(hosts []string, wildcards []string) bool { - if len(hosts) == 0 || len(wildcards) == 0 { - return false - } - for _, host := range hosts { - for _, wildcard := range wildcards { - if strings.HasPrefix(host, "*.") { - continue - } - if certmagic.MatchWildcard(host, "*."+wildcard) { - return true - } - } - } - return false -} diff --git a/caddyconfig/httploader.go b/caddyconfig/httploader.go index a25041a34..a0a46460a 100644 --- a/caddyconfig/httploader.go +++ b/caddyconfig/httploader.go @@ -136,7 +136,7 @@ func (hl HTTPLoader) LoadConfig(ctx caddy.Context) ([]byte, error) { } func attemptHttpCall(client *http.Client, request *http.Request) (*http.Response, error) { - resp, err := client.Do(request) + resp, err := client.Do(request) //nolint:gosec // no SSRF; comes from trusted config if err != nil { return nil, fmt.Errorf("problem calling http loader url: %v", err) } else if resp.StatusCode < 200 || resp.StatusCode > 499 { @@ -151,7 +151,7 @@ func doHttpCallWithRetries(ctx caddy.Context, client *http.Client, request *http var err error const maxAttempts = 10 - for i := 0; i < maxAttempts; i++ { + for i := range maxAttempts { resp, err = attemptHttpCall(client, request) if err != nil && i < maxAttempts-1 { select { diff --git a/caddyconfig/load.go b/caddyconfig/load.go index 9422d2fbb..d2498ed6f 100644 --- a/caddyconfig/load.go +++ b/caddyconfig/load.go @@ -106,7 +106,7 @@ func (adminLoad) handleLoad(w http.ResponseWriter, r *http.Request) error { if err != nil { caddy.Log().Named("admin.api.load").Error(err.Error()) } - _, _ = w.Write(respBody) + _, _ = w.Write(respBody) //nolint:gosec // false positive: no XSS here } body = result } diff --git a/caddytest/caddytest.go b/caddytest/caddytest.go index 7b56bb281..26fa0533e 100644 --- a/caddytest/caddytest.go +++ b/caddytest/caddytest.go @@ -187,7 +187,7 @@ func (tc *Tester) initServer(rawConfig string, configType string) error { req.Header.Add("Content-Type", "text/"+configType) } - res, err := client.Do(req) + res, err := client.Do(req) //nolint:gosec // no SSRF because URL is hard-coded to localhost, and port comes from config if err != nil { tc.t.Errorf("unable to contact caddy server. %s", err) return err @@ -279,7 +279,7 @@ func validateTestPrerequisites(tc *Tester) error { return err } tc.t.Cleanup(func() { - os.Remove(f.Name()) + os.Remove(f.Name()) //nolint:gosec // false positive, filename comes from std lib, no path traversal }) if _, err := fmt.Fprintf(f, initConfig, tc.config.AdminPort); err != nil { return err @@ -362,6 +362,8 @@ func CreateTestingTransport() *http.Transport { // AssertLoadError will load a config and expect an error func AssertLoadError(t *testing.T, rawConfig string, configType string, expectedError string) { + t.Helper() + tc := NewTester(t) err := tc.initServer(rawConfig, configType) @@ -372,6 +374,8 @@ func AssertLoadError(t *testing.T, rawConfig string, configType string, expected // AssertRedirect makes a request and asserts the redirection happens func (tc *Tester) AssertRedirect(requestURI string, expectedToLocation string, expectedStatusCode int) *http.Response { + tc.t.Helper() + redirectPolicyFunc := func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } @@ -409,6 +413,8 @@ func (tc *Tester) AssertRedirect(requestURI string, expectedToLocation string, e // CompareAdapt adapts a config and then compares it against an expected result func CompareAdapt(t testing.TB, filename, rawConfig string, adapterName string, expectedResponse string) bool { + t.Helper() + cfgAdapter := caddyconfig.GetAdapter(adapterName) if cfgAdapter == nil { t.Logf("unrecognized config adapter '%s'", adapterName) @@ -468,6 +474,8 @@ func CompareAdapt(t testing.TB, filename, rawConfig string, adapterName string, // AssertAdapt adapts a config and then tests it against an expected result func AssertAdapt(t testing.TB, rawConfig string, adapterName string, expectedResponse string) { + t.Helper() + ok := CompareAdapt(t, "Caddyfile", rawConfig, adapterName, expectedResponse) if !ok { t.Fail() @@ -496,7 +504,9 @@ func applyHeaders(t testing.TB, req *http.Request, requestHeaders []string) { // AssertResponseCode will execute the request and verify the status code, returns a response for additional assertions func (tc *Tester) AssertResponseCode(req *http.Request, expectedStatusCode int) *http.Response { - resp, err := tc.Client.Do(req) + tc.t.Helper() + + resp, err := tc.Client.Do(req) //nolint:gosec // no SSRFs demonstrated if err != nil { tc.t.Fatalf("failed to call server %s", err) } @@ -510,6 +520,8 @@ func (tc *Tester) AssertResponseCode(req *http.Request, expectedStatusCode int) // AssertResponse request a URI and assert the status code and the body contains a string func (tc *Tester) AssertResponse(req *http.Request, expectedStatusCode int, expectedBody string) (*http.Response, string) { + tc.t.Helper() + resp := tc.AssertResponseCode(req, expectedStatusCode) defer resp.Body.Close() @@ -531,6 +543,8 @@ func (tc *Tester) AssertResponse(req *http.Request, expectedStatusCode int, expe // AssertGetResponse GET a URI and expect a statusCode and body text func (tc *Tester) AssertGetResponse(requestURI string, expectedStatusCode int, expectedBody string) (*http.Response, string) { + tc.t.Helper() + req, err := http.NewRequest("GET", requestURI, nil) if err != nil { tc.t.Fatalf("unable to create request %s", err) @@ -541,6 +555,8 @@ func (tc *Tester) AssertGetResponse(requestURI string, expectedStatusCode int, e // AssertDeleteResponse request a URI and expect a statusCode and body text func (tc *Tester) AssertDeleteResponse(requestURI string, expectedStatusCode int, expectedBody string) (*http.Response, string) { + tc.t.Helper() + req, err := http.NewRequest("DELETE", requestURI, nil) if err != nil { tc.t.Fatalf("unable to create request %s", err) @@ -551,6 +567,8 @@ func (tc *Tester) AssertDeleteResponse(requestURI string, expectedStatusCode int // AssertPostResponseBody POST to a URI and assert the response code and body func (tc *Tester) AssertPostResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) { + tc.t.Helper() + req, err := http.NewRequest("POST", requestURI, requestBody) if err != nil { tc.t.Errorf("failed to create request %s", err) @@ -564,6 +582,8 @@ func (tc *Tester) AssertPostResponseBody(requestURI string, requestHeaders []str // AssertPutResponseBody PUT to a URI and assert the response code and body func (tc *Tester) AssertPutResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) { + tc.t.Helper() + req, err := http.NewRequest("PUT", requestURI, requestBody) if err != nil { tc.t.Errorf("failed to create request %s", err) @@ -577,6 +597,8 @@ func (tc *Tester) AssertPutResponseBody(requestURI string, requestHeaders []stri // AssertPatchResponseBody PATCH to a URI and assert the response code and body func (tc *Tester) AssertPatchResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) { + tc.t.Helper() + req, err := http.NewRequest("PATCH", requestURI, requestBody) if err != nil { tc.t.Errorf("failed to create request %s", err) diff --git a/caddytest/caddytest_test.go b/caddytest/caddytest_test.go index a9d5da936..31266fa8f 100644 --- a/caddytest/caddytest_test.go +++ b/caddytest/caddytest_test.go @@ -1,6 +1,7 @@ package caddytest import ( + "bytes" "net/http" "strings" "testing" @@ -126,3 +127,118 @@ func TestLoadUnorderedJSON(t *testing.T) { } tester.AssertResponseCode(req, 200) } + +func TestCheckID(t *testing.T) { + tester := NewTester(t) + tester.InitServer(`{ + "admin": { + "listen": "localhost:2999" + }, + "apps": { + "http": { + "http_port": 9080, + "servers": { + "s_server": { + "@id": "s_server", + "listen": [ + ":9080" + ], + "routes": [ + { + "handle": [ + { + "handler": "static_response", + "body": "Hello" + } + ] + } + ] + } + } + } + } + } + `, "json") + headers := []string{"Content-Type:application/json"} + sServer1 := []byte(`{"@id":"s_server","listen":[":9080"],"routes":[{"@id":"route1","handle":[{"handler":"static_response","body":"Hello 2"}]}]}`) + + // PUT to an existing ID should fail with a 409 conflict + tester.AssertPutResponseBody( + "http://localhost:2999/id/s_server", + headers, + bytes.NewBuffer(sServer1), + 409, + `{"error":"[/config/apps/http/servers/s_server] key already exists: s_server"}`+"\n") + + // POST replaces the object fully + tester.AssertPostResponseBody( + "http://localhost:2999/id/s_server", + headers, + bytes.NewBuffer(sServer1), + 200, + "") + + // Verify the server is running the new route + tester.AssertGetResponse( + "http://localhost:9080/", + 200, + "Hello 2") + + // Update the existing route to ensure IDs are handled correctly when replaced + tester.AssertPostResponseBody( + "http://localhost:2999/id/s_server", + headers, + bytes.NewBuffer([]byte(`{"@id":"s_server","listen":[":9080"],"routes":[{"@id":"route1","handle":[{"handler":"static_response","body":"Hello2"}],"match":[{"path":["/route_1/*"]}]}]}`)), + 200, + "") + + sServer2 := []byte(`{"@id":"s_server","listen":[":9080"],"routes":[{"@id":"route1","handle":[{"handler":"static_response","body":"Hello2"}],"match":[{"path":["/route_1/*"]}]}]}`) + + // Identical patch should succeed and return 200 (config is unchanged branch) + tester.AssertPatchResponseBody( + "http://localhost:2999/id/s_server", + headers, + bytes.NewBuffer(sServer2), + 200, + "") + + route2 := []byte(`{"@id":"route2","handle": [{"handler": "static_response","body": "route2"}],"match":[{"path":["/route_2/*"]}]}`) + + // Put a new route2 object before the route1 object due to the path of /id/route1 + // Being translated to: /config/apps/http/servers/s_server/routes/0 + tester.AssertPutResponseBody( + "http://localhost:2999/id/route1", + headers, + bytes.NewBuffer(route2), + 200, + "") + + // Verify that the whole config looks correct, now containing both route1 and route2 + tester.AssertGetResponse( + "http://localhost:2999/config/", + 200, + `{"admin":{"listen":"localhost:2999"},"apps":{"http":{"http_port":9080,"servers":{"s_server":{"@id":"s_server","listen":[":9080"],"routes":[{"@id":"route2","handle":[{"body":"route2","handler":"static_response"}],"match":[{"path":["/route_2/*"]}]},{"@id":"route1","handle":[{"body":"Hello2","handler":"static_response"}],"match":[{"path":["/route_1/*"]}]}]}}}}}`+"\n") + + // Try to add another copy of route2 using POST to test duplicate ID handling + // Since the first route2 ended up at array index 0, and we are appending to the array, the index for the new element would be 2 + tester.AssertPostResponseBody( + "http://localhost:2999/id/route2", + headers, + bytes.NewBuffer(route2), + 400, + `{"error":"indexing config: duplicate ID 'route2' found at /config/apps/http/servers/s_server/routes/0 and /config/apps/http/servers/s_server/routes/2"}`+"\n") + + // Use PATCH to modify an existing object successfully + tester.AssertPatchResponseBody( + "http://localhost:2999/id/route1", + headers, + bytes.NewBuffer([]byte(`{"@id":"route1","handle":[{"handler":"static_response","body":"route1"}],"match":[{"path":["/route_1/*"]}]}`)), + 200, + "") + + // Verify the PATCH updated the server state + tester.AssertGetResponse( + "http://localhost:9080/route_1/", + 200, + "route1") +} diff --git a/caddytest/integration/acme_test.go b/caddytest/integration/acme_test.go index f10aef6a8..5473f9a81 100644 --- a/caddytest/integration/acme_test.go +++ b/caddytest/integration/acme_test.go @@ -51,7 +51,7 @@ func TestACMEServerWithDefaults(t *testing.T) { Client: &acme.Client{ Directory: "https://acme.localhost:9443/acme/local/directory", HTTPClient: tester.Client, - Logger: slog.New(zapslog.NewHandler(logger.Core())), + Logger: slog.New(zapslog.NewHandler(logger.Core(), zapslog.WithName("acmez"))), }, ChallengeSolvers: map[string]acmez.Solver{ acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger}, @@ -120,7 +120,7 @@ func TestACMEServerWithMismatchedChallenges(t *testing.T) { Client: &acme.Client{ Directory: "https://acme.localhost:9443/acme/local/directory", HTTPClient: tester.Client, - Logger: slog.New(zapslog.NewHandler(logger.Core())), + Logger: slog.New(zapslog.NewHandler(logger.Core(), zapslog.WithName("acmez"))), }, ChallengeSolvers: map[string]acmez.Solver{ acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger}, diff --git a/caddytest/integration/acmeserver_test.go b/caddytest/integration/acmeserver_test.go index d6a9ba005..06deaa0ef 100644 --- a/caddytest/integration/acmeserver_test.go +++ b/caddytest/integration/acmeserver_test.go @@ -127,7 +127,7 @@ func TestACMEServerAllowPolicy(t *testing.T) { _, err := client.ObtainCertificateForSANs(ctx, account, certPrivateKey, []string{"not-matching.localhost"}) if err == nil { t.Errorf("obtaining certificate for 'not-matching.localhost' domain") - } else if err != nil && !strings.Contains(err.Error(), "urn:ietf:params:acme:error:rejectedIdentifier") { + } else if !strings.Contains(err.Error(), "urn:ietf:params:acme:error:rejectedIdentifier") { t.Logf("unexpected error: %v", err) } } @@ -200,7 +200,7 @@ func TestACMEServerDenyPolicy(t *testing.T) { _, err := client.ObtainCertificateForSANs(ctx, account, certPrivateKey, []string{"deny.localhost"}) if err == nil { t.Errorf("obtaining certificate for 'deny.localhost' domain") - } else if err != nil && !strings.Contains(err.Error(), "urn:ietf:params:acme:error:rejectedIdentifier") { + } else if !strings.Contains(err.Error(), "urn:ietf:params:acme:error:rejectedIdentifier") { t.Logf("unexpected error: %v", err) } } diff --git a/caddytest/integration/autohttps_test.go b/caddytest/integration/autohttps_test.go index 1dbdbcee2..fdfb5a93e 100644 --- a/caddytest/integration/autohttps_test.go +++ b/caddytest/integration/autohttps_test.go @@ -143,3 +143,26 @@ func TestAutoHTTPRedirectsInsertedBeforeUserDefinedCatchAllWithNoExplicitHTTPSit tester.AssertGetResponse("http://foo.localhost:9080/", 200, "Foo") tester.AssertGetResponse("http://baz.localhost:9080/", 200, "Foo") } + +func TestAutoHTTPSRedirectSortingExactMatchOverWildcard(t *testing.T) { + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port 9080 + https_port 9443 + local_certs + } + *.localhost:10443 { + respond "Wildcard" + } + dev.localhost { + respond "Exact" + } + `, "caddyfile") + + tester.AssertRedirect("http://dev.localhost:9080/", "https://dev.localhost/", http.StatusPermanentRedirect) + + tester.AssertRedirect("http://foo.localhost:9080/", "https://foo.localhost:10443/", http.StatusPermanentRedirect) +} diff --git a/caddytest/integration/caddyfile_adapt/forward_auth_authelia.caddyfiletest b/caddytest/integration/caddyfile_adapt/forward_auth_authelia.caddyfiletest index 240bdc62f..831d7d2fb 100644 --- a/caddytest/integration/caddyfile_adapt/forward_auth_authelia.caddyfiletest +++ b/caddytest/integration/caddyfile_adapt/forward_auth_authelia.caddyfiletest @@ -46,6 +46,18 @@ app.example.com { } ] }, + { + "handle": [ + { + "handler": "headers", + "request": { + "delete": [ + "Remote-Email" + ] + } + } + ] + }, { "handle": [ { @@ -73,6 +85,18 @@ app.example.com { } ] }, + { + "handle": [ + { + "handler": "headers", + "request": { + "delete": [ + "Remote-Groups" + ] + } + } + ] + }, { "handle": [ { @@ -100,6 +124,18 @@ app.example.com { } ] }, + { + "handle": [ + { + "handler": "headers", + "request": { + "delete": [ + "Remote-Name" + ] + } + } + ] + }, { "handle": [ { @@ -127,6 +163,18 @@ app.example.com { } ] }, + { + "handle": [ + { + "handler": "headers", + "request": { + "delete": [ + "Remote-User" + ] + } + } + ] + }, { "handle": [ { @@ -200,4 +248,4 @@ app.example.com { } } } -} \ No newline at end of file +} diff --git a/caddytest/integration/caddyfile_adapt/forward_auth_copy_headers_strip.caddyfiletest b/caddytest/integration/caddyfile_adapt/forward_auth_copy_headers_strip.caddyfiletest new file mode 100644 index 000000000..887bef0ab --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/forward_auth_copy_headers_strip.caddyfiletest @@ -0,0 +1,146 @@ +:8080 + +forward_auth 127.0.0.1:9091 { + uri / + copy_headers X-User-Id X-User-Role +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":8080" + ], + "routes": [ + { + "handle": [ + { + "handle_response": [ + { + "match": { + "status_code": [ + 2 + ] + }, + "routes": [ + { + "handle": [ + { + "handler": "vars" + } + ] + }, + { + "handle": [ + { + "handler": "headers", + "request": { + "delete": [ + "X-User-Id" + ] + } + } + ] + }, + { + "handle": [ + { + "handler": "headers", + "request": { + "set": { + "X-User-Id": [ + "{http.reverse_proxy.header.X-User-Id}" + ] + } + } + } + ], + "match": [ + { + "not": [ + { + "vars": { + "{http.reverse_proxy.header.X-User-Id}": [ + "" + ] + } + } + ] + } + ] + }, + { + "handle": [ + { + "handler": "headers", + "request": { + "delete": [ + "X-User-Role" + ] + } + } + ] + }, + { + "handle": [ + { + "handler": "headers", + "request": { + "set": { + "X-User-Role": [ + "{http.reverse_proxy.header.X-User-Role}" + ] + } + } + } + ], + "match": [ + { + "not": [ + { + "vars": { + "{http.reverse_proxy.header.X-User-Role}": [ + "" + ] + } + } + ] + } + ] + } + ] + } + ], + "handler": "reverse_proxy", + "headers": { + "request": { + "set": { + "X-Forwarded-Method": [ + "{http.request.method}" + ], + "X-Forwarded-Uri": [ + "{http.request.uri}" + ] + } + } + }, + "rewrite": { + "method": "GET", + "uri": "/" + }, + "upstreams": [ + { + "dial": "127.0.0.1:9091" + } + ] + } + ] + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/caddytest/integration/caddyfile_adapt/forward_auth_rename_headers.caddyfiletest b/caddytest/integration/caddyfile_adapt/forward_auth_rename_headers.caddyfiletest index c2be2ed43..5d61e5ff2 100644 --- a/caddytest/integration/caddyfile_adapt/forward_auth_rename_headers.caddyfiletest +++ b/caddytest/integration/caddyfile_adapt/forward_auth_rename_headers.caddyfiletest @@ -35,6 +35,18 @@ forward_auth localhost:9000 { } ] }, + { + "handle": [ + { + "handler": "headers", + "request": { + "delete": [ + "1" + ] + } + } + ] + }, { "handle": [ { @@ -62,6 +74,18 @@ forward_auth localhost:9000 { } ] }, + { + "handle": [ + { + "handler": "headers", + "request": { + "delete": [ + "B" + ] + } + } + ] + }, { "handle": [ { @@ -89,6 +113,18 @@ forward_auth localhost:9000 { } ] }, + { + "handle": [ + { + "handler": "headers", + "request": { + "delete": [ + "3" + ] + } + } + ] + }, { "handle": [ { @@ -116,6 +152,18 @@ forward_auth localhost:9000 { } ] }, + { + "handle": [ + { + "handler": "headers", + "request": { + "delete": [ + "D" + ] + } + } + ] + }, { "handle": [ { @@ -143,6 +191,18 @@ forward_auth localhost:9000 { } ] }, + { + "handle": [ + { + "handler": "headers", + "request": { + "delete": [ + "5" + ] + } + } + ] + }, { "handle": [ { @@ -203,4 +263,4 @@ forward_auth localhost:9000 { } } } -} \ No newline at end of file +} diff --git a/caddytest/integration/caddyfile_adapt/global_options_resolvers.caddyfiletest b/caddytest/integration/caddyfile_adapt/global_options_resolvers.caddyfiletest new file mode 100644 index 000000000..7043b5da3 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/global_options_resolvers.caddyfiletest @@ -0,0 +1,77 @@ +{ + email test@example.com + dns mock + tls_resolvers 1.1.1.1 8.8.8.8 + acme_dns +} + +example.com { +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "example.com" + ] + } + ], + "terminal": true + } + ] + } + } + }, + "tls": { + "automation": { + "policies": [ + { + "issuers": [ + { + "challenges": { + "dns": { + "resolvers": [ + "1.1.1.1", + "8.8.8.8" + ] + } + }, + "email": "test@example.com", + "module": "acme" + }, + { + "ca": "https://acme.zerossl.com/v2/DV90", + "challenges": { + "dns": { + "resolvers": [ + "1.1.1.1", + "8.8.8.8" + ] + } + }, + "email": "test@example.com", + "module": "acme" + } + ] + } + ] + }, + "dns": { + "name": "mock" + }, + "resolvers": [ + "1.1.1.1", + "8.8.8.8" + ] + } + } +} diff --git a/caddytest/integration/caddyfile_adapt/global_options_resolvers_http_challenge.caddyfiletest b/caddytest/integration/caddyfile_adapt/global_options_resolvers_http_challenge.caddyfiletest new file mode 100644 index 000000000..d375dc711 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/global_options_resolvers_http_challenge.caddyfiletest @@ -0,0 +1,38 @@ +{ + tls_resolvers 1.1.1.1 8.8.8.8 +} + +example.com { +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "example.com" + ] + } + ], + "terminal": true + } + ] + } + } + }, + "tls": { + "resolvers": [ + "1.1.1.1", + "8.8.8.8" + ] + } + } +} diff --git a/caddytest/integration/caddyfile_adapt/global_options_resolvers_local_dns_inherit.caddyfiletest b/caddytest/integration/caddyfile_adapt/global_options_resolvers_local_dns_inherit.caddyfiletest new file mode 100644 index 000000000..20385f84b --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/global_options_resolvers_local_dns_inherit.caddyfiletest @@ -0,0 +1,72 @@ +{ + email test@example.com + dns mock + tls_resolvers 1.1.1.1 8.8.8.8 +} + +example.com { + tls { + dns mock + } +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "example.com" + ] + } + ], + "terminal": true + } + ] + } + } + }, + "tls": { + "automation": { + "policies": [ + { + "subjects": [ + "example.com" + ], + "issuers": [ + { + "challenges": { + "dns": { + "provider": { + "name": "mock" + }, + "resolvers": [ + "1.1.1.1", + "8.8.8.8" + ] + } + }, + "email": "test@example.com", + "module": "acme" + } + ] + } + ] + }, + "dns": { + "name": "mock" + }, + "resolvers": [ + "1.1.1.1", + "8.8.8.8" + ] + } + } +} diff --git a/caddytest/integration/caddyfile_adapt/global_options_resolvers_local_override.caddyfiletest b/caddytest/integration/caddyfile_adapt/global_options_resolvers_local_override.caddyfiletest new file mode 100644 index 000000000..27f7d09d3 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/global_options_resolvers_local_override.caddyfiletest @@ -0,0 +1,98 @@ +{ + email test@example.com + dns mock + tls_resolvers 1.1.1.1 8.8.8.8 + acme_dns +} + +example.com { + tls { + resolvers 9.9.9.9 + } +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "example.com" + ] + } + ], + "terminal": true + } + ] + } + } + }, + "tls": { + "automation": { + "policies": [ + { + "subjects": [ + "example.com" + ], + "issuers": [ + { + "challenges": { + "dns": { + "resolvers": [ + "9.9.9.9" + ] + } + }, + "email": "test@example.com", + "module": "acme" + } + ] + }, + { + "issuers": [ + { + "challenges": { + "dns": { + "resolvers": [ + "1.1.1.1", + "8.8.8.8" + ] + } + }, + "email": "test@example.com", + "module": "acme" + }, + { + "ca": "https://acme.zerossl.com/v2/DV90", + "challenges": { + "dns": { + "resolvers": [ + "1.1.1.1", + "8.8.8.8" + ] + } + }, + "email": "test@example.com", + "module": "acme" + } + ] + } + ] + }, + "dns": { + "name": "mock" + }, + "resolvers": [ + "1.1.1.1", + "8.8.8.8" + ] + } + } +} diff --git a/caddytest/integration/caddyfile_adapt/global_options_resolvers_mixed.caddyfiletest b/caddytest/integration/caddyfile_adapt/global_options_resolvers_mixed.caddyfiletest new file mode 100644 index 000000000..3a4b5571c --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/global_options_resolvers_mixed.caddyfiletest @@ -0,0 +1,112 @@ +{ + email test@example.com + dns mock + tls_resolvers 1.1.1.1 8.8.8.8 + acme_dns +} + +site1.example.com { +} + +site2.example.com { + tls { + resolvers 9.9.9.9 8.8.4.4 + } +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "site1.example.com" + ] + } + ], + "terminal": true + }, + { + "match": [ + { + "host": [ + "site2.example.com" + ] + } + ], + "terminal": true + } + ] + } + } + }, + "tls": { + "automation": { + "policies": [ + { + "subjects": [ + "site2.example.com" + ], + "issuers": [ + { + "challenges": { + "dns": { + "resolvers": [ + "9.9.9.9", + "8.8.4.4" + ] + } + }, + "email": "test@example.com", + "module": "acme" + } + ] + }, + { + "issuers": [ + { + "challenges": { + "dns": { + "resolvers": [ + "1.1.1.1", + "8.8.8.8" + ] + } + }, + "email": "test@example.com", + "module": "acme" + }, + { + "ca": "https://acme.zerossl.com/v2/DV90", + "challenges": { + "dns": { + "resolvers": [ + "1.1.1.1", + "8.8.8.8" + ] + } + }, + "email": "test@example.com", + "module": "acme" + } + ] + } + ] + }, + "dns": { + "name": "mock" + }, + "resolvers": [ + "1.1.1.1", + "8.8.8.8" + ] + } + } +} diff --git a/caddytest/integration/caddyfile_adapt/global_server_options_single.caddyfiletest b/caddytest/integration/caddyfile_adapt/global_server_options_single.caddyfiletest index 6b2ffaec4..4991b308e 100644 --- a/caddytest/integration/caddyfile_adapt/global_server_options_single.caddyfiletest +++ b/caddytest/integration/caddyfile_adapt/global_server_options_single.caddyfiletest @@ -21,6 +21,7 @@ keepalive_interval 20s keepalive_idle 20s keepalive_count 10 + 0rtt off } } @@ -90,7 +91,8 @@ foo.com { "h2", "h2c", "h3" - ] + ], + "allow_0rtt": false } } } diff --git a/caddytest/integration/caddyfile_adapt/import_block_snippet_non_replaced_block_from_separate_file.caddyfiletest b/caddytest/integration/caddyfile_adapt/import_block_snippet_non_replaced_block_from_separate_file.caddyfiletest new file mode 100644 index 000000000..b42a84a09 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/import_block_snippet_non_replaced_block_from_separate_file.caddyfiletest @@ -0,0 +1,52 @@ +import testdata/issue_7518_unused_block_panic_snippets.conf + +example.com { + import snippet +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "example.com" + ] + } + ], + "handle": [ + { + "handler": "subroute", + "routes": [ + { + "handle": [ + { + "handler": "headers", + "response": { + "set": { + "Reverse_proxy": [ + "localhost:3000" + ] + } + } + } + ] + } + ] + } + ], + "terminal": true + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/caddytest/integration/caddyfile_adapt/log_roll_days.caddyfiletest b/caddytest/integration/caddyfile_adapt/log_roll_days.caddyfiletest index 3ead4ac18..9b15eb282 100644 --- a/caddytest/integration/caddyfile_adapt/log_roll_days.caddyfiletest +++ b/caddytest/integration/caddyfile_adapt/log_roll_days.caddyfiletest @@ -1,27 +1,47 @@ :80 -log { +log one { output file /var/log/access.log { + mode 0644 + dir_mode 0755 roll_size 1gb roll_uncompressed + roll_compression none roll_local_time roll_keep 5 roll_keep_for 90d } } +log two { + output file /var/log/access-2.log { + mode 0777 + dir_mode from_file + roll_size 1gib + roll_compression zstd + roll_interval 12h + roll_at 00:00 06:00 12:00,18:00 + roll_minutes 10 40 45,46 + roll_keep 10 + roll_keep_for 90d + } +} ---------- { "logging": { "logs": { "default": { "exclude": [ - "http.log.access.log0" + "http.log.access.one", + "http.log.access.two" ] }, - "log0": { + "one": { "writer": { + "dir_mode": "0755", "filename": "/var/log/access.log", + "mode": "0644", "output": "file", + "roll_compression": "none", "roll_gzip": false, "roll_keep": 5, "roll_keep_days": 90, @@ -29,7 +49,35 @@ log { "roll_size_mb": 954 }, "include": [ - "http.log.access.log0" + "http.log.access.one" + ] + }, + "two": { + "writer": { + "dir_mode": "from_file", + "filename": "/var/log/access-2.log", + "mode": "0777", + "output": "file", + "roll_at": [ + "00:00", + "06:00", + "12:00", + "18:00" + ], + "roll_compression": "zstd", + "roll_interval": 43200000000000, + "roll_keep": 10, + "roll_keep_days": 90, + "roll_minutes": [ + 10, + 40, + 45, + 46 + ], + "roll_size_mb": 1024 + }, + "include": [ + "http.log.access.two" ] } } @@ -42,7 +90,7 @@ log { ":80" ], "logs": { - "default_logger_name": "log0" + "default_logger_name": "two" } } } diff --git a/caddytest/integration/caddyfile_adapt/renewal_window_ratio_global.caddyfiletest b/caddytest/integration/caddyfile_adapt/renewal_window_ratio_global.caddyfiletest new file mode 100644 index 000000000..f6af0ce72 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/renewal_window_ratio_global.caddyfiletest @@ -0,0 +1,41 @@ +{ + renewal_window_ratio 0.1666 +} + +example.com { +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "example.com" + ] + } + ], + "terminal": true + } + ] + } + } + }, + "tls": { + "automation": { + "policies": [ + { + "renewal_window_ratio": 0.1666 + } + ] + } + } + } +} diff --git a/caddytest/integration/caddyfile_adapt/renewal_window_ratio_tls_directive.caddyfiletest b/caddytest/integration/caddyfile_adapt/renewal_window_ratio_tls_directive.caddyfiletest new file mode 100644 index 000000000..82c43f2a5 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/renewal_window_ratio_tls_directive.caddyfiletest @@ -0,0 +1,63 @@ +{ + renewal_window_ratio 0.1666 +} + +a.example.com { + tls { + renewal_window_ratio 0.25 + } +} + +b.example.com { +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "a.example.com" + ] + } + ], + "terminal": true + }, + { + "match": [ + { + "host": [ + "b.example.com" + ] + } + ], + "terminal": true + } + ] + } + } + }, + "tls": { + "automation": { + "policies": [ + { + "subjects": [ + "a.example.com" + ], + "renewal_window_ratio": 0.25 + }, + { + "renewal_window_ratio": 0.1666 + } + ] + } + } + } +} diff --git a/caddytest/integration/caddyfile_adapt/tls_acme_dns_override_global_dns.caddyfiletest b/caddytest/integration/caddyfile_adapt/tls_acme_dns_override_global_dns.caddyfiletest new file mode 100644 index 000000000..1267b6c78 --- /dev/null +++ b/caddytest/integration/caddyfile_adapt/tls_acme_dns_override_global_dns.caddyfiletest @@ -0,0 +1,83 @@ +{ + dns mock foo + acme_dns mock bar +} + +localhost { + tls { + resolvers 8.8.8.8 8.8.4.4 + } +} +---------- +{ + "apps": { + "http": { + "servers": { + "srv0": { + "listen": [ + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "localhost" + ] + } + ], + "terminal": true + } + ] + } + } + }, + "tls": { + "automation": { + "policies": [ + { + "subjects": [ + "localhost" + ], + "issuers": [ + { + "challenges": { + "dns": { + "provider": { + "argument": "bar", + "name": "mock" + }, + "resolvers": [ + "8.8.8.8", + "8.8.4.4" + ] + } + }, + "module": "acme" + } + ] + }, + { + "issuers": [ + { + "challenges": { + "dns": { + "provider": { + "argument": "bar", + "name": "mock" + } + } + }, + "module": "acme" + } + ] + } + ] + }, + "dns": { + "argument": "foo", + "name": "mock" + } + } + } +} diff --git a/caddytest/integration/forwardauth_test.go b/caddytest/integration/forwardauth_test.go new file mode 100644 index 000000000..d0ecc2be1 --- /dev/null +++ b/caddytest/integration/forwardauth_test.go @@ -0,0 +1,206 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package integration + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/caddyserver/caddy/v2/caddytest" +) + +// TestForwardAuthCopyHeadersStripsClientHeaders is a regression test for the +// header injection vulnerability in forward_auth copy_headers. +// +// When the auth service returns 200 OK without one of the copy_headers headers, +// the MatchNot guard skips the Set operation. Before this fix, the original +// client-supplied header survived unchanged into the backend request, allowing +// privilege escalation with only a valid (non-privileged) bearer token. After +// the fix, an unconditional delete route runs first, so the backend always +// sees an absent header rather than the attacker-supplied value. +func TestForwardAuthCopyHeadersStripsClientHeaders(t *testing.T) { + // Mock auth service: accepts any Bearer token, returns 200 OK with NO + // identity headers. This is the stateless JWT validator pattern that + // triggers the vulnerability. + authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusUnauthorized) + })) + defer authSrv.Close() + + // Mock backend: records the identity headers it receives. A real application + // would use X-User-Id / X-User-Role to make authorization decisions. + type received struct{ userID, userRole string } + var ( + mu sync.Mutex + last received + ) + backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + last = received{ + userID: r.Header.Get("X-User-Id"), + userRole: r.Header.Get("X-User-Role"), + } + mu.Unlock() + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") + })) + defer backendSrv.Close() + + authAddr := strings.TrimPrefix(authSrv.URL, "http://") + backendAddr := strings.TrimPrefix(backendSrv.URL, "http://") + + tester := caddytest.NewTester(t) + tester.InitServer(fmt.Sprintf(` + { + skip_install_trust + admin localhost:2999 + http_port 9080 + https_port 9443 + grace_period 1ns + } + http://localhost:9080 { + forward_auth %s { + uri / + copy_headers X-User-Id X-User-Role + } + reverse_proxy %s + } + `, authAddr, backendAddr), "caddyfile") + + // Case 1: no token. Auth must still reject the request even when the client + // includes identity headers. This confirms the auth check is not bypassed. + req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil) + req.Header.Set("X-User-Id", "injected") + req.Header.Set("X-User-Role", "injected") + resp := tester.AssertResponseCode(req, http.StatusUnauthorized) + resp.Body.Close() + + // Case 2: valid token, no injected headers. The backend should see absent + // identity headers (the auth service never returns them). + req, _ = http.NewRequest(http.MethodGet, "http://localhost:9080/", nil) + req.Header.Set("Authorization", "Bearer token123") + tester.AssertResponse(req, http.StatusOK, "ok") + mu.Lock() + gotID, gotRole := last.userID, last.userRole + mu.Unlock() + if gotID != "" { + t.Errorf("baseline: X-User-Id should be absent, got %q", gotID) + } + if gotRole != "" { + t.Errorf("baseline: X-User-Role should be absent, got %q", gotRole) + } + + // Case 3 (the security regression): valid token plus forged identity headers. + // The fix must strip those values so the backend never sees them. + req, _ = http.NewRequest(http.MethodGet, "http://localhost:9080/", nil) + req.Header.Set("Authorization", "Bearer token123") + req.Header.Set("X-User-Id", "admin") // forged + req.Header.Set("X-User-Role", "superadmin") // forged + tester.AssertResponse(req, http.StatusOK, "ok") + mu.Lock() + gotID, gotRole = last.userID, last.userRole + mu.Unlock() + if gotID != "" { + t.Errorf("injection: X-User-Id must be stripped, got %q", gotID) + } + if gotRole != "" { + t.Errorf("injection: X-User-Role must be stripped, got %q", gotRole) + } +} + +// TestForwardAuthCopyHeadersAuthResponseWins verifies that when the auth +// service does include a copy_headers header in its response, that value +// is forwarded to the backend and takes precedence over any client-supplied +// value for the same header. +func TestForwardAuthCopyHeadersAuthResponseWins(t *testing.T) { + const wantUserID = "service-user-42" + const wantUserRole = "editor" + + // Auth service: accepts bearer token and sets identity headers. + authSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + w.Header().Set("X-User-Id", wantUserID) + w.Header().Set("X-User-Role", wantUserRole) + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusUnauthorized) + })) + defer authSrv.Close() + + type received struct{ userID, userRole string } + var ( + mu sync.Mutex + last received + ) + backendSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + last = received{ + userID: r.Header.Get("X-User-Id"), + userRole: r.Header.Get("X-User-Role"), + } + mu.Unlock() + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") + })) + defer backendSrv.Close() + + authAddr := strings.TrimPrefix(authSrv.URL, "http://") + backendAddr := strings.TrimPrefix(backendSrv.URL, "http://") + + tester := caddytest.NewTester(t) + tester.InitServer(fmt.Sprintf(` + { + skip_install_trust + admin localhost:2999 + http_port 9080 + https_port 9443 + grace_period 1ns + } + http://localhost:9080 { + forward_auth %s { + uri / + copy_headers X-User-Id X-User-Role + } + reverse_proxy %s + } + `, authAddr, backendAddr), "caddyfile") + + // The client sends forged headers; the auth service overrides them with + // its own values. The backend must receive the auth service values. + req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil) + req.Header.Set("Authorization", "Bearer token123") + req.Header.Set("X-User-Id", "forged-id") // must be overwritten + req.Header.Set("X-User-Role", "forged-role") // must be overwritten + tester.AssertResponse(req, http.StatusOK, "ok") + + mu.Lock() + gotID, gotRole := last.userID, last.userRole + mu.Unlock() + if gotID != wantUserID { + t.Errorf("X-User-Id: want %q, got %q", wantUserID, gotID) + } + if gotRole != wantUserRole { + t.Errorf("X-User-Role: want %q, got %q", wantUserRole, gotRole) + } +} diff --git a/caddytest/integration/listener_test.go b/caddytest/integration/listener_test.go index 30642b1ae..bd2d94e1a 100644 --- a/caddytest/integration/listener_test.go +++ b/caddytest/integration/listener_test.go @@ -3,7 +3,7 @@ package integration import ( "bytes" "fmt" - "math/rand" + "math/rand/v2" "net" "net/http" "strings" @@ -54,7 +54,7 @@ func TestHTTPRedirectWrapperWithLargeUpload(t *testing.T) { const uploadSize = (1024 * 1024) + 1 // 1 MB + 1 byte // 1 more than an MB body := make([]byte, uploadSize) - rand.New(rand.NewSource(0)).Read(body) + rand.NewChaCha8([32]byte{}).Read(body) tester := setupListenerWrapperTest(t, func(writer http.ResponseWriter, request *http.Request) { buf := new(bytes.Buffer) diff --git a/caddytest/integration/pki_test.go b/caddytest/integration/pki_test.go index 846798209..3f1491e7e 100644 --- a/caddytest/integration/pki_test.go +++ b/caddytest/integration/pki_test.go @@ -53,7 +53,7 @@ func TestLeafCertLifetimeLessThanIntermediate(t *testing.T) { } } } - `, "json", "certificate lifetime (168h0m0s) should be less than intermediate certificate lifetime (168h0m0s)") + `, "json", "should be less than intermediate certificate lifetime") } func TestIntermediateLifetimeLessThanRoot(t *testing.T) { @@ -103,5 +103,5 @@ func TestIntermediateLifetimeLessThanRoot(t *testing.T) { } } } - `, "json", "intermediate certificate lifetime must be less than root certificate lifetime (86400h0m0s)") + `, "json", "intermediate certificate lifetime must be less than actual root certificate lifetime") } diff --git a/caddytest/integration/proxyprotocol_test.go b/caddytest/integration/proxyprotocol_test.go new file mode 100644 index 000000000..e57c323bc --- /dev/null +++ b/caddytest/integration/proxyprotocol_test.go @@ -0,0 +1,595 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Integration tests for Caddy's PROXY protocol support, covering two distinct +// roles that Caddy can play: +// +// 1. As a PROXY protocol *sender* (reverse proxy outbound transport): +// Caddy receives an inbound request from a test client and the +// reverse_proxy handler forwards it to an upstream with a PROXY protocol +// header (v1 or v2) prepended to the connection. A lightweight backend +// built with go-proxyproto validates that the header was received and +// carries the correct client address. +// +// Transport versions tested: +// - "1.1" -> plain HTTP/1.1 to the upstream +// - "h2c" -> HTTP/2 cleartext (h2c) to the upstream (regression for #7529) +// - "2" -> HTTP/2 over TLS (h2) to the upstream +// +// For each transport version both PROXY protocol v1 and v2 are exercised. +// +// HTTP/3 (h3) is not included because it uses QUIC/UDP and therefore +// bypasses the TCP-level dialContext that injects PROXY protocol headers; +// there is no meaningful h3 + proxy protocol sender combination to test. +// +// 2. As a PROXY protocol *receiver* (server-side listener wrapper): +// A raw TCP client dials Caddy directly, injects a PROXY v2 header +// spoofing a source address, and sends a normal HTTP/1.1 request. The +// Caddy server is configured with the proxy_protocol listener wrapper and +// is expected to surface the spoofed address via the +// {http.request.remote.host} placeholder. + +package integration + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + + goproxy "github.com/pires/go-proxyproto" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" + + "github.com/caddyserver/caddy/v2/caddytest" +) + +// proxyProtoBackend is a minimal HTTP server that sits behind a +// go-proxyproto listener and records the source address that was +// delivered in the PROXY header for each request. +type proxyProtoBackend struct { + mu sync.Mutex + headerAddrs []string // host:port strings extracted from each PROXY header + + ln net.Listener + srv *http.Server +} + +// newProxyProtoBackend starts a TCP listener wrapped with go-proxyproto on a +// random local port and serves requests with a simple "OK" body. The PROXY +// header source addresses are accumulated in headerAddrs so tests can +// inspect them. +func newProxyProtoBackend(t *testing.T) *proxyProtoBackend { + t.Helper() + + b := &proxyProtoBackend{} + + rawLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("backend: listen: %v", err) + } + + // Wrap with go-proxyproto so the PROXY header is stripped and parsed + // before the HTTP server sees the connection. We use REQUIRE so that a + // missing header returns an error instead of silently passing through. + pLn := &goproxy.Listener{ + Listener: rawLn, + Policy: func(_ net.Addr) (goproxy.Policy, error) { + return goproxy.REQUIRE, nil + }, + } + b.ln = pLn + + // Wrap the handler with h2c support so the backend can speak HTTP/2 + // cleartext (h2c) as well as plain HTTP/1.1. Without this, Caddy's + // reverse proxy would receive a 'frame too large' error when the + // upstream transport is configured to use h2c. + h2Server := &http2.Server{} + handlerFn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // go-proxyproto has already updated the net.Conn's remote + // address to the value from the PROXY header; the HTTP server + // surfaces it in r.RemoteAddr. + b.mu.Lock() + b.headerAddrs = append(b.headerAddrs, r.RemoteAddr) + b.mu.Unlock() + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, "OK") + }) + + b.srv = &http.Server{ + Handler: h2c.NewHandler(handlerFn, h2Server), + } + + go b.srv.Serve(pLn) //nolint:errcheck + t.Cleanup(func() { + _ = b.srv.Close() + _ = rawLn.Close() + }) + + return b +} + +// addr returns the listening address (host:port) of the backend. +func (b *proxyProtoBackend) addr() string { + return b.ln.Addr().String() +} + +// recordedAddrs returns a snapshot of all PROXY-header source addresses seen +// so far. +func (b *proxyProtoBackend) recordedAddrs() []string { + b.mu.Lock() + defer b.mu.Unlock() + cp := make([]string, len(b.headerAddrs)) + copy(cp, b.headerAddrs) + return cp +} + +// tlsProxyProtoBackend is a TLS-enabled backend that sits behind a +// go-proxyproto listener. The PROXY header is stripped before the TLS +// handshake so the layer order on a connection is: +// +// raw TCP → go-proxyproto (strips PROXY header) → TLS handshake → HTTP/2 +type tlsProxyProtoBackend struct { + mu sync.Mutex + headerAddrs []string + + srv *httptest.Server +} + +// newTLSProxyProtoBackend starts a TLS listener that first reads and strips +// PROXY protocol headers (go-proxyproto, REQUIRE policy) and then performs a +// TLS handshake. The backend speaks HTTP/2 over TLS (h2). +// +// The certificate is the standard self-signed certificate generated by +// httptest.Server; the Caddy transport must be configured with +// insecure_skip_verify: true to trust it. +func newTLSProxyProtoBackend(t *testing.T) *tlsProxyProtoBackend { + t.Helper() + + b := &tlsProxyProtoBackend{} + + handlerFn := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b.mu.Lock() + b.headerAddrs = append(b.headerAddrs, r.RemoteAddr) + b.mu.Unlock() + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, "OK") + }) + + rawLn, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("tlsBackend: listen: %v", err) + } + + // Wrap with go-proxyproto so the PROXY header is consumed before TLS. + pLn := &goproxy.Listener{ + Listener: rawLn, + Policy: func(_ net.Addr) (goproxy.Policy, error) { + return goproxy.REQUIRE, nil + }, + } + + // httptest.NewUnstartedServer lets us replace the listener before + // calling StartTLS(), which wraps our proxyproto listener with + // tls.NewListener. This gives us the right layer order. + b.srv = httptest.NewUnstartedServer(handlerFn) + b.srv.Listener = pLn + + // StartTLS enables HTTP/2 on the server automatically. + b.srv.StartTLS() + + t.Cleanup(func() { + b.srv.Close() + }) + + return b +} + +// addr returns the listening address (host:port) of the TLS backend. +func (b *tlsProxyProtoBackend) addr() string { + return b.srv.Listener.Addr().String() +} + +// tlsConfig returns the *tls.Config used by the backend server. +// Tests can use it to verify cert details if needed. +func (b *tlsProxyProtoBackend) tlsConfig() *tls.Config { + return b.srv.TLS +} + +// recordedAddrs returns a snapshot of all PROXY-header source addresses. +func (b *tlsProxyProtoBackend) recordedAddrs() []string { + b.mu.Lock() + defer b.mu.Unlock() + cp := make([]string, len(b.headerAddrs)) + copy(cp, b.headerAddrs) + return cp +} + +// proxyProtoTLSConfig builds a Caddy JSON configuration that proxies to a TLS +// upstream with PROXY protocol. The transport uses insecure_skip_verify so +// the self-signed certificate generated by httptest.Server is accepted. +func proxyProtoTLSConfig(listenPort int, backendAddr, ppVersion string, transportVersions []string) string { + versionsJSON, _ := json.Marshal(transportVersions) + return fmt.Sprintf(`{ + "admin": { + "listen": "localhost:2999" + }, + "apps": { + "pki": { + "certificate_authorities": { + "local": { + "install_trust": false + } + } + }, + "http": { + "grace_period": 1, + "servers": { + "proxy": { + "listen": [":%d"], + "automatic_https": { + "disable": true + }, + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "upstreams": [{"dial": "%s"}], + "transport": { + "protocol": "http", + "proxy_protocol": "%s", + "versions": %s, + "tls": { + "insecure_skip_verify": true + } + } + } + ] + } + ] + } + } + } + } + }`, listenPort, backendAddr, ppVersion, string(versionsJSON)) +} + +// testTLSProxyProtocolMatrix is the shared implementation for TLS-based proxy +// protocol tests. It mirrors testProxyProtocolMatrix but uses a TLS backend. +func testTLSProxyProtocolMatrix(t *testing.T, ppVersion string, transportVersions []string, numRequests int) { + t.Helper() + + backend := newTLSProxyProtoBackend(t) + listenPort := freePort(t) + + tester := caddytest.NewTester(t) + tester.WithDefaultOverrides(caddytest.Config{ + AdminPort: 2999, + }) + cfg := proxyProtoTLSConfig(listenPort, backend.addr(), ppVersion, transportVersions) + tester.InitServer(cfg, "json") + + proxyURL := fmt.Sprintf("http://127.0.0.1:%d/", listenPort) + + for i := 0; i < numRequests; i++ { + resp, err := tester.Client.Get(proxyURL) + if err != nil { + t.Fatalf("request %d/%d: GET %s: %v", i+1, numRequests, proxyURL, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("request %d/%d: expected status 200, got %d", i+1, numRequests, resp.StatusCode) + } + } + + addrs := backend.recordedAddrs() + if len(addrs) == 0 { + t.Fatalf("backend recorded no PROXY protocol addresses (expected at least 1)") + } + + for i, addr := range addrs { + host, _, err := net.SplitHostPort(addr) + if err != nil { + t.Errorf("addr[%d] %q: SplitHostPort: %v", i, addr, err) + continue + } + if host != "127.0.0.1" { + t.Errorf("addr[%d]: expected source 127.0.0.1, got %q", i, host) + } + } +} + +// proxyProtoConfig builds a Caddy JSON configuration that: +// - listens on listenPort for inbound HTTP requests +// - proxies them to backendAddr with PROXY protocol ppVersion ("v1"/"v2") +// - uses the given transport versions (e.g. ["1.1"] or ["h2c"]) +func proxyProtoConfig(listenPort int, backendAddr, ppVersion string, transportVersions []string) string { + versionsJSON, _ := json.Marshal(transportVersions) + return fmt.Sprintf(`{ + "admin": { + "listen": "localhost:2999" + }, + "apps": { + "pki": { + "certificate_authorities": { + "local": { + "install_trust": false + } + } + }, + "http": { + "grace_period": 1, + "servers": { + "proxy": { + "listen": [":%d"], + "automatic_https": { + "disable": true + }, + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "upstreams": [{"dial": "%s"}], + "transport": { + "protocol": "http", + "proxy_protocol": "%s", + "versions": %s + } + } + ] + } + ] + } + } + } + } + }`, listenPort, backendAddr, ppVersion, string(versionsJSON)) +} + +// freePort returns a free local TCP port by binding briefly and releasing it. +func freePort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("freePort: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + return port +} + +// TestProxyProtocolV1WithH1 verifies that PROXY protocol v1 headers are sent +// correctly when the transport uses HTTP/1.1 to the upstream. +func TestProxyProtocolV1WithH1(t *testing.T) { + testProxyProtocolMatrix(t, "v1", []string{"1.1"}, 1) +} + +// TestProxyProtocolV2WithH1 verifies that PROXY protocol v2 headers are sent +// correctly when the transport uses HTTP/1.1 to the upstream. +func TestProxyProtocolV2WithH1(t *testing.T) { + testProxyProtocolMatrix(t, "v2", []string{"1.1"}, 1) +} + +// TestProxyProtocolV1WithH2C verifies that PROXY protocol v1 headers are sent +// correctly when the transport uses h2c (HTTP/2 cleartext) to the upstream. +func TestProxyProtocolV1WithH2C(t *testing.T) { + testProxyProtocolMatrix(t, "v1", []string{"h2c"}, 1) +} + +// TestProxyProtocolV2WithH2C verifies that PROXY protocol v2 headers are sent +// correctly when the transport uses h2c (HTTP/2 cleartext) to the upstream. +// This is the primary regression test for github.com/caddyserver/caddy/issues/7529: +// before the fix, the h2 transport opened a new TCP connection per request +// (because req.URL.Host was mangled differently for each request due to the +// varying client port), which caused file-descriptor exhaustion under load. +func TestProxyProtocolV2WithH2C(t *testing.T) { + testProxyProtocolMatrix(t, "v2", []string{"h2c"}, 1) +} + +// TestProxyProtocolV2WithH2CMultipleRequests sends several sequential requests +// through the h2c + PROXY-protocol path and confirms that: +// 1. Every request receives a 200 response (no connection exhaustion). +// 2. The backend received at least one PROXY header (connection was reused). +// +// This is the core regression guard for issue #7529: without the fix, a new +// TCP connection was opened per request, quickly exhausting file descriptors. +func TestProxyProtocolV2WithH2CMultipleRequests(t *testing.T) { + testProxyProtocolMatrix(t, "v2", []string{"h2c"}, 5) +} + +// TestProxyProtocolV1WithH2 verifies that PROXY protocol v1 headers are sent +// correctly when the transport uses HTTP/2 over TLS (h2) to the upstream. +func TestProxyProtocolV1WithH2(t *testing.T) { + testTLSProxyProtocolMatrix(t, "v1", []string{"2"}, 1) +} + +// TestProxyProtocolV2WithH2 verifies that PROXY protocol v2 headers are sent +// correctly when the transport uses HTTP/2 over TLS (h2) to the upstream. +func TestProxyProtocolV2WithH2(t *testing.T) { + testTLSProxyProtocolMatrix(t, "v2", []string{"2"}, 1) +} + +// TestProxyProtocolServerAndProxy is an end-to-end matrix test that exercises +// all combinations of PROXY protocol version x transport version. +func TestProxyProtocolServerAndProxy(t *testing.T) { + plainTests := []struct { + name string + ppVersion string + transportVersions []string + numRequests int + }{ + {"h1-v1", "v1", []string{"1.1"}, 3}, + {"h1-v2", "v2", []string{"1.1"}, 3}, + {"h2c-v1", "v1", []string{"h2c"}, 3}, + {"h2c-v2", "v2", []string{"h2c"}, 3}, + } + for _, tc := range plainTests { + t.Run(tc.name, func(t *testing.T) { + testProxyProtocolMatrix(t, tc.ppVersion, tc.transportVersions, tc.numRequests) + }) + } + + tlsTests := []struct { + name string + ppVersion string + transportVersions []string + numRequests int + }{ + {"h2-v1", "v1", []string{"2"}, 3}, + {"h2-v2", "v2", []string{"2"}, 3}, + } + for _, tc := range tlsTests { + t.Run(tc.name, func(t *testing.T) { + testTLSProxyProtocolMatrix(t, tc.ppVersion, tc.transportVersions, tc.numRequests) + }) + } +} + +// testProxyProtocolMatrix is the shared implementation for the proxy protocol +// tests. It: +// 1. Starts a go-proxyproto-wrapped backend. +// 2. Configures Caddy as a reverse proxy with the given PROXY protocol +// version and transport versions. +// 3. Sends numRequests GET requests through Caddy and asserts 200 OK each time. +// 4. Asserts the backend recorded at least one PROXY header whose source host +// is 127.0.0.1 (the loopback address used by the test client). +func testProxyProtocolMatrix(t *testing.T, ppVersion string, transportVersions []string, numRequests int) { + t.Helper() + + backend := newProxyProtoBackend(t) + listenPort := freePort(t) + + tester := caddytest.NewTester(t) + tester.WithDefaultOverrides(caddytest.Config{ + AdminPort: 2999, + }) + cfg := proxyProtoConfig(listenPort, backend.addr(), ppVersion, transportVersions) + tester.InitServer(cfg, "json") + + // If the test is h2c-only (no "1.1" in versions), reconfigure the test + // client transport to use unencrypted HTTP/2 so we actually exercise the + // h2c code path through Caddy. + if slices.Contains(transportVersions, "h2c") && !slices.Contains(transportVersions, "1.1") { + tr, ok := tester.Client.Transport.(*http.Transport) + if ok { + tr.Protocols = new(http.Protocols) + tr.Protocols.SetHTTP1(false) + tr.Protocols.SetUnencryptedHTTP2(true) + } + } + + proxyURL := fmt.Sprintf("http://127.0.0.1:%d/", listenPort) + + for i := 0; i < numRequests; i++ { + resp, err := tester.Client.Get(proxyURL) + if err != nil { + t.Fatalf("request %d/%d: GET %s: %v", i+1, numRequests, proxyURL, err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("request %d/%d: expected status 200, got %d", i+1, numRequests, resp.StatusCode) + } + } + + // The backend must have seen at least one PROXY header. For h1, there is + // one per request; for h2c, requests share the same connection so only one + // header is written at connection establishment. + addrs := backend.recordedAddrs() + if len(addrs) == 0 { + t.Fatalf("backend recorded no PROXY protocol addresses (expected at least 1)") + } + + // Every PROXY-decoded source address must be the loopback address since + // the test client always connects from 127.0.0.1. + for i, addr := range addrs { + host, _, err := net.SplitHostPort(addr) + if err != nil { + t.Errorf("addr[%d] %q: SplitHostPort: %v", i, addr, err) + continue + } + if host != "127.0.0.1" { + t.Errorf("addr[%d]: expected source 127.0.0.1, got %q", i, host) + } + } +} + +// TestProxyProtocolListenerWrapper verifies that Caddy's +// caddy.listeners.proxy_protocol listener wrapper can successfully parse +// incoming PROXY protocol headers. +// +// The test dials Caddy's listening port directly, injects a raw PROXY v2 +// header spoofing source address 10.0.0.1:1234, then sends a normal +// HTTP/1.1 GET request. The Caddy server is configured to echo back the +// remote address ({http.request.remote.host}). The test asserts that the +// echoed address is the spoofed 10.0.0.1. +func TestProxyProtocolListenerWrapper(t *testing.T) { + tester := caddytest.NewTester(t) + tester.InitServer(`{ + skip_install_trust + admin localhost:2999 + http_port 9080 + https_port 9443 + grace_period 1ns + servers :9080 { + listener_wrappers { + proxy_protocol { + timeout 5s + allow 127.0.0.0/8 + } + } + } + } + http://localhost:9080 { + respond "{http.request.remote.host}" + }`, "caddyfile") + + // Dial the Caddy listener directly and inject a PROXY v2 header that + // claims the connection originates from 10.0.0.1:1234. + conn, err := net.Dial("tcp", "127.0.0.1:9080") + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + spoofedSrc := &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 1234} + spoofedDst := &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 9080} + hdr := goproxy.HeaderProxyFromAddrs(2, spoofedSrc, spoofedDst) + if _, err := hdr.WriteTo(conn); err != nil { + t.Fatalf("write proxy header: %v", err) + } + + // Write a minimal HTTP/1.1 GET request. + _, err = fmt.Fprintf(conn, + "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + if err != nil { + t.Fatalf("write HTTP request: %v", err) + } + + // Read the raw response and look for the spoofed address in the body. + buf := make([]byte, 4096) + n, _ := conn.Read(buf) + raw := string(buf[:n]) + + if !strings.Contains(raw, "10.0.0.1") { + t.Errorf("expected spoofed address 10.0.0.1 in response body; full response:\n%s", raw) + } +} diff --git a/caddytest/integration/reverseproxy_test.go b/caddytest/integration/reverseproxy_test.go index cbfe8433b..6e0b3dcff 100644 --- a/caddytest/integration/reverseproxy_test.go +++ b/caddytest/integration/reverseproxy_test.go @@ -8,7 +8,6 @@ import ( "runtime" "strings" "testing" - "time" "github.com/caddyserver/caddy/v2/caddytest" ) @@ -327,6 +326,41 @@ func TestReverseProxyWithPlaceholderTCPDialAddress(t *testing.T) { } func TestReverseProxyHealthCheck(t *testing.T) { + // Start lightweight backend servers so they're ready before Caddy's + // active health checker runs; this avoids a startup race where the + // health checker probes backends that haven't yet begun accepting + // connections and marks them unhealthy. + // + // This mirrors how health checks are typically used in practice (to a separate + // backend service) and avoids probing the same Caddy instance while it's still + // provisioning and not ready to accept connections. + + // backend server that responds to proxied requests + helloSrv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _, _ = w.Write([]byte("Hello, World!")) + }), + } + ln0, err := net.Listen("tcp", "127.0.0.1:2020") + if err != nil { + t.Fatalf("failed to listen on 127.0.0.1:2020: %v", err) + } + go helloSrv.Serve(ln0) + t.Cleanup(func() { helloSrv.Close(); ln0.Close() }) + + // backend server that serves health checks + healthSrv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _, _ = w.Write([]byte("ok")) + }), + } + ln1, err := net.Listen("tcp", "127.0.0.1:2021") + if err != nil { + t.Fatalf("failed to listen on 127.0.0.1:2021: %v", err) + } + go healthSrv.Serve(ln1) + t.Cleanup(func() { healthSrv.Close(); ln1.Close() }) + tester := caddytest.NewTester(t) tester.InitServer(` { @@ -336,12 +370,6 @@ func TestReverseProxyHealthCheck(t *testing.T) { https_port 9443 grace_period 1ns } - http://localhost:2020 { - respond "Hello, World!" - } - http://localhost:2021 { - respond "ok" - } http://localhost:9080 { reverse_proxy { to localhost:2020 @@ -355,8 +383,68 @@ func TestReverseProxyHealthCheck(t *testing.T) { } } `, "caddyfile") + tester.AssertGetResponse("http://localhost:9080/", 200, "Hello, World!") +} - time.Sleep(100 * time.Millisecond) // TODO: for some reason this test seems particularly flaky, getting 503 when it should be 200, unless we wait +// TestReverseProxyHealthCheckPortUsed verifies that health_port is actually +// used for active health checks and not the upstream's main port. This is a +// regression test for https://github.com/caddyserver/caddy/issues/7524. +func TestReverseProxyHealthCheckPortUsed(t *testing.T) { + // upstream server: serves proxied requests normally, but returns 503 for + // /health so that if health checks mistakenly hit this port the upstream + // gets marked unhealthy and the proxy returns 503. + upstreamSrv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/health" { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + _, _ = w.Write([]byte("Hello, World!")) + }), + } + ln0, err := net.Listen("tcp", "127.0.0.1:2022") + if err != nil { + t.Fatalf("failed to listen on 127.0.0.1:2022: %v", err) + } + go upstreamSrv.Serve(ln0) + t.Cleanup(func() { upstreamSrv.Close(); ln0.Close() }) + + // separate health check server on the configured health_port: returns 200 + // so the upstream is marked healthy only if health checks go to this port. + healthSrv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _, _ = w.Write([]byte("ok")) + }), + } + ln1, err := net.Listen("tcp", "127.0.0.1:2023") + if err != nil { + t.Fatalf("failed to listen on 127.0.0.1:2023: %v", err) + } + go healthSrv.Serve(ln1) + t.Cleanup(func() { healthSrv.Close(); ln1.Close() }) + + tester := caddytest.NewTester(t) + tester.InitServer(` + { + skip_install_trust + admin localhost:2999 + http_port 9080 + https_port 9443 + grace_period 1ns + } + http://localhost:9080 { + reverse_proxy { + to localhost:2022 + + health_uri /health + health_port 2023 + health_interval 10ms + health_timeout 100ms + health_passes 1 + health_fails 1 + } + } + `, "caddyfile") tester.AssertGetResponse("http://localhost:9080/", 200, "Hello, World!") } diff --git a/caddytest/integration/testdata/issue_7518_unused_block_panic_snippets.conf b/caddytest/integration/testdata/issue_7518_unused_block_panic_snippets.conf new file mode 100644 index 000000000..0f3e53a2b --- /dev/null +++ b/caddytest/integration/testdata/issue_7518_unused_block_panic_snippets.conf @@ -0,0 +1,15 @@ +# Used by import_block_snippet_non_replaced_block_from_separate_file.caddyfiletest + +(snippet) { + header { + reverse_proxy localhost:3000 + {block} + } +} + +# This snippet being unused by the test Caddyfile is intentional. +# This is to test that a panic runtime error triggered by an out-of-range slice index access +# will not happen again, please see issue #7518 and pull request #7543 for more information +(unused_snippet) { + header SomeHeader SomeValue +} \ No newline at end of file diff --git a/cmd/caddy/main.go b/cmd/caddy/main.go index 48fa149aa..5002605b6 100644 --- a/cmd/caddy/main.go +++ b/cmd/caddy/main.go @@ -29,6 +29,8 @@ package main import ( + _ "time/tzdata" + caddycmd "github.com/caddyserver/caddy/v2/cmd" // plug in Caddy modules here diff --git a/cmd/cobra.go b/cmd/cobra.go index 9ecb389e2..14c8d2988 100644 --- a/cmd/cobra.go +++ b/cmd/cobra.go @@ -9,9 +9,14 @@ import ( ) var defaultFactory = newRootCommandFactory(func() *cobra.Command { - return &cobra.Command{ - Use: "caddy", - Long: `Caddy is an extensible server platform written in Go. + bin := caddy.CustomBinaryName + if bin == "" { + bin = "caddy" + } + + long := caddy.CustomLongDescription + if long == "" { + long = `Caddy is an extensible server platform written in Go. At its core, Caddy merely manages configuration. Modules are plugged in statically at compile-time to provide useful functionality. Caddy's @@ -91,7 +96,12 @@ package installers: https://caddyserver.com/docs/install Instructions for running Caddy in production are also available: https://caddyserver.com/docs/running -`, +` + } + + return &cobra.Command{ + Use: bin, + Long: long, Example: ` $ caddy run $ caddy run --config caddy.json $ caddy reload --config caddy.json diff --git a/cmd/commandfuncs.go b/cmd/commandfuncs.go index 75d114992..faa275b03 100644 --- a/cmd/commandfuncs.go +++ b/cmd/commandfuncs.go @@ -74,7 +74,7 @@ func cmdStart(fl Flags) (int, error) { // ensure it's the process we're expecting - we can be // sure by giving it some random bytes and having it echo // them back to us) - cmd := exec.Command(os.Args[0], "run", "--pingback", ln.Addr().String()) + cmd := exec.Command(os.Args[0], "run", "--pingback", ln.Addr().String()) //nolint:gosec // no command injection that I can determine... // we should be able to run caddy in relative paths if errors.Is(cmd.Err, exec.ErrDot) { cmd.Err = nil @@ -372,7 +372,7 @@ func cmdReload(fl Flags) (int, error) { return caddy.ExitCodeFailedStartup, fmt.Errorf("no config file to load") } - adminAddr, err := DetermineAdminAPIAddress(addressFlag, config, configFlag, configAdapterFlag) + adminAddr, err := DetermineAdminAPIAddress(addressFlag, config, configFile, configAdapterFlag) if err != nil { return caddy.ExitCodeFailedStartup, fmt.Errorf("couldn't determine admin API address: %v", err) } @@ -411,11 +411,65 @@ func cmdBuildInfo(_ Flags) (int, error) { return caddy.ExitCodeSuccess, nil } +// jsonModuleInfo holds metadata about a Caddy module for JSON output. +type jsonModuleInfo struct { + ModuleName string `json:"module_name"` + ModuleType string `json:"module_type"` + Version string `json:"version,omitempty"` + PackageURL string `json:"package_url,omitempty"` +} + func cmdListModules(fl Flags) (int, error) { packages := fl.Bool("packages") versions := fl.Bool("versions") skipStandard := fl.Bool("skip-standard") + jsonOutput := fl.Bool("json") + // Organize modules by whether they come with the standard distribution + standard, nonstandard, unknown, err := getModules() + if err != nil { + // If module info can't be fetched, just print the IDs and exit + for _, m := range caddy.Modules() { + fmt.Println(m) + } + return caddy.ExitCodeSuccess, nil + } + + // Logic for JSON output + if jsonOutput { + output := []jsonModuleInfo{} + + // addToOutput is a helper to convert internal module info to the JSON-serializable struct + addToOutput := func(list []moduleInfo, moduleType string) { + for _, mi := range list { + item := jsonModuleInfo{ + ModuleName: mi.caddyModuleID, + ModuleType: moduleType, // Mapping the type here + } + if mi.goModule != nil { + item.Version = mi.goModule.Version + item.PackageURL = mi.goModule.Path + } + output = append(output, item) + } + } + + // Pass the respective type for each category + if !skipStandard { + addToOutput(standard, "standard") + } + addToOutput(nonstandard, "non-standard") + addToOutput(unknown, "unknown") + + jsonBytes, err := json.MarshalIndent(output, "", " ") + if err != nil { + return caddy.ExitCodeFailedQuit, err + } + fmt.Println(string(jsonBytes)) + return caddy.ExitCodeSuccess, nil + } + + // Logic for Text output (Fallback) printModuleInfo := func(mi moduleInfo) { fmt.Print(mi.caddyModuleID) if versions && mi.goModule != nil { @@ -433,16 +487,6 @@ func cmdListModules(fl Flags) (int, error) { fmt.Println() } - // organize modules by whether they come with the standard distribution - standard, nonstandard, unknown, err := getModules() - if err != nil { - // oh well, just print the module IDs and exit - for _, m := range caddy.Modules() { - fmt.Println(m) - } - return caddy.ExitCodeSuccess, nil - } - // Standard modules (always shipped with Caddy) if !skipStandard { if len(standard) > 0 { @@ -461,8 +505,8 @@ func cmdListModules(fl Flags) (int, error) { for _, mod := range nonstandard { printModuleInfo(mod) } + fmt.Printf("\n Non-standard modules: %d\n", len(nonstandard)) } - fmt.Printf("\n Non-standard modules: %d\n", len(nonstandard)) // Unknown modules (couldn't get Caddy module info) if len(unknown) > 0 { @@ -472,8 +516,8 @@ func cmdListModules(fl Flags) (int, error) { for _, mod := range unknown { printModuleInfo(mod) } + fmt.Printf("\n Unknown modules: %d\n", len(unknown)) } - fmt.Printf("\n Unknown modules: %d\n", len(unknown)) return caddy.ExitCodeSuccess, nil } @@ -653,7 +697,7 @@ func cmdFmt(fl Flags) (int, error) { output := caddyfile.Format(input) if fl.Bool("overwrite") { - if err := os.WriteFile(configFile, output, 0o600); err != nil { + if err := os.WriteFile(configFile, output, 0o600); err != nil { //nolint:gosec // path traversal is not really a thing here, this is either "Caddyfile" or admin-controlled return caddy.ExitCodeFailedStartup, fmt.Errorf("overwriting formatted file: %v", err) } return caddy.ExitCodeSuccess, nil @@ -776,7 +820,7 @@ func AdminAPIRequest(adminAddr, method, uri string, headers http.Header, body io }, } - resp, err := client.Do(req) + resp, err := client.Do(req) //nolint:gosec // the only SSRF here would be self-sabatoge I think if err != nil { return nil, fmt.Errorf("performing request: %v", err) } diff --git a/cmd/commands.go b/cmd/commands.go index c9ea636b9..417720f06 100644 --- a/cmd/commands.go +++ b/cmd/commands.go @@ -229,12 +229,13 @@ documentation: https://go.dev/doc/modules/version-numbers RegisterCommand(Command{ Name: "list-modules", - Usage: "[--packages] [--versions] [--skip-standard]", + Usage: "[--packages] [--versions] [--skip-standard] [--json]", Short: "Lists the installed Caddy modules", CobraFunc: func(cmd *cobra.Command) { cmd.Flags().BoolP("packages", "", false, "Print package paths") cmd.Flags().BoolP("versions", "", false, "Print version information") cmd.Flags().BoolP("skip-standard", "s", false, "Skip printing standard modules") + cmd.Flags().BoolP("json", "", false, "Print modules in JSON format") cmd.RunE = WrapCommandFuncForCobra(cmdListModules) }, }) diff --git a/cmd/main.go b/cmd/main.go index 411f4545d..07666072f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -231,7 +231,10 @@ func loadConfigWithLogger(logger *zap.Logger, configFile, adapterName string) ([ // validate that the config is at least valid JSON err = json.Unmarshal(config, new(any)) if err != nil { - return nil, "", "", fmt.Errorf("config is not valid JSON: %v; did you mean to use a config adapter (the --adapter flag)?", err) + if jsonErr, ok := err.(*json.SyntaxError); ok { + return nil, "", "", fmt.Errorf("config is not valid JSON: %w, at offset %d; did you mean to use a config adapter (the --adapter flag)?", err, jsonErr.Offset) + } + return nil, "", "", fmt.Errorf("config is not valid JSON: %w; did you mean to use a config adapter (the --adapter flag)?", err) } } @@ -481,7 +484,13 @@ func setResourceLimits(logger *zap.Logger) func() { // See https://pkg.go.dev/runtime/debug#SetMemoryLimit _, _ = memlimit.SetGoMemLimitWithOpts( memlimit.WithLogger( - slog.New(zapslog.NewHandler(logger.Core())), + slog.New(zapslog.NewHandler( + logger.Core(), + zapslog.WithName("memlimit"), + // the default enables traces at ERROR level, this disables + // them by setting it to a level higher than any other level + zapslog.AddStacktraceAt(slog.Level(127)), + )), ), memlimit.WithProvider( memlimit.ApplyFallback( diff --git a/config_test.go b/config_test.go index 4e32febe6..08b10d3cc 100644 --- a/config_test.go +++ b/config_test.go @@ -345,7 +345,8 @@ func TestProvisionContext_NilConfig(t *testing.T) { } // Clean up - ctx.cfg.cancelFunc() + // TODO: Investigate + ctx.cfg.cancelFunc(nil) } func TestDuration_UnmarshalJSON_EdgeCases(t *testing.T) { diff --git a/context.go b/context.go index 4c1139936..980027275 100644 --- a/context.go +++ b/context.go @@ -21,12 +21,14 @@ import ( "log" "log/slog" "reflect" + "sync" "github.com/caddyserver/certmagic" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" "go.uber.org/zap" "go.uber.org/zap/exp/zapslog" + "go.uber.org/zap/zapcore" "github.com/caddyserver/caddy/v2/internal/filesystems" ) @@ -61,10 +63,17 @@ type Context struct { // modules which are loaded will be properly unloaded. // See standard library context package's documentation. func NewContext(ctx Context) (Context, context.CancelFunc) { + newCtx, cancelCause := NewContextWithCause(ctx) + return newCtx, func() { cancelCause(nil) } +} + +// NewContextWithCause is like NewContext but returns a context.CancelCauseFunc. +// EXPERIMENTAL: This API is subject to change. +func NewContextWithCause(ctx Context) (Context, context.CancelCauseFunc) { newCtx := Context{moduleInstances: make(map[string][]Module), cfg: ctx.cfg, metricsRegistry: prometheus.NewPedanticRegistry()} - c, cancel := context.WithCancel(ctx.Context) - wrappedCancel := func() { - cancel() + c, cancel := context.WithCancelCause(ctx.Context) + wrappedCancel := func(cause error) { + cancel(cause) for _, f := range ctx.cleanupFuncs { f() @@ -583,24 +592,62 @@ func (ctx Context) Logger(module ...Module) *zap.Logger { return ctx.cfg.Logging.Logger(mod) } +type slogHandlerFactory func(handler slog.Handler, core zapcore.Core, moduleID string) slog.Handler + +var ( + slogHandlerFactories []slogHandlerFactory + slogHandlerFactoriesMu sync.RWMutex +) + +// RegisterSlogHandlerFactory allows modules to register custom log/slog.Handler, +// for instance, to add contextual data to the logs. +func RegisterSlogHandlerFactory(factory slogHandlerFactory) { + slogHandlerFactoriesMu.Lock() + slogHandlerFactories = append(slogHandlerFactories, factory) + slogHandlerFactoriesMu.Unlock() +} + // Slogger returns a slog logger that is intended for use by // the most recent module associated with the context. func (ctx Context) Slogger() *slog.Logger { + var ( + handler slog.Handler + core zapcore.Core + moduleID string + ) + + // the default enables traces at ERROR level, this disables + // them by setting it to a level higher than any other level + tracesOpt := zapslog.AddStacktraceAt(slog.Level(127)) + if ctx.cfg == nil { // often the case in tests; just use a dev logger l, err := zap.NewDevelopment() if err != nil { panic("config missing, unable to create dev logger: " + err.Error()) } - return slog.New(zapslog.NewHandler(l.Core())) + + core = l.Core() + handler = zapslog.NewHandler(core, tracesOpt) + } else { + mod := ctx.Module() + if mod == nil { + core = Log().Core() + handler = zapslog.NewHandler(core, tracesOpt) + } else { + moduleID = string(mod.CaddyModule().ID) + core = ctx.cfg.Logging.Logger(mod).Core() + handler = zapslog.NewHandler(core, zapslog.WithName(moduleID), tracesOpt) + } } - mod := ctx.Module() - if mod == nil { - return slog.New(zapslog.NewHandler(Log().Core())) + + slogHandlerFactoriesMu.RLock() + for _, f := range slogHandlerFactories { + handler = f(handler, core, moduleID) } - return slog.New(zapslog.NewHandler(ctx.cfg.Logging.Logger(mod).Core(), - zapslog.WithName(string(mod.CaddyModule().ID)), - )) + slogHandlerFactoriesMu.RUnlock() + + return slog.New(handler) } // Modules returns the lineage of modules that this context provisioned, diff --git a/go.mod b/go.mod index 70f85aed9..034174c85 100644 --- a/go.mod +++ b/go.mod @@ -1,108 +1,125 @@ module github.com/caddyserver/caddy/v2 -go 1.25 +go 1.25.0 require ( - github.com/BurntSushi/toml v1.5.0 - github.com/DeRuina/timberjack v1.3.8 - github.com/KimMachineGun/automemlimit v0.7.4 + github.com/BurntSushi/toml v1.6.0 + github.com/DeRuina/timberjack v1.3.9 + github.com/KimMachineGun/automemlimit v0.7.5 github.com/Masterminds/sprig/v3 v3.3.0 - github.com/alecthomas/chroma/v2 v2.20.0 + github.com/alecthomas/chroma/v2 v2.23.1 github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b - github.com/caddyserver/certmagic v0.25.0 - github.com/caddyserver/zerossl v0.1.3 - github.com/cloudflare/circl v1.6.1 + github.com/caddyserver/certmagic v0.25.2 + github.com/caddyserver/zerossl v0.1.5 + github.com/cloudflare/circl v1.6.3 github.com/dustin/go-humanize v1.0.1 - github.com/go-chi/chi/v5 v5.2.3 - github.com/google/cel-go v0.26.1 + github.com/go-chi/chi/v5 v5.2.5 + github.com/google/cel-go v0.27.0 github.com/google/uuid v1.6.0 - github.com/klauspost/compress v1.18.0 + github.com/klauspost/compress v1.18.4 github.com/klauspost/cpuid/v2 v2.3.0 - github.com/mholt/acmez/v3 v3.1.4 + github.com/mholt/acmez/v3 v3.1.6 github.com/prometheus/client_golang v1.23.2 - github.com/quic-go/quic-go v0.55.0 - github.com/smallstep/certificates v0.28.4 + github.com/quic-go/quic-go v0.59.0 + github.com/smallstep/certificates v0.30.0-rc3 github.com/smallstep/nosql v0.7.0 github.com/smallstep/truststore v0.13.0 - github.com/spf13/cobra v1.10.1 - github.com/spf13/pflag v1.0.9 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53 - github.com/yuin/goldmark v1.7.13 + github.com/tailscale/tscert v0.0.0-20251216020129-aea342f6d747 + github.com/yuin/goldmark v1.7.16 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 - go.opentelemetry.io/contrib/propagators/autoprop v0.63.0 - go.opentelemetry.io/otel v1.38.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 - go.opentelemetry.io/otel/sdk v1.38.0 + go.opentelemetry.io/contrib/exporters/autoexport v0.65.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 + go.opentelemetry.io/contrib/propagators/autoprop v0.65.0 + go.opentelemetry.io/otel v1.40.0 + go.opentelemetry.io/otel/sdk v1.40.0 + go.step.sm/crypto v0.76.2 go.uber.org/automaxprocs v1.6.0 - go.uber.org/zap v1.27.0 + go.uber.org/zap v1.27.1 go.uber.org/zap/exp v0.3.0 - golang.org/x/crypto v0.43.0 - golang.org/x/crypto/x509roots/fallback v0.0.0-20250927194341-2beaa59a3c99 - golang.org/x/net v0.46.0 - golang.org/x/sync v0.17.0 - golang.org/x/term v0.36.0 + golang.org/x/crypto v0.48.0 + golang.org/x/crypto/x509roots/fallback v0.0.0-20260213171211-a408498e5541 + golang.org/x/net v0.51.0 + golang.org/x/sync v0.19.0 + golang.org/x/term v0.40.0 golang.org/x/time v0.14.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - cel.dev/expr v0.24.0 // indirect - cloud.google.com/go/auth v0.16.4 // indirect + cel.dev/expr v0.25.1 // indirect + cloud.google.com/go/auth v0.18.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.8.0 // indirect - dario.cat/mergo v1.0.1 // indirect - github.com/Microsoft/go-winio v0.6.0 // indirect - github.com/antlr4-go/antlr/v4 v4.13.0 // indirect - github.com/ccoveille/go-safecast v1.6.1 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + dario.cat/mergo v1.0.2 // indirect + filippo.io/bigmod v0.1.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/ccoveille/go-safecast/v2 v2.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/coreos/go-oidc/v3 v3.14.1 // indirect + github.com/coreos/go-oidc/v3 v3.17.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/francoispqt/gojay v1.2.13 // indirect - github.com/fxamacker/cbor/v2 v2.8.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-jose/go-jose/v3 v3.0.4 // indirect - github.com/go-jose/go-jose/v4 v4.1.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745 // indirect - github.com/google/go-tpm v0.9.5 // 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/googleapis/enterprise-certificate-proxy v0.3.6 // indirect - github.com/googleapis/gax-go/v2 v2.15.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/quic-go/qpack v0.5.1 // indirect - github.com/smallstep/cli-utils v0.12.1 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/smallstep/cli-utils v0.12.2 // indirect github.com/smallstep/go-attestation v0.4.4-0.20241119153605-2306d5b464ca // indirect - github.com/smallstep/linkedca v0.23.0 // indirect + github.com/smallstep/linkedca v0.25.0 // indirect github.com/smallstep/pkcs7 v0.2.1 // indirect - github.com/smallstep/scep v0.0.0-20240926084937-8cf1ca453101 // indirect + github.com/smallstep/scep v0.0.0-20250318231241-a25cabb69492 // indirect + github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zeebo/blake3 v0.2.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/propagators/aws v1.38.0 // indirect - go.opentelemetry.io/contrib/propagators/b3 v1.38.0 // indirect - go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 // indirect - go.opentelemetry.io/contrib/propagators/ot v1.38.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/bridges/prometheus 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/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/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/sdk/log v0.16.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect - golang.org/x/oauth2 v0.31.0 // indirect - google.golang.org/api v0.247.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + google.golang.org/api v0.266.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect ) require ( - filippo.io/edwards25519 v1.1.0 // indirect + filippo.io/edwards25519 v1.2.0 // indirect github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/semver/v3 v3.3.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 @@ -125,39 +142,37 @@ require ( github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/libdns/libdns v1.1.1 github.com/manifoldco/promptui v0.9.0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect - github.com/miekg/dns v1.1.68 // 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 - github.com/pires/go-proxyproto v0.8.1 + github.com/pires/go-proxyproto v0.11.0 github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.2 - github.com/prometheus/common v0.67.1 // indirect - github.com/prometheus/procfs v0.17.0 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.19.2 // indirect github.com/rs/xid v1.6.0 // indirect github.com/russross/blackfriday/v2 v2.1.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.5 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/slackhq/nebula v1.10.3 // indirect github.com/spf13/cast v1.7.0 // indirect - github.com/stoewer/go-strcase v1.2.0 // indirect github.com/urfave/cli v1.22.17 // indirect go.etcd.io/bbolt v1.3.10 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect - go.opentelemetry.io/otel/metric v1.38.0 // indirect - go.opentelemetry.io/otel/trace v1.38.0 - go.opentelemetry.io/proto/otlp v1.7.1 // indirect - go.step.sm/crypto v0.70.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 + go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/sys v0.37.0 - golang.org/x/text v0.30.0 // indirect - golang.org/x/tools v0.38.0 // indirect - google.golang.org/grpc v1.75.1 // indirect - google.golang.org/protobuf v1.36.10 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/sys v0.41.0 + golang.org/x/text v0.34.0 + golang.org/x/tools v0.42.0 // indirect + google.golang.org/grpc v1.79.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect howett.net/plist v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index 5c4a2975b..47017562d 100644 --- a/go.sum +++ b/go.sum @@ -1,104 +1,96 @@ -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo= -cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA= -cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q= -cloud.google.com/go/auth v0.16.4 h1:fXOAIQmkApVvcIn7Pc2+5J8QTMVbUGLscnSVNl11su8= -cloud.google.com/go/auth v0.16.4/go.mod h1:j10ncYwjX/g3cdX7GpEzsdM+d+ZNsXAbb6qXA7p1Y5M= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +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.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= +cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= 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.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= -cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= -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.22.0 h1:dBRIj7+GDeeEvatJeTB19oYZNV0aj6wEqSIT/7gLqtk= -cloud.google.com/go/kms v1.22.0/go.mod h1:U7mf8Sva5jpOb4bxYZdtw/9zsbIjrklYwPcvMk34AL8= -cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= -cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= -dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= -dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU= -dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU= -dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4= -dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU= -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= +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.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/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/DeRuina/timberjack v1.3.8 h1:lLxmRExvZygKSbb27Vp9hS0Tv8mL0WmFbwfRF29nY0Q= -github.com/DeRuina/timberjack v1.3.8/go.mod h1:RLoeQrwrCGIEF8gO5nV5b/gMD0QIy7bzQhBUgpp1EqE= -github.com/KimMachineGun/automemlimit v0.7.4 h1:UY7QYOIfrr3wjjOAqahFmC3IaQCLWvur9nmfIn6LnWk= -github.com/KimMachineGun/automemlimit v0.7.4/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM= +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= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= -github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= -github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= -github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 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.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= -github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +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.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= -github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= -github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= -github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= 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.38.0 h1:UCRQ5mlqcFk9HJDIqENSLR3wiG1VTWlyUfLDEvY7RxU= -github.com/aws/aws-sdk-go-v2 v1.38.0/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= -github.com/aws/aws-sdk-go-v2/config v1.31.0 h1:9yH0xiY5fUnVNLRWO0AtayqwU1ndriZdN78LlhruJR4= -github.com/aws/aws-sdk-go-v2/config v1.31.0/go.mod h1:VeV3K72nXnhbe4EuxxhzsDc/ByrCSlZwUnWH52Nde/I= -github.com/aws/aws-sdk-go-v2/credentials v1.18.4 h1:IPd0Algf1b+Qy9BcDp0sCUcIWdCQPSzDoMK3a8pcbUM= -github.com/aws/aws-sdk-go-v2/credentials v1.18.4/go.mod h1:nwg78FjH2qvsRM1EVZlX9WuGUJOL5od+0qvm0adEzHk= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.3 h1:GicIdnekoJsjq9wqnvyi2elW6CGMSYKhdozE7/Svh78= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.3/go.mod h1:R7BIi6WNC5mc1kfRM7XM/VHC3uRWkjc396sfabq4iOo= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.3 h1:o9RnO+YZ4X+kt5Z7Nvcishlz0nksIt2PIzDglLMP0vA= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.3/go.mod h1:+6aLJzOG1fvMOyzIySYjOFjcguGvVRL68R+uoRencN4= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.3 h1:joyyUFhiTQQmVK6ImzNU9TQSNRNeD9kOklqTzyk5v6s= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.3/go.mod h1:+vNIyZQP3b3B1tSLI0lxvrU9cfM7gpdRXMFfm67ZcPc= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0 h1:6+lZi2JeGKtCraAj1rpoZfKqnQ9SptseRZioejfUOLM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.0/go.mod h1:eb3gfbVIxIoGgJsi9pGne19dhCBpK6opTYpQqAmdy44= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.3 h1:ieRzyHXypu5ByllM7Sp4hC5f/1Fy5wqxqY0yB85hC7s= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.3/go.mod h1:O5ROz8jHiOAKAwx179v+7sHMhfobFVi6nZt8DEyiYoM= -github.com/aws/aws-sdk-go-v2/service/kms v1.44.0 h1:Z95XCqqSnwXr0AY7PgsiOUBhUG2GoDM5getw6RfD1Lg= -github.com/aws/aws-sdk-go-v2/service/kms v1.44.0/go.mod h1:DqcSngL7jJeU1fOzh5Ll5rSvX/MlMV6OZlE4mVdFAQc= -github.com/aws/aws-sdk-go-v2/service/sso v1.28.0 h1:Mc/MKBf2m4VynyJkABoVEN+QzkfLqGj0aiJuEe7cMeM= -github.com/aws/aws-sdk-go-v2/service/sso v1.28.0/go.mod h1:iS5OmxEcN4QIPXARGhavH7S8kETNL11kym6jhoS7IUQ= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.0 h1:6csaS/aJmqZQbKhi1EyEMM7yBW653Wy/B9hnBofW+sw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.33.0/go.mod h1:59qHWaY5B+Rs7HGTuVGaC32m0rdpQ68N8QCN3khYiqs= -github.com/aws/aws-sdk-go-v2/service/sts v1.37.0 h1:MG9VFW43M4A8BYeAfaJJZWrroinxeTi2r3+SnmLQfSA= -github.com/aws/aws-sdk-go-v2/service/sts v1.37.0/go.mod h1:JdeBDPgpJfuS6rU/hNglmOigKhyEZtBmbraLE4GK1J8= -github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= -github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +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.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/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g= -github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= -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/ccoveille/go-safecast v1.6.1 h1:Nb9WMDR8PqhnKCVs2sCB+OqhohwO5qaXtCviZkIff5Q= -github.com/ccoveille/go-safecast v1.6.1/go.mod h1:QqwNjxQ7DAqY0C721OIO9InMk9zCwcsO7tnRuHytad8= +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= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= @@ -114,15 +106,13 @@ 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/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -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.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= -github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= @@ -150,25 +140,19 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= -github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= -github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= 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= -github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= -github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= -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-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +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.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= -github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -176,56 +160,39 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 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= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= github.com/google/btree v1.1.2/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.1.8-0.20240110162603-74a5dd331745 h1:heyoXNxkRT155x4jTAiSv5BVSVkueifPUm+Q8LUXMRo= github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745/go.mod h1:zN0wUQgV9LjwLZeFHnrAbQi8hzMVvEWePyk+MhPOk7k= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= -github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= -github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= -github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/google/go-tpm-tools v0.4.5 h1:3fhthtyMDbIZFR5/0y1hvUoZ1Kf4i1eZ7C73R4Pvd+k= -github.com/google/go-tpm-tools v0.4.5/go.mod h1:ktjTNq8yZFD6TzdBFefUfen96rF3NpYwpSb2d8bc+Y8= +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= github.com/google/go-tspi v0.3.0/go.mod h1:xfMGI3G0PhxCdNVcYr1C4C+EizojDg/TXuX5by8CiHI= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= 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.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= -github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY= -github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg= -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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= +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/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= 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= @@ -242,14 +209,10 @@ github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= -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/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= @@ -257,32 +220,30 @@ github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 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/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= -github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +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/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 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/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= -github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= -github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= +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= @@ -291,20 +252,15 @@ github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= -github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= -github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 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= @@ -312,24 +268,22 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= -github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 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.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= -github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= -github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= -github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= -github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= -github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +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.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/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= @@ -337,59 +291,32 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/schollz/jsonstore v1.1.0 h1:WZBDjgezFS34CHI+myb4s8GGpir3UMpy7vWoCeO0n6E= github.com/schollz/jsonstore v1.1.0/go.mod h1:15c6+9guw8vDRyozGjN3FoILt0wpruJk9Pi66vjaZfg= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY= -github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM= -github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= -github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= -github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ= -github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw= -github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI= -github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= -github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag= -github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg= -github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw= -github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y= -github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= -github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q= -github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ= -github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I= -github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0= -github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ= -github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk= -github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 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/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4= -github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -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.5 h1:ZrxcvP/lxwFglaijmiwXLuCSkybZMJnqSYI1S8DtGnY= -github.com/slackhq/nebula v1.9.5/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.28.4 h1:JTU6/A5Xes6m+OsR6fw1RACSA362vJc9SOFVG7poBEw= -github.com/smallstep/certificates v0.28.4/go.mod h1:LUqo+7mKZE7FZldlTb0zhU4A0bq4G4+akieFMcTaWvA= -github.com/smallstep/cli-utils v0.12.1 h1:D9QvfbFqiKq3snGZ2xDcXEFrdFJ1mQfPHZMq/leerpE= -github.com/smallstep/cli-utils v0.12.1/go.mod h1:skV2Neg8qjiKPu2fphM89H9bIxNpKiiRTnX9Q6Lc+20= +github.com/smallstep/certificates v0.30.0-rc3 h1:Lx/NNJ4n+L3Pyx5NtVRGXeqviPPXTFFGLRiC1fCwU50= +github.com/smallstep/certificates v0.30.0-rc3/go.mod h1:e5/ylYYpvnjCVZz6RpyOkpTe73EGPYoL+8TZZ5EtLjI= +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= github.com/smallstep/go-attestation v0.4.4-0.20241119153605-2306d5b464ca/go.mod h1:vNAduivU014fubg6ewygkAvQC0IQVXqdc8vaGl/0er4= -github.com/smallstep/linkedca v0.23.0 h1:5W/7EudlK1HcCIdZM68dJlZ7orqCCCyv6bm2l/0JmLU= -github.com/smallstep/linkedca v0.23.0/go.mod h1:7cyRM9soAYySg9ag65QwytcgGOM+4gOlkJ/YA58A9E8= +github.com/smallstep/linkedca v0.25.0 h1:txT9QHGbCsJq0MhAghBq7qhurGY727tQuqUi+n4BVBo= +github.com/smallstep/linkedca v0.25.0/go.mod h1:Q3jVAauFKNlF86W5/RFtgQeyDKz98GL/KN3KG4mJOvc= github.com/smallstep/nosql v0.7.0 h1:YiWC9ZAHcrLCrayfaF+QJUv16I2bZ7KdLC3RpJcnAnE= github.com/smallstep/nosql v0.7.0/go.mod h1:H5VnKMCbeq9QA6SRY5iqPylfxLfYcLwvUff3onQ8+HU= -github.com/smallstep/pkcs7 v0.0.0-20240911091500-b1cae6277023/go.mod h1:CM5KrX7rxWgwDdMj9yef/pJB2OPgy/56z4IEx2UIbpc= github.com/smallstep/pkcs7 v0.2.1 h1:6Kfzr/QizdIuB6LSv8y1LJdZ3aPSfTNhTLqAx9CTLfA= github.com/smallstep/pkcs7 v0.2.1/go.mod h1:RcXHsMfL+BzH8tRhmrF1NkkpebKpq3JEM66cOFxanf0= -github.com/smallstep/scep v0.0.0-20240926084937-8cf1ca453101 h1:LyZqn24/ZiVg8v9Hq07K6mx6RqPtpDeK+De5vf4QEY4= -github.com/smallstep/scep v0.0.0-20240926084937-8cf1ca453101/go.mod h1:EuKQjYGQwhUa1mgD21zxIgOgUYLsqikJmvxNscxpS/Y= +github.com/smallstep/scep v0.0.0-20250318231241-a25cabb69492 h1:k23+s51sgYix4Zgbvpmy+1ZgXLjr4ZTkBTqXmpnImwA= +github.com/smallstep/scep v0.0.0-20250318231241-a25cabb69492/go.mod h1:QQhwLqCS13nhv8L5ov7NgusowENUtXdEzdytjmJHdZQ= github.com/smallstep/truststore v0.13.0 h1:90if9htAOblavbMeWlqNLnO9bsjjgVv2hQeQJCi/py4= github.com/smallstep/truststore v0.13.0/go.mod h1:3tmMp2aLKZ/OA/jnFUB0cYPcho402UG2knuJoPh4j7A= -github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE= -github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -398,15 +325,14 @@ github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= -github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= -github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= 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= @@ -414,7 +340,6 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 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= @@ -422,21 +347,20 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl 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= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53 h1:uxMgm0C+EjytfAqyfBG55ZONKQ7mvd7x4YYCWsf8QHQ= -github.com/tailscale/tscert v0.0.0-20240608151842-d3f834017e53/go.mod h1:kNGUQ3VESx3VZwRwA9MSCUegIl6+saPL8Noq82ozCaU= -github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= +github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= +github.com/tailscale/tscert v0.0.0-20251216020129-aea342f6d747 h1:RnBbFMmodYzhC6adOjTbtUQXyzV8dcvKYbolzs6Qch0= +github.com/tailscale/tscert v0.0.0-20251216020129-aea342f6d747/go.mod h1:ejPAJui3kVK4u5TgMtqtXlWf5HnKh9fLy5kvpaeuas0= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ= github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo= -github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU= -github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= 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= @@ -447,92 +371,104 @@ github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ= -go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +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.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= -go.opentelemetry.io/contrib/propagators/autoprop v0.63.0 h1:S3+4UwR3Y1tUKklruMwOacAFInNvtuOexz4ZTmJNAyw= -go.opentelemetry.io/contrib/propagators/autoprop v0.63.0/go.mod h1:qpIuOggbbw2T9nKRaO1je/oTRKd4zslAcJonN8LYbTg= -go.opentelemetry.io/contrib/propagators/aws v1.38.0 h1:eRZ7asSbLc5dH7+TBzL6hFKb1dabz0IV51uUUwYRZts= -go.opentelemetry.io/contrib/propagators/aws v1.38.0/go.mod h1:wXqc9NTGcXapBExHBDVLEZlByu6quiQL8w7Tjgv8TCg= -go.opentelemetry.io/contrib/propagators/b3 v1.38.0 h1:uHsCCOSKl0kLrV2dLkFK+8Ywk9iKa/fptkytc6aFFEo= -go.opentelemetry.io/contrib/propagators/b3 v1.38.0/go.mod h1:wMRSZJZcY8ya9mApLLhwIMjqmApy2o/Ml+62lhvxyHU= -go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 h1:nXGeLvT1QtCAhkASkP/ksjkTKZALIaQBIW+JSIw1KIc= -go.opentelemetry.io/contrib/propagators/jaeger v1.38.0/go.mod h1:oMvOXk78ZR3KEuPMBgp/ThAMDy9ku/eyUVztr+3G6Wo= -go.opentelemetry.io/contrib/propagators/ot v1.38.0 h1:k4gSyyohaDXI8F9BDXYC3uO2vr5sRNeQFMsN9Zn0EoI= -go.opentelemetry.io/contrib/propagators/ot v1.38.0/go.mod h1:2hDsuiHRO39SRUMhYGqmj64z/IuMRoxE4bBSFR82Lo8= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= -go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= -go.step.sm/crypto v0.70.0 h1:Q9Ft7N637mucyZcHZd1+0VVQJVwDCKqcb9CYcYi7cds= -go.step.sm/crypto v0.70.0/go.mod h1:pzfUhS5/ue7ev64PLlEgXvhx1opwbhFCjkvlhsxVds0= +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.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= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= -go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= 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= -go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE= -golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw= -golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +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.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 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.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/crypto/x509roots/fallback v0.0.0-20250927194341-2beaa59a3c99 h1:CH0o4/bZX6KIUCjjgjmtNtfM/kXSkTYlzTOB9vZF45g= -golang.org/x/crypto/x509roots/fallback v0.0.0-20250927194341-2beaa59a3c99/go.mod h1:MEIPiCnxvQEjA4astfaKItNwEVZA5Ki+3+nyGbJ5N18= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= -golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= -golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +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-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= 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.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +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= @@ -541,55 +477,37 @@ 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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= -golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +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= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +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.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 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.0.0-20220811171246-fbc7d0a398ab/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= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.25.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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.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= @@ -598,12 +516,10 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 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.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +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.1-0.20180807135948-17ff2d5776d2/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= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= @@ -611,76 +527,45 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 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.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= -golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +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-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +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.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= -google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= -google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y= -google.golang.org/api v0.247.0 h1:tSd/e0QrUlLsrwMKmkbQhYVa109qIintOls2Wh6bngc= -google.golang.org/api v0.247.0/go.mod h1:r1qZOPmxXffXg6xS5uhx16Fa/UFY8QU/K4bfKrnvovM= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= -google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -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-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= -google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= -google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= -google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= -google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/api v0.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk= +google.golang.org/api v0.266.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-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +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.5.1 h1:F29+wU6Ee6qgu9TddPgooOdaqsxTMunOoj8KA5yuS5A= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o= -honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= -sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= -sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= diff --git a/listeners.go b/listeners.go index 512cfcbda..bf69b39d3 100644 --- a/listeners.go +++ b/listeners.go @@ -31,7 +31,7 @@ import ( "github.com/quic-go/quic-go" "github.com/quic-go/quic-go/http3" - "github.com/quic-go/quic-go/qlog" + h3qlog "github.com/quic-go/quic-go/http3/qlog" "go.uber.org/zap" "golang.org/x/time/rate" @@ -229,7 +229,7 @@ func (na NetworkAddress) JoinHostPort(offset uint) string { func (na NetworkAddress) Expand() []NetworkAddress { size := na.PortRangeSize() addrs := make([]NetworkAddress, size) - for portOffset := uint(0); portOffset < size; portOffset++ { + for portOffset := range size { addrs[portOffset] = na.At(portOffset) } return addrs @@ -431,7 +431,7 @@ func JoinNetworkAddress(network, host, port string) string { // // NOTE: This API is EXPERIMENTAL and may be changed or removed. // NOTE: user should close the returned listener twice, once to stop accepting new connections, the second time to free up the packet conn. -func (na NetworkAddress) ListenQUIC(ctx context.Context, portOffset uint, config net.ListenConfig, tlsConf *tls.Config) (http3.QUICListener, error) { +func (na NetworkAddress) ListenQUIC(ctx context.Context, portOffset uint, config net.ListenConfig, tlsConf *tls.Config, pcWrappers []PacketConnWrapper, allow0rttconf *bool) (http3.QUICListener, error) { lnKey := listenerKey("quic"+na.Network, na.JoinHostPort(portOffset)) sharedEarlyListener, _, err := listenerPool.LoadOrNew(lnKey, func() (Destructor, error) { @@ -443,12 +443,19 @@ func (na NetworkAddress) ListenQUIC(ctx context.Context, portOffset uint, config ln := lnAny.(net.PacketConn) h3ln := ln - for { - // retrieve the underlying socket, so quic-go can optimize. - if unwrapper, ok := h3ln.(interface{ Unwrap() net.PacketConn }); ok { - h3ln = unwrapper.Unwrap() - } else { - break + if len(pcWrappers) == 0 { + for { + // retrieve the underlying socket, so quic-go can optimize. + if unwrapper, ok := h3ln.(interface{ Unwrap() net.PacketConn }); ok { + h3ln = unwrapper.Unwrap() + } else { + break + } + } + } else { + // wrap packet conn before QUIC + for _, pcWrapper := range pcWrappers { + h3ln = pcWrapper.WrapPacketConn(h3ln) } } @@ -463,11 +470,15 @@ func (na NetworkAddress) ListenQUIC(ctx context.Context, portOffset uint, config Conn: h3ln, VerifySourceAddress: func(addr net.Addr) bool { return !limiter.Allow() }, } + allow0rtt := true + if allow0rttconf != nil { + allow0rtt = *allow0rttconf + } earlyLn, err := tr.ListenEarly( http3.ConfigureTLSConfig(quicTlsConfig), &quic.Config{ - Allow0RTT: true, - Tracer: qlog.DefaultConnectionTracer, + Allow0RTT: allow0rtt, + Tracer: h3qlog.DefaultConnectionTracer, }, ) if err != nil { @@ -501,7 +512,7 @@ func ListenerUsage(network, addr string) int { // contextAndCancelFunc groups context and its cancelFunc type contextAndCancelFunc struct { context.Context - context.CancelFunc + context.CancelCauseFunc } // sharedQUICState manages GetConfigForClient @@ -531,17 +542,17 @@ func (sqs *sharedQUICState) getConfigForClient(ch *tls.ClientHelloInfo) (*tls.Co // addState adds tls.Config and activeRequests to the map if not present and returns the corresponding context and its cancelFunc // so that when cancelled, the active tls.Config will change -func (sqs *sharedQUICState) addState(tlsConfig *tls.Config) (context.Context, context.CancelFunc) { +func (sqs *sharedQUICState) addState(tlsConfig *tls.Config) (context.Context, context.CancelCauseFunc) { sqs.rmu.Lock() defer sqs.rmu.Unlock() if cacc, ok := sqs.tlsConfs[tlsConfig]; ok { - return cacc.Context, cacc.CancelFunc + return cacc.Context, cacc.CancelCauseFunc } - ctx, cancel := context.WithCancel(context.Background()) - wrappedCancel := func() { - cancel() + ctx, cancel := context.WithCancelCause(context.Background()) + wrappedCancel := func(cause error) { + cancel(cause) sqs.rmu.Lock() defer sqs.rmu.Unlock() @@ -597,13 +608,13 @@ func fakeClosedErr(l interface{ Addr() net.Addr }) error { // indicating that it is pretending to be closed so that the // server using it can terminate, while the underlying // socket is actually left open. -var errFakeClosed = fmt.Errorf("listener 'closed' 😉") +var errFakeClosed = fmt.Errorf("QUIC listener 'closed' 😉") type fakeCloseQuicListener struct { closed int32 // accessed atomically; belongs to this struct only *sharedQuicListener // embedded, so we also become a quic.EarlyListener context context.Context - contextCancel context.CancelFunc + contextCancel context.CancelCauseFunc } // Currently Accept ignores the passed context, however a situation where @@ -626,7 +637,7 @@ func (fcql *fakeCloseQuicListener) Accept(_ context.Context) (*quic.Conn, error) func (fcql *fakeCloseQuicListener) Close() error { if atomic.CompareAndSwapInt32(&fcql.closed, 0, 1) { - fcql.contextCancel() + fcql.contextCancel(errFakeClosed) } else if atomic.CompareAndSwapInt32(&fcql.closed, 1, 2) { _, _ = listenerPool.Delete(fcql.sharedQuicListener.key) } @@ -695,6 +706,19 @@ type ListenerWrapper interface { WrapListener(net.Listener) net.Listener } +// PacketConnWrapper is a type that wraps a packet conn +// so it can modify the input packet conn methods. +// Modules that implement this interface are found +// in the caddy.packetconns namespace. Usually, to +// wrap a packet conn, you will define your own struct +// type that embeds the input packet conn, then +// implement your own methods that you want to wrap, +// calling the underlying packet conn methods where +// appropriate. +type PacketConnWrapper interface { + WrapPacketConn(net.PacketConn) net.PacketConn +} + // listenerPool stores and allows reuse of active listeners. var listenerPool = NewUsagePool() diff --git a/modules.go b/modules.go index 24c452589..93a9343f6 100644 --- a/modules.go +++ b/modules.go @@ -342,7 +342,11 @@ func ParseStructTag(tag string) (map[string]string, error) { func StrictUnmarshalJSON(data []byte, v any) error { dec := json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() - return dec.Decode(v) + err := dec.Decode(v) + if jsonErr, ok := err.(*json.SyntaxError); ok { + return fmt.Errorf("%w, at offset %d", jsonErr, jsonErr.Offset) + } + return err } var JSONRawMessageType = reflect.TypeFor[json.RawMessage]() diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go index 7611285f7..74f1466be 100644 --- a/modules/caddyhttp/app.go +++ b/modules/caddyhttp/app.go @@ -18,6 +18,7 @@ import ( "cmp" "context" "crypto/tls" + "errors" "fmt" "maps" "net" @@ -51,6 +52,7 @@ func init() { // Placeholder | Description // ------------|--------------- // `{http.request.body}` | The request body (⚠️ inefficient; use only for debugging) +// `{http.request.body_base64}` | The request body, base64-encoded (⚠️ for debugging) // `{http.request.cookie.*}` | HTTP request cookie // `{http.request.duration}` | Time up to now spent handling the request (after decoding headers from client) // `{http.request.duration_ms}` | Same as 'duration', but in milliseconds. @@ -82,6 +84,7 @@ func init() { // `{http.request.tls.proto}` | The negotiated next protocol // `{http.request.tls.proto_mutual}` | The negotiated next protocol was advertised by the server // `{http.request.tls.server_name}` | The server name requested by the client, if any +// `{http.request.tls.ech}` | Whether ECH was offered by the client and accepted by the server // `{http.request.tls.client.fingerprint}` | The SHA256 checksum of the client certificate // `{http.request.tls.client.public_key}` | The public key of the client certificate. // `{http.request.tls.client.public_key_sha256}` | The SHA256 checksum of the client's public key. @@ -198,6 +201,8 @@ func (app *App) Provision(ctx caddy.Context) error { if app.Metrics != nil { app.Metrics.init = sync.Once{} app.Metrics.httpMetrics = &httpMetrics{} + // Scan config for allowed hosts to prevent cardinality explosion + app.Metrics.scanConfigForHosts(app) } // prepare each server oldContext := ctx.Context @@ -344,6 +349,20 @@ func (app *App) Provision(ctx caddy.Context) error { srv.listenerWrappers = append([]caddy.ListenerWrapper{new(tlsPlaceholderWrapper)}, srv.listenerWrappers...) } } + + // set up each packet conn modifier + if srv.PacketConnWrappersRaw != nil { + vals, err := ctx.LoadModule(srv, "PacketConnWrappersRaw") + if err != nil { + return fmt.Errorf("loading packet conn wrapper modules: %v", err) + } + // if any wrappers were configured, they come before the QUIC handshake; + // unlike TLS above, there is no QUIC placeholder + for _, val := range vals.([]any) { + srv.packetConnWrappers = append(srv.packetConnWrappers, val.(caddy.PacketConnWrapper)) + } + } + // pre-compile the primary handler chain, and be sure to wrap it in our // route handler so that important security checks are done, etc. primaryRoute := emptyHandler @@ -693,9 +712,10 @@ func (app *App) Stop() error { // enforce grace period if configured if app.GracePeriod > 0 { var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, time.Duration(app.GracePeriod)) + timeout := time.Duration(app.GracePeriod) + ctx, cancel = context.WithTimeoutCause(ctx, timeout, fmt.Errorf("server graceful shutdown %ds timeout", int(timeout.Seconds()))) defer cancel() - app.logger.Info("servers shutting down; grace period initiated", zap.Duration("duration", time.Duration(app.GracePeriod))) + app.logger.Info("servers shutting down; grace period initiated", zap.Duration("duration", timeout)) } else { app.logger.Info("servers shutting down with eternal grace period") } @@ -721,6 +741,9 @@ func (app *App) Stop() error { } if err := server.server.Shutdown(ctx); err != nil { + if cause := context.Cause(ctx); cause != nil && errors.Is(err, context.DeadlineExceeded) { + err = cause + } app.logger.Error("server shutdown", zap.Error(err), zap.Strings("addresses", server.Listen)) @@ -744,6 +767,9 @@ func (app *App) Stop() error { } if err := server.h3server.Shutdown(ctx); err != nil { + if cause := context.Cause(ctx); cause != nil && errors.Is(err, context.DeadlineExceeded) { + err = cause + } app.logger.Error("HTTP/3 server shutdown", zap.Error(err), zap.Strings("addresses", server.Listen)) diff --git a/modules/caddyhttp/autohttps.go b/modules/caddyhttp/autohttps.go index 05f8a7517..32e9f106d 100644 --- a/modules/caddyhttp/autohttps.go +++ b/modules/caddyhttp/autohttps.go @@ -90,7 +90,16 @@ func (app *App) automaticHTTPSPhase1(ctx caddy.Context, repl *caddy.Replacer) er // the log configuration for an HTTPS enabled server var logCfg *ServerLogConfig - for srvName, srv := range app.Servers { + // Sort server names to ensure deterministic iteration. + // This prevents race conditions where the order of server processing + // could affect which server gets assigned the HTTP->HTTPS redirect listener. + srvNames := make([]string, 0, len(app.Servers)) + for name := range app.Servers { + srvNames = append(srvNames, name) + } + slices.Sort(srvNames) + for _, srvName := range srvNames { + srv := app.Servers[srvName] // as a prerequisite, provision route matchers; this is // required for all routes on all servers, and must be // done before we attempt to do phase 1 of auto HTTPS, @@ -398,15 +407,60 @@ uniqueDomainsLoop: return append(routes, app.makeRedirRoute(uint(app.httpsPort()), MatcherSet{MatchProtocol("http")})) } + // Sort redirect addresses to ensure deterministic process + redirServerAddrsSorted := make([]string, 0, len(redirServers)) + for addr := range redirServers { + redirServerAddrsSorted = append(redirServerAddrsSorted, addr) + } + slices.Sort(redirServerAddrsSorted) + redirServersLoop: - for redirServerAddr, routes := range redirServers { + for _, redirServerAddr := range redirServerAddrsSorted { + routes := redirServers[redirServerAddr] // for each redirect listener, see if there's already a // server configured to listen on that exact address; if so, // insert the redirect route to the end of its route list // after any other routes with host matchers; otherwise, // we'll create a new server for all the listener addresses // that are unused and serve the remaining redirects from it - for _, srv := range app.Servers { + + // Sort redirect routes by host specificity to ensure exact matches + // take precedence over wildcards, preventing ambiguous routing. + slices.SortFunc(routes, func(a, b Route) int { + hostA := getFirstHostFromRoute(a) + hostB := getFirstHostFromRoute(b) + + // Catch-all routes (empty host) have the lowest priority + if hostA == "" && hostB != "" { + return 1 + } + if hostB == "" && hostA != "" { + return -1 + } + + hasWildcardA := strings.Contains(hostA, "*") + hasWildcardB := strings.Contains(hostB, "*") + + // Exact domains take precedence over wildcards + if !hasWildcardA && hasWildcardB { + return -1 + } + if hasWildcardA && !hasWildcardB { + return 1 + } + + // If both are exact or both are wildcards, the longer one is more specific + if len(hostA) != len(hostB) { + return len(hostB) - len(hostA) + } + + // Tie-breaker: alphabetical order to ensure determinism + return strings.Compare(hostA, hostB) + }) + + // Use the sorted srvNames to consistently find the target server + for _, srvName := range srvNames { + srv := app.Servers[srvName] // only look at servers which listen on an address which // we want to add redirects to if !srv.hasListenerAddress(redirServerAddr) { @@ -560,6 +614,27 @@ func (app *App) createAutomationPolicies(ctx caddy.Context, internalNames, tails } } + // Ensure automation policies' CertMagic configs are rebuilt when + // ACME issuer templates may have been modified above (for example, + // alternate ports filled in by the HTTP app). If a policy is already + // provisioned, perform a lightweight rebuild of the CertMagic config + // so issuers receive SetConfig with the updated templates; otherwise + // run a normal Provision to initialize the policy. + for i, ap := range app.tlsApp.Automation.Policies { + // If the policy is already provisioned, rebuild only the CertMagic + // config so issuers get SetConfig with updated templates. Otherwise + // provision the policy normally (which may load modules). + if ap.IsProvisioned() { + if err := ap.RebuildCertMagic(app.tlsApp); err != nil { + return fmt.Errorf("rebuilding certmagic config for automation policy %d: %v", i, err) + } + } else { + if err := ap.Provision(app.tlsApp); err != nil { + return fmt.Errorf("provisioning automation policy %d after auto-HTTPS defaults: %v", i, err) + } + } + } + if basePolicy == nil { // no base policy found; we will make one basePolicy = new(caddytls.AutomationPolicy) @@ -773,3 +848,26 @@ func isTailscaleDomain(name string) bool { } type acmeCapable interface{ GetACMEIssuer() *caddytls.ACMEIssuer } + +// getFirstHostFromRoute traverses a route's matchers to find the Host rule. +// Since we are dealing with internally generated redirect routes, the host +// is typically the first string within the MatchHost. +func getFirstHostFromRoute(r Route) string { + for _, matcherSet := range r.MatcherSets { + for _, m := range matcherSet { + // Check if the matcher is of type MatchHost (value or pointer) + switch hm := m.(type) { + case MatchHost: + if len(hm) > 0 { + return hm[0] + } + case *MatchHost: + if len(*hm) > 0 { + return (*hm)[0] + } + } + } + } + // Return an empty string if it's a catch-all route (no specific host) + return "" +} diff --git a/modules/caddyhttp/caddyauth/basicauth.go b/modules/caddyhttp/caddyauth/basicauth.go index 5a9e167e1..4152d7908 100644 --- a/modules/caddyhttp/caddyauth/basicauth.go +++ b/modules/caddyhttp/caddyauth/basicauth.go @@ -19,7 +19,7 @@ import ( "encoding/hex" "encoding/json" "fmt" - weakrand "math/rand" + weakrand "math/rand/v2" "net/http" "strings" "sync" @@ -244,7 +244,7 @@ func (c *Cache) makeRoom() { // strategy; generating random numbers is cheap and // ensures a much better distribution. //nolint:gosec - rnd := weakrand.Intn(len(c.cache)) + rnd := weakrand.IntN(len(c.cache)) i := 0 for key := range c.cache { if i == rnd { @@ -287,7 +287,7 @@ type Account struct { // The user's hashed password, in Modular Crypt Format (with `$` prefix) // or base64-encoded. - Password string `json:"password"` + Password string `json:"password"` //nolint:gosec // false positive, this is a hashed password password []byte } diff --git a/modules/caddyhttp/celmatcher.go b/modules/caddyhttp/celmatcher.go index 3d118ea79..3038c8926 100644 --- a/modules/caddyhttp/celmatcher.go +++ b/modules/caddyhttp/celmatcher.go @@ -412,10 +412,12 @@ func CELMatcherImpl(macroName, funcName string, matcherDataTypes []*cel.Type, fa return nil, fmt.Errorf("unsupported matcher data type: %s, %s", matcherDataTypes[0], matcherDataTypes[1]) } case 3: + // nolint:gosec // false positive, impossible to be out of bounds; see: https://github.com/securego/gosec/issues/1525 if matcherDataTypes[0] == cel.StringType && matcherDataTypes[1] == cel.StringType && matcherDataTypes[2] == cel.StringType { macro = parser.NewGlobalMacro(macroName, 3, celMatcherStringListMacroExpander(funcName)) matcherDataTypes = []*cel.Type{cel.ListType(cel.StringType)} } else { + // nolint:gosec // false positive, impossible to be out of bounds; see: https://github.com/securego/gosec/issues/1525 return nil, fmt.Errorf("unsupported matcher data type: %s, %s, %s", matcherDataTypes[0], matcherDataTypes[1], matcherDataTypes[2]) } } @@ -665,12 +667,29 @@ func celMatcherJSONMacroExpander(funcName string) parser.MacroExpander { // map literals containing heterogeneous values, in this case string and list // of string. func CELValueToMapStrList(data ref.Val) (map[string][]string, error) { - mapStrType := reflect.TypeOf(map[string]any{}) + // Prefer map[string]any, but newer cel-go versions may return map[any]any + mapStrType := reflect.TypeFor[map[string]any]() mapStrRaw, err := data.ConvertToNative(mapStrType) + var mapStrIface map[string]any if err != nil { - return nil, err + // Try map[any]any and convert keys to strings + mapAnyType := reflect.TypeFor[map[any]any]() + mapAnyRaw, err2 := data.ConvertToNative(mapAnyType) + if err2 != nil { + return nil, err + } + mapAnyIface := mapAnyRaw.(map[any]any) + mapStrIface = make(map[string]any, len(mapAnyIface)) + for k, v := range mapAnyIface { + ks, ok := k.(string) + if !ok { + return nil, fmt.Errorf("unsupported map key type in header match: %T", k) + } + mapStrIface[ks] = v + } + } else { + mapStrIface = mapStrRaw.(map[string]any) } - mapStrIface := mapStrRaw.(map[string]any) mapStrListStr := make(map[string][]string, len(mapStrIface)) for k, v := range mapStrIface { switch val := v.(type) { @@ -685,13 +704,26 @@ func CELValueToMapStrList(data ref.Val) (map[string][]string, error) { for i, elem := range val { strVal, ok := elem.(types.String) if !ok { - return nil, fmt.Errorf("unsupported value type in header match: %T", val) + return nil, fmt.Errorf("unsupported value type in matcher input: %T", val) } convVals[i] = string(strVal) } mapStrListStr[k] = convVals + case []any: + convVals := make([]string, len(val)) + for i, elem := range val { + switch e := elem.(type) { + case string: + convVals[i] = e + case types.String: + convVals[i] = string(e) + default: + return nil, fmt.Errorf("unsupported element type in matcher input list: %T", elem) + } + } + mapStrListStr[k] = convVals default: - return nil, fmt.Errorf("unsupported value type in header match: %T", val) + return nil, fmt.Errorf("unsupported value type in matcher input: %T", val) } } return mapStrListStr, nil diff --git a/modules/caddyhttp/encode/encode.go b/modules/caddyhttp/encode/encode.go index e23d9109c..ac995c37b 100644 --- a/modules/caddyhttp/encode/encode.go +++ b/modules/caddyhttp/encode/encode.go @@ -168,8 +168,8 @@ func (enc *Encode) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyh // caches without knowing about our changes... if etag := r.Header.Get("If-None-Match"); etag != "" && !strings.HasPrefix(etag, "W/") { ourSuffix := "-" + encName + `"` - if strings.HasSuffix(etag, ourSuffix) { - etag = strings.TrimSuffix(etag, ourSuffix) + `"` + if before, ok := strings.CutSuffix(etag, ourSuffix); ok { + etag = before + `"` r.Header.Set("If-None-Match", etag) } } diff --git a/modules/caddyhttp/errors.go b/modules/caddyhttp/errors.go index f1e7de894..d27df6626 100644 --- a/modules/caddyhttp/errors.go +++ b/modules/caddyhttp/errors.go @@ -17,7 +17,7 @@ package caddyhttp import ( "errors" "fmt" - weakrand "math/rand" + weakrand "math/rand/v2" "path" "runtime" "strings" @@ -101,7 +101,7 @@ func randString(n int, sameCase bool) string { b := make([]byte, n) for i := range b { //nolint:gosec - b[i] = dict[weakrand.Int63()%int64(len(dict))] + b[i] = dict[weakrand.IntN(len(dict))] } return string(b) } diff --git a/modules/caddyhttp/fileserver/browse.go b/modules/caddyhttp/fileserver/browse.go index 52aa7a9f8..304417009 100644 --- a/modules/caddyhttp/fileserver/browse.go +++ b/modules/caddyhttp/fileserver/browse.go @@ -169,6 +169,7 @@ func (fsrv *FileServer) serveBrowse(fileSystem fs.FS, root, dirPath string, w ht // Actual files for _, item := range listing.Items { + //nolint:gosec // not sure how this could be XSS unless you lose control of the file system (like aren't sanitizing) and client ignores Content-Type of text/plain if _, err := fmt.Fprintf(writer, "%s\t%s\t%s\n", item.Name, item.HumanSize(), item.HumanModTime("January 2, 2006 at 15:04:05"), ); err != nil { diff --git a/modules/caddyhttp/fileserver/matcher.go b/modules/caddyhttp/fileserver/matcher.go index fbcd36e0a..8ced5a19d 100644 --- a/modules/caddyhttp/fileserver/matcher.go +++ b/modules/caddyhttp/fileserver/matcher.go @@ -404,7 +404,7 @@ func (m MatchFile) selectFile(r *http.Request) (bool, error) { } // for each glob result, combine all the forms of the path - var candidates []matchCandidate + candidates := make([]matchCandidate, 0, len(globResults)) for _, result := range globResults { candidates = append(candidates, matchCandidate{ fullpath: result, @@ -720,6 +720,7 @@ var globSafeRepl = strings.NewReplacer( "*", "\\*", "[", "\\[", "?", "\\?", + "\\", "\\\\", ) const ( diff --git a/modules/caddyhttp/fileserver/matcher_test.go b/modules/caddyhttp/fileserver/matcher_test.go index f0ec4b392..4342d5d31 100644 --- a/modules/caddyhttp/fileserver/matcher_test.go +++ b/modules/caddyhttp/fileserver/matcher_test.go @@ -20,7 +20,9 @@ import ( "net/http/httptest" "net/url" "os" + "path/filepath" "runtime" + "strings" "testing" "github.com/caddyserver/caddy/v2" @@ -28,6 +30,13 @@ import ( "github.com/caddyserver/caddy/v2/modules/caddyhttp" ) +type testCase struct { + path string + expectedPath string + expectedType string + matched bool +} + func TestFileMatcher(t *testing.T) { // Windows doesn't like colons in files names isWindows := runtime.GOOS == "windows" @@ -45,12 +54,7 @@ func TestFileMatcher(t *testing.T) { f.Close() } - for i, tc := range []struct { - path string - expectedPath string - expectedType string - matched bool - }{ + for i, tc := range []testCase{ { path: "/foo.txt", expectedPath: "/foo.txt", @@ -116,44 +120,71 @@ func TestFileMatcher(t *testing.T) { matched: !isWindows, }, } { - m := &MatchFile{ - fsmap: &filesystems.FileSystemMap{}, - Root: "./testdata", - TryFiles: []string{"{http.request.uri.path}", "{http.request.uri.path}/"}, - } + fileMatcherTest(t, i, tc) + } +} - u, err := url.Parse(tc.path) - if err != nil { - t.Errorf("Test %d: parsing path: %v", i, err) - } +func TestFileMatcherNonWindows(t *testing.T) { + if runtime.GOOS == "windows" { + return + } - req := &http.Request{URL: u} - repl := caddyhttp.NewTestReplacer(req) + // this is impossible to test on Windows, but tests a security patch for other platforms + tc := testCase{ + path: "/foodir/secr%5Cet.txt", + expectedPath: "/foodir/secr\\et.txt", + expectedType: "file", + matched: true, + } - result, err := m.MatchWithError(req) - if err != nil { - t.Errorf("Test %d: unexpected error: %v", i, err) - } - if result != tc.matched { - t.Errorf("Test %d: expected match=%t, got %t", i, tc.matched, result) - } + f, err := os.Create(filepath.Join("testdata", strings.TrimPrefix(tc.expectedPath, "/"))) + if err != nil { + t.Fatalf("could not create test file: %v", err) + } + defer f.Close() + defer os.Remove(f.Name()) - rel, ok := repl.Get("http.matchers.file.relative") - if !ok && result { - t.Errorf("Test %d: expected replacer value", i) - } - if !result { - continue - } + fileMatcherTest(t, 0, tc) +} - if rel != tc.expectedPath { - t.Errorf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath) - } +func fileMatcherTest(t *testing.T, i int, tc testCase) { + m := &MatchFile{ + fsmap: &filesystems.FileSystemMap{}, + Root: "./testdata", + TryFiles: []string{"{http.request.uri.path}", "{http.request.uri.path}/"}, + } - fileType, _ := repl.Get("http.matchers.file.type") - if fileType != tc.expectedType { - t.Errorf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType) - } + u, err := url.Parse(tc.path) + if err != nil { + t.Errorf("Test %d: parsing path: %v", i, err) + } + + req := &http.Request{URL: u} + repl := caddyhttp.NewTestReplacer(req) + + result, err := m.MatchWithError(req) + if err != nil { + t.Errorf("Test %d: unexpected error: %v", i, err) + } + if result != tc.matched { + t.Errorf("Test %d: expected match=%t, got %t", i, tc.matched, result) + } + + rel, ok := repl.Get("http.matchers.file.relative") + if !ok && result { + t.Errorf("Test %d: expected replacer value", i) + } + if !result { + return + } + + if rel != tc.expectedPath { + t.Errorf("Test %d: actual path: %v, expected: %v", i, rel, tc.expectedPath) + } + + fileType, _ := repl.Get("http.matchers.file.type") + if fileType != tc.expectedType { + t.Errorf("Test %d: actual file type: %v, expected: %v", i, fileType, tc.expectedType) } } diff --git a/modules/caddyhttp/fileserver/staticfiles.go b/modules/caddyhttp/fileserver/staticfiles.go index 3daf8daef..dce40302d 100644 --- a/modules/caddyhttp/fileserver/staticfiles.go +++ b/modules/caddyhttp/fileserver/staticfiles.go @@ -20,7 +20,7 @@ import ( "fmt" "io" "io/fs" - weakrand "math/rand" + weakrand "math/rand/v2" "mime" "net/http" "os" @@ -125,6 +125,11 @@ type FileServer struct { // When possible, all paths are resolved to their absolute form before // comparisons are made. For maximum clarity and explictness, use complete, // absolute paths; or, for greater portability, use relative paths instead. + // + // Note that hide comparisons are case-sensitive. On case-insensitive + // filesystems, requests with different path casing may still resolve to the + // same file or directory on disk, so hide should not be treated as a + // security boundary for sensitive paths. Hide []string `json:"hide,omitempty"` // The names of files to try as index files if a folder is requested. @@ -601,7 +606,7 @@ func (fsrv *FileServer) openFile(fileSystem fs.FS, filename string, w http.Respo // maybe the server is under load and ran out of file descriptors? // have client wait arbitrary seconds to help prevent a stampede //nolint:gosec - backoff := weakrand.Intn(maxBackoff-minBackoff) + minBackoff + backoff := weakrand.IntN(maxBackoff-minBackoff) + minBackoff w.Header().Set("Retry-After", strconv.Itoa(backoff)) if c := fsrv.logger.Check(zapcore.DebugLevel, "retry after backoff"); c != nil { c.Write(zap.String("filename", filename), zap.Int("backoff", backoff), zap.Error(err)) diff --git a/modules/caddyhttp/headers/caddyfile.go b/modules/caddyhttp/headers/caddyfile.go index f060471b1..2bf7dd4bf 100644 --- a/modules/caddyhttp/headers/caddyfile.go +++ b/modules/caddyhttp/headers/caddyfile.go @@ -168,8 +168,6 @@ func parseReqHdrCaddyfile(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, } h.Next() // consume the directive name again (matcher parsing resets) - configValues := []httpcaddyfile.ConfigValue{} - if !h.NextArg() { return nil, h.ArgErr() } @@ -204,7 +202,7 @@ func parseReqHdrCaddyfile(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, return nil, h.Err(err.Error()) } - configValues = append(configValues, h.NewRoute(matcherSet, hdr)...) + configValues := h.NewRoute(matcherSet, hdr) if h.NextArg() { return nil, h.ArgErr() diff --git a/modules/caddyhttp/headers/headers.go b/modules/caddyhttp/headers/headers.go index 33d9e39ee..97eee07ba 100644 --- a/modules/caddyhttp/headers/headers.go +++ b/modules/caddyhttp/headers/headers.go @@ -161,11 +161,11 @@ func (ops *HeaderOps) Provision(_ caddy.Context) error { // containsPlaceholders checks if the string contains Caddy placeholder syntax {key} func containsPlaceholders(s string) bool { - openIdx := strings.Index(s, "{") - if openIdx == -1 { + _, after, ok := strings.Cut(s, "{") + if !ok { return false } - closeIdx := strings.Index(s[openIdx+1:], "}") + closeIdx := strings.Index(after, "}") if closeIdx == -1 { return false } @@ -217,7 +217,10 @@ type RespHeaderOps struct { } // ApplyTo applies ops to hdr using repl. -func (ops HeaderOps) ApplyTo(hdr http.Header, repl *caddy.Replacer) { +func (ops *HeaderOps) ApplyTo(hdr http.Header, repl *caddy.Replacer) { + if ops == nil { + return + } // before manipulating headers in other ways, check if there // is configuration to delete all headers, and do that first // because if a header is to be added, we don't want to delete diff --git a/modules/caddyhttp/intercept/intercept.go b/modules/caddyhttp/intercept/intercept.go index cb23adf0a..bacdc74b5 100644 --- a/modules/caddyhttp/intercept/intercept.go +++ b/modules/caddyhttp/intercept/intercept.go @@ -17,6 +17,7 @@ package intercept import ( "bytes" "fmt" + "io" "net/http" "strconv" "strings" @@ -175,10 +176,35 @@ func (ir Intercept) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddy c.Write(zap.Int("handler", rec.handlerIndex)) } - // pass the request through the response handler routes - return rec.handler.Routes.Compile(next).ServeHTTP(w, r) + // response recorder doesn't create a new copy of the original headers, they're + // present in the original response writer + // create a new recorder to see if any response body from the new handler is present, + // if not, use the already buffered response body + recorder := caddyhttp.NewResponseRecorder(w, nil, nil) + if err := rec.handler.Routes.Compile(emptyHandler).ServeHTTP(recorder, r); err != nil { + return err + } + + // no new response status and the status is not 0 + if recorder.Status() == 0 && rec.Status() != 0 { + w.WriteHeader(rec.Status()) + } + + // no new response body and there is some in the original response + // TODO: what if the new response doesn't have a body by design? + // see: https://github.com/caddyserver/caddy/pull/6232#issue-2235224400 + if recorder.Size() == 0 && buf.Len() > 0 { + _, err := io.Copy(w, buf) + return err + } + return nil } +// this handler does nothing because everything we need is already buffered +var emptyHandler caddyhttp.Handler = caddyhttp.HandlerFunc(func(_ http.ResponseWriter, req *http.Request) error { + return nil +}) + // UnmarshalCaddyfile sets up the handler from Caddyfile tokens. Syntax: // // intercept [] { diff --git a/modules/caddyhttp/logging.go b/modules/caddyhttp/logging.go index e8a1316bd..b937a6f1e 100644 --- a/modules/caddyhttp/logging.go +++ b/modules/caddyhttp/logging.go @@ -15,18 +15,28 @@ package caddyhttp import ( + "context" "encoding/json" "errors" + "log/slog" "net" "net/http" "strings" + "sync" "go.uber.org/zap" + "go.uber.org/zap/exp/zapslog" "go.uber.org/zap/zapcore" "github.com/caddyserver/caddy/v2" ) +func init() { + caddy.RegisterSlogHandlerFactory(func(handler slog.Handler, core zapcore.Core, moduleID string) slog.Handler { + return &extraFieldsSlogHandler{defaultHandler: handler, core: core, moduleID: moduleID} + }) +} + // ServerLogConfig describes a server's logging configuration. If // enabled without customization, all requests to this server are // logged to the default logger; logger destinations may be @@ -223,17 +233,21 @@ func errLogValues(err error) (status int, msg string, fields func() []zapcore.Fi // ExtraLogFields is a list of extra fields to log with every request. type ExtraLogFields struct { - fields []zapcore.Field + fields []zapcore.Field + handlers sync.Map } // Add adds a field to the list of extra fields to log. func (e *ExtraLogFields) Add(field zap.Field) { + e.handlers.Clear() e.fields = append(e.fields, field) } // Set sets a field in the list of extra fields to log. // If the field already exists, it is replaced. func (e *ExtraLogFields) Set(field zap.Field) { + e.handlers.Clear() + for i := range e.fields { if e.fields[i].Key == field.Key { e.fields[i] = field @@ -243,6 +257,29 @@ func (e *ExtraLogFields) Set(field zap.Field) { e.fields = append(e.fields, field) } +func (e *ExtraLogFields) getSloggerHandler(handler *extraFieldsSlogHandler) (h slog.Handler) { + if existing, ok := e.handlers.Load(handler); ok { + return existing.(slog.Handler) + } + + if handler.moduleID == "" { + h = zapslog.NewHandler(handler.core.With(e.fields)) + } else { + h = zapslog.NewHandler(handler.core.With(e.fields), zapslog.WithName(handler.moduleID)) + } + + if handler.group != "" { + h = h.WithGroup(handler.group) + } + if handler.attrs != nil { + h = h.WithAttrs(handler.attrs) + } + + e.handlers.Store(handler, h) + + return h +} + const ( // Variable name used to indicate that this request // should be omitted from the access logs @@ -254,3 +291,43 @@ const ( // Variable name used to indicate the logger to be used AccessLoggerNameVarKey string = "access_logger_names" ) + +type extraFieldsSlogHandler struct { + defaultHandler slog.Handler + core zapcore.Core + moduleID string + group string + attrs []slog.Attr +} + +func (e *extraFieldsSlogHandler) Enabled(ctx context.Context, level slog.Level) bool { + return e.defaultHandler.Enabled(ctx, level) +} + +func (e *extraFieldsSlogHandler) Handle(ctx context.Context, record slog.Record) error { + if elf, ok := ctx.Value(ExtraLogFieldsCtxKey).(*ExtraLogFields); ok { + return elf.getSloggerHandler(e).Handle(ctx, record) + } + + return e.defaultHandler.Handle(ctx, record) +} + +func (e *extraFieldsSlogHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &extraFieldsSlogHandler{ + e.defaultHandler.WithAttrs(attrs), + e.core, + e.moduleID, + e.group, + append(e.attrs, attrs...), + } +} + +func (e *extraFieldsSlogHandler) WithGroup(name string) slog.Handler { + return &extraFieldsSlogHandler{ + e.defaultHandler.WithGroup(name), + e.core, + e.moduleID, + name, + e.attrs, + } +} diff --git a/modules/caddyhttp/logging/caddyfile.go b/modules/caddyhttp/logging/caddyfile.go index 010b48919..38d79014b 100644 --- a/modules/caddyhttp/logging/caddyfile.go +++ b/modules/caddyhttp/logging/caddyfile.go @@ -15,6 +15,8 @@ package logging import ( + "strings" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" "github.com/caddyserver/caddy/v2/modules/caddyhttp" @@ -26,7 +28,7 @@ func init() { // parseCaddyfile sets up the log_append handler from Caddyfile tokens. Syntax: // -// log_append [] +// log_append [] [<] func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { handler := new(LogAppend) err := handler.UnmarshalCaddyfile(h.Dispenser) @@ -43,6 +45,10 @@ func (h *LogAppend) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { if !d.NextArg() { return d.ArgErr() } + if strings.HasPrefix(h.Key, "<") && len(h.Key) > 1 { + h.Early = true + h.Key = h.Key[1:] + } h.Value = d.Val() return nil } diff --git a/modules/caddyhttp/logging/logadd.go b/modules/caddyhttp/logging/logappend.go similarity index 51% rename from modules/caddyhttp/logging/logadd.go rename to modules/caddyhttp/logging/logappend.go index 3b554367f..56758d68c 100644 --- a/modules/caddyhttp/logging/logadd.go +++ b/modules/caddyhttp/logging/logappend.go @@ -15,6 +15,8 @@ package logging import ( + "bytes" + "encoding/base64" "net/http" "strings" @@ -42,6 +44,12 @@ type LogAppend struct { // map, the value of that key will be used. Otherwise // the value will be used as-is as a constant string. Value string `json:"value,omitempty"` + + // Early, if true, adds the log field before calling + // the next handler in the chain. By default, the log + // field is added on the way back up the middleware chain, + // after all subsequent handlers have completed. + Early bool `json:"early,omitempty"` } // CaddyModule returns the Caddy module information. @@ -53,13 +61,63 @@ func (LogAppend) CaddyModule() caddy.ModuleInfo { } func (h LogAppend) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { - // Run the next handler in the chain first. + // Determine if we need to add the log field early. + // We do if the Early flag is set, or for convenience, + // if the value is a special placeholder for the request body. + needsEarly := h.Early || h.Value == placeholderRequestBody || h.Value == placeholderRequestBodyBase64 + + // Check if we need to buffer the response for special placeholders + needsResponseBody := h.Value == placeholderResponseBody || h.Value == placeholderResponseBodyBase64 + + if needsEarly && !needsResponseBody { + // Add the log field before calling the next handler + // (but not if we need the response body, which isn't available yet) + h.addLogField(r, nil) + } + + var rec caddyhttp.ResponseRecorder + var buf *bytes.Buffer + + if needsResponseBody { + // Wrap the response writer with a recorder to capture the response body + buf = new(bytes.Buffer) + rec = caddyhttp.NewResponseRecorder(w, buf, func(status int, header http.Header) bool { + // Always buffer the response when we need to log the body + return true + }) + w = rec + } + + // Run the next handler in the chain. // If an error occurs, we still want to add // any extra log fields that we can, so we // hold onto the error and return it later. handlerErr := next.ServeHTTP(w, r) - // On the way back up the chain, add the extra log field + if needsResponseBody { + // Write the buffered response to the client + if rec.Buffered() { + h.addLogField(r, buf) + err := rec.WriteResponse() + if err != nil { + return err + } + } + return handlerErr + } + + if !h.Early { + // Add the log field after the handler completes + h.addLogField(r, buf) + } + + return handlerErr +} + +// addLogField adds the log field to the request's extra log fields. +// If buf is not nil, it contains the buffered response body for special +// response body placeholders. +func (h LogAppend) addLogField(r *http.Request, buf *bytes.Buffer) { ctx := r.Context() vars := ctx.Value(caddyhttp.VarsCtxKey).(map[string]any) @@ -67,7 +125,21 @@ func (h LogAppend) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyh extra := ctx.Value(caddyhttp.ExtraLogFieldsCtxKey).(*caddyhttp.ExtraLogFields) var varValue any - if strings.HasPrefix(h.Value, "{") && + + // Handle special case placeholders for response body + if h.Value == placeholderResponseBody { + if buf != nil { + varValue = buf.String() + } else { + varValue = "" + } + } else if h.Value == placeholderResponseBodyBase64 { + if buf != nil { + varValue = base64.StdEncoding.EncodeToString(buf.Bytes()) + } else { + varValue = "" + } + } else if strings.HasPrefix(h.Value, "{") && strings.HasSuffix(h.Value, "}") && strings.Count(h.Value, "{") == 1 { // the value looks like a placeholder, so get its value @@ -84,10 +156,17 @@ func (h LogAppend) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyh // We use zap.Any because it will reflect // to the correct type for us. extra.Add(zap.Any(h.Key, varValue)) - - return handlerErr } +const ( + // Special placeholder values that are handled by log_append + // rather than by the replacer. + placeholderRequestBody = "{http.request.body}" + placeholderRequestBodyBase64 = "{http.request.body_base64}" + placeholderResponseBody = "{http.response.body}" + placeholderResponseBodyBase64 = "{http.response.body_base64}" +) + // Interface guards var ( _ caddyhttp.MiddlewareHandler = (*LogAppend)(nil) diff --git a/modules/caddyhttp/marshalers.go b/modules/caddyhttp/marshalers.go index 9bce377f4..2a40b6cd7 100644 --- a/modules/caddyhttp/marshalers.go +++ b/modules/caddyhttp/marshalers.go @@ -110,6 +110,7 @@ func (t LoggableTLSConnState) MarshalLogObject(enc zapcore.ObjectEncoder) error enc.AddUint16("cipher_suite", t.CipherSuite) enc.AddString("proto", t.NegotiatedProtocol) enc.AddString("server_name", t.ServerName) + enc.AddBool("ech", t.ECHAccepted) if len(t.PeerCertificates) > 0 { enc.AddString("client_common_name", t.PeerCertificates[0].Subject.CommonName) enc.AddString("client_serial", t.PeerCertificates[0].SerialNumber.String()) diff --git a/modules/caddyhttp/matchers.go b/modules/caddyhttp/matchers.go index 22976cfbd..27e5c5ae6 100644 --- a/modules/caddyhttp/matchers.go +++ b/modules/caddyhttp/matchers.go @@ -262,13 +262,17 @@ func (m MatchHost) Provision(_ caddy.Context) error { if err != nil { return fmt.Errorf("converting hostname '%s' to ASCII: %v", host, err) } - if asciiHost != host { - m[i] = asciiHost - } normalizedHost := strings.ToLower(asciiHost) if firstI, ok := seen[normalizedHost]; ok { return fmt.Errorf("host at index %d is repeated at index %d: %s", firstI, i, host) } + // Normalize exact hosts for standardized comparison in large-list fastpath later on. + // Keep wildcards/placeholders untouched. + if m.fuzzy(asciiHost) { + m[i] = asciiHost + } else { + m[i] = normalizedHost + } seen[normalizedHost] = i } @@ -312,14 +316,15 @@ func (m MatchHost) MatchWithError(r *http.Request) (bool, error) { } if m.large() { + reqHostLower := strings.ToLower(reqHost) // fast path: locate exact match using binary search (about 100-1000x faster for large lists) pos := sort.Search(len(m), func(i int) bool { if m.fuzzy(m[i]) { return false } - return m[i] >= reqHost + return m[i] >= reqHostLower }) - if pos < len(m) && m[pos] == reqHost { + if pos < len(m) && m[pos] == reqHostLower { return true, nil } } @@ -533,6 +538,7 @@ func (m MatchPath) MatchWithError(r *http.Request) (bool, error) { } func (MatchPath) matchPatternWithEscapeSequence(escapedPath, matchPath string) bool { + escapedPath = strings.ToLower(escapedPath) // We would just compare the pattern against r.URL.Path, // but the pattern contains %, indicating that we should // compare at least some part of the path in raw/escaped @@ -632,8 +638,8 @@ func (MatchPath) matchPatternWithEscapeSequence(escapedPath, matchPath string) b // we can now treat rawpath globs (%*) as regular globs (*) matchPath = strings.ReplaceAll(matchPath, "%*", "*") - // ignore error here because we can't handle it anyway= - matches, _ := path.Match(matchPath, sb.String()) + // ignore error here because we can't handle it anyway + matches, _ := path.Match(matchPath, strings.ToLower(sb.String())) return matches } diff --git a/modules/caddyhttp/matchers_test.go b/modules/caddyhttp/matchers_test.go index b15b6316d..c3d8c405e 100644 --- a/modules/caddyhttp/matchers_test.go +++ b/modules/caddyhttp/matchers_test.go @@ -412,6 +412,16 @@ func TestPathMatcher(t *testing.T) { input: "/foo%2fbar/baz", expect: true, }, + { + match: MatchPath{"/admin%2fpanel"}, + input: "/ADMIN%2fpanel", + expect: true, + }, + { + match: MatchPath{"/admin%2fpa*el"}, + input: "/ADMIN%2fPaAzZLm123NEL", + expect: true, + }, } { err := tc.match.Provision(caddy.Context{}) if err == nil && tc.provisionErr { @@ -957,6 +967,7 @@ func TestVarREMatcher(t *testing.T) { desc string match MatchVarsRE input VarsMiddleware + headers http.Header expect bool expectRepl map[string]string }{ @@ -991,6 +1002,14 @@ func TestVarREMatcher(t *testing.T) { input: VarsMiddleware{"Var1": "var1Value"}, expect: true, }, + { + desc: "placeholder key value containing braces is not double-expanded", + match: MatchVarsRE{"{http.request.header.X-Input}": &MatchRegexp{Pattern: ".+", Name: "val"}}, + input: VarsMiddleware{}, + headers: http.Header{"X-Input": []string{"{env.HOME}"}}, + expect: true, + expectRepl: map[string]string{"val.0": "{env.HOME}"}, + }, } { t.Run(tc.desc, func(t *testing.T) { t.Parallel() @@ -1007,7 +1026,7 @@ func TestVarREMatcher(t *testing.T) { } // set up the fake request and its Replacer - req := &http.Request{URL: new(url.URL), Method: http.MethodGet} + req := &http.Request{URL: new(url.URL), Method: http.MethodGet, Header: tc.headers} repl := caddy.NewReplacer() ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl) ctx = context.WithValue(ctx, VarsCtxKey, make(map[string]any)) diff --git a/modules/caddyhttp/metrics.go b/modules/caddyhttp/metrics.go index 9bb97e0b4..b212bbfb8 100644 --- a/modules/caddyhttp/metrics.go +++ b/modules/caddyhttp/metrics.go @@ -17,14 +17,60 @@ import ( // Metrics configures metrics observations. // EXPERIMENTAL and subject to change or removal. +// +// Example configuration: +// +// { +// "apps": { +// "http": { +// "metrics": { +// "per_host": true, +// "observe_catchall_hosts": false +// }, +// "servers": { +// "srv0": { +// "routes": [{ +// "match": [{"host": ["example.com", "www.example.com"]}], +// "handle": [{"handler": "static_response", "body": "Hello"}] +// }] +// } +// } +// } +// } +// } +// +// In this configuration: +// - Requests to example.com and www.example.com get individual host labels +// - All other hosts (e.g., attacker.com) are aggregated under "_other" label +// - This prevents unlimited cardinality from arbitrary Host headers type Metrics struct { // Enable per-host metrics. Enabling this option may // incur high-memory consumption, depending on the number of hosts // managed by Caddy. + // + // CARDINALITY PROTECTION: To prevent unbounded cardinality attacks, + // only explicitly configured hosts (via host matchers) are allowed + // by default. Other hosts are aggregated under the "_other" label. + // See AllowCatchAllHosts to change this behavior. PerHost bool `json:"per_host,omitempty"` - init sync.Once - httpMetrics *httpMetrics `json:"-"` + // Allow metrics for catch-all hosts (hosts without explicit configuration). + // When false (default), only hosts explicitly configured via host matchers + // will get individual metrics labels. All other hosts will be aggregated + // under the "_other" label to prevent cardinality explosion. + // + // This is automatically enabled for HTTPS servers (since certificates provide + // some protection against unbounded cardinality), but disabled for HTTP servers + // by default to prevent cardinality attacks from arbitrary Host headers. + // + // Set to true to allow all hosts to get individual metrics (NOT RECOMMENDED + // for production environments exposed to the internet). + ObserveCatchallHosts bool `json:"observe_catchall_hosts,omitempty"` + + init sync.Once + httpMetrics *httpMetrics + allowedHosts map[string]struct{} + hasHTTPSServer bool } type httpMetrics struct { @@ -101,6 +147,63 @@ func initHTTPMetrics(ctx caddy.Context, metrics *Metrics) { }, httpLabels) } +// scanConfigForHosts scans the HTTP app configuration to build a set of allowed hosts +// for metrics collection, similar to how auto-HTTPS scans for domain names. +func (m *Metrics) scanConfigForHosts(app *App) { + if !m.PerHost { + return + } + + m.allowedHosts = make(map[string]struct{}) + m.hasHTTPSServer = false + + for _, srv := range app.Servers { + // Check if this server has TLS enabled + serverHasTLS := len(srv.TLSConnPolicies) > 0 + if serverHasTLS { + m.hasHTTPSServer = true + } + + // Collect hosts from route matchers + for _, route := range srv.Routes { + for _, matcherSet := range route.MatcherSets { + for _, matcher := range matcherSet { + if hm, ok := matcher.(*MatchHost); ok { + for _, host := range *hm { + // Only allow non-fuzzy hosts to prevent unbounded cardinality + if !hm.fuzzy(host) { + m.allowedHosts[strings.ToLower(host)] = struct{}{} + } + } + } + } + } + } + } +} + +// shouldAllowHostMetrics determines if metrics should be collected for the given host. +// This implements the cardinality protection by only allowing metrics for: +// 1. Explicitly configured hosts +// 2. Catch-all requests on HTTPS servers (if AllowCatchAllHosts is true or auto-enabled) +// 3. Catch-all requests on HTTP servers only if explicitly allowed +func (m *Metrics) shouldAllowHostMetrics(host string, isHTTPS bool) bool { + if !m.PerHost { + return true // host won't be used in labels anyway + } + + normalizedHost := strings.ToLower(host) + + // Always allow explicitly configured hosts + if _, exists := m.allowedHosts[normalizedHost]; exists { + return true + } + + // For catch-all requests (not in allowed hosts) + allowCatchAll := m.ObserveCatchallHosts || (isHTTPS && m.hasHTTPSServer) + return allowCatchAll +} + // serverNameFromContext extracts the current server name from the context. // Returns "UNKNOWN" if none is available (should probably never happen). func serverNameFromContext(ctx context.Context) string { @@ -111,21 +214,24 @@ func serverNameFromContext(ctx context.Context) string { return srv.name } -type metricsInstrumentedHandler struct { +// metricsInstrumentedRoute wraps a compiled route Handler with metrics +// instrumentation. It wraps the entire compiled route chain once, +// collecting metrics only once per route match. +type metricsInstrumentedRoute struct { handler string - mh MiddlewareHandler + next Handler metrics *Metrics } -func newMetricsInstrumentedHandler(ctx caddy.Context, handler string, mh MiddlewareHandler, metrics *Metrics) *metricsInstrumentedHandler { - metrics.init.Do(func() { - initHTTPMetrics(ctx, metrics) +func newMetricsInstrumentedRoute(ctx caddy.Context, handler string, next Handler, m *Metrics) *metricsInstrumentedRoute { + m.init.Do(func() { + initHTTPMetrics(ctx, m) }) - return &metricsInstrumentedHandler{handler, mh, metrics} + return &metricsInstrumentedRoute{handler: handler, next: next, metrics: m} } -func (h *metricsInstrumentedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request, next Handler) error { +func (h *metricsInstrumentedRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) error { server := serverNameFromContext(r.Context()) labels := prometheus.Labels{"server": server, "handler": h.handler} method := metrics.SanitizeMethod(r.Method) @@ -133,9 +239,19 @@ func (h *metricsInstrumentedHandler) ServeHTTP(w http.ResponseWriter, r *http.Re // of a panic statusLabels := prometheus.Labels{"server": server, "handler": h.handler, "method": method, "code": ""} + // Determine if this is an HTTPS request + isHTTPS := r.TLS != nil + if h.metrics.PerHost { - labels["host"] = strings.ToLower(r.Host) - statusLabels["host"] = strings.ToLower(r.Host) + // Apply cardinality protection for host metrics + if h.metrics.shouldAllowHostMetrics(r.Host, isHTTPS) { + labels["host"] = strings.ToLower(r.Host) + statusLabels["host"] = strings.ToLower(r.Host) + } else { + // Use a catch-all label for unallowed hosts to prevent cardinality explosion + labels["host"] = "_other" + statusLabels["host"] = "_other" + } } inFlight := h.metrics.httpMetrics.requestInFlight.With(labels) @@ -154,7 +270,7 @@ func (h *metricsInstrumentedHandler) ServeHTTP(w http.ResponseWriter, r *http.Re return false }) wrec := NewResponseRecorder(w, nil, writeHeaderRecorder) - err := h.mh.ServeHTTP(wrec, r, next) + err := h.next.ServeHTTP(wrec, r) dur := time.Since(start).Seconds() h.metrics.httpMetrics.requestCount.With(labels).Inc() diff --git a/modules/caddyhttp/metrics_test.go b/modules/caddyhttp/metrics_test.go index 4e1aa8b30..987b3f342 100644 --- a/modules/caddyhttp/metrics_test.go +++ b/modules/caddyhttp/metrics_test.go @@ -2,6 +2,7 @@ package caddyhttp import ( "context" + "crypto/tls" "errors" "net/http" "net/http/httptest" @@ -46,16 +47,12 @@ func TestMetricsInstrumentedHandler(t *testing.T) { return handlerErr }) - mh := middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error { - return h.ServeHTTP(w, r) - }) - - ih := newMetricsInstrumentedHandler(ctx, "bar", mh, metrics) + ih := newMetricsInstrumentedRoute(ctx, "bar", h, metrics) r := httptest.NewRequest("GET", "/", nil) w := httptest.NewRecorder() - if actual := ih.ServeHTTP(w, r, h); actual != handlerErr { + if actual := ih.ServeHTTP(w, r); actual != handlerErr { t.Errorf("Not same: expected %#v, but got %#v", handlerErr, actual) } if actual := testutil.ToFloat64(metrics.httpMetrics.requestInFlight); actual != 0.0 { @@ -63,19 +60,19 @@ func TestMetricsInstrumentedHandler(t *testing.T) { } handlerErr = nil - if err := ih.ServeHTTP(w, r, h); err != nil { + if err := ih.ServeHTTP(w, r); err != nil { t.Errorf("Received unexpected error: %v", err) } // an empty handler - no errors, no header written - mh = middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error { + emptyHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }) - ih = newMetricsInstrumentedHandler(ctx, "empty", mh, metrics) + ih = newMetricsInstrumentedRoute(ctx, "empty", emptyHandler, metrics) r = httptest.NewRequest("GET", "/", nil) w = httptest.NewRecorder() - if err := ih.ServeHTTP(w, r, h); err != nil { + if err := ih.ServeHTTP(w, r); err != nil { t.Errorf("Received unexpected error: %v", err) } if actual := w.Result().StatusCode; actual != 200 { @@ -86,16 +83,16 @@ func TestMetricsInstrumentedHandler(t *testing.T) { } // handler returning an error with an HTTP status - mh = middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error { + errHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return Error(http.StatusTooManyRequests, nil) }) - ih = newMetricsInstrumentedHandler(ctx, "foo", mh, metrics) + ih = newMetricsInstrumentedRoute(ctx, "foo", errHandler, metrics) r = httptest.NewRequest("GET", "/", nil) w = httptest.NewRecorder() - if err := ih.ServeHTTP(w, r, nil); err == nil { + if err := ih.ServeHTTP(w, r); err == nil { t.Errorf("expected error to be propagated") } @@ -206,9 +203,11 @@ func TestMetricsInstrumentedHandler(t *testing.T) { func TestMetricsInstrumentedHandlerPerHost(t *testing.T) { ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()}) metrics := &Metrics{ - PerHost: true, - init: sync.Once{}, - httpMetrics: &httpMetrics{}, + PerHost: true, + ObserveCatchallHosts: true, // Allow all hosts for testing + init: sync.Once{}, + httpMetrics: &httpMetrics{}, + allowedHosts: make(map[string]struct{}), } handlerErr := errors.New("oh noes") response := []byte("hello world!") @@ -222,16 +221,12 @@ func TestMetricsInstrumentedHandlerPerHost(t *testing.T) { return handlerErr }) - mh := middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error { - return h.ServeHTTP(w, r) - }) - - ih := newMetricsInstrumentedHandler(ctx, "bar", mh, metrics) + ih := newMetricsInstrumentedRoute(ctx, "bar", h, metrics) r := httptest.NewRequest("GET", "/", nil) w := httptest.NewRecorder() - if actual := ih.ServeHTTP(w, r, h); actual != handlerErr { + if actual := ih.ServeHTTP(w, r); actual != handlerErr { t.Errorf("Not same: expected %#v, but got %#v", handlerErr, actual) } if actual := testutil.ToFloat64(metrics.httpMetrics.requestInFlight); actual != 0.0 { @@ -239,19 +234,19 @@ func TestMetricsInstrumentedHandlerPerHost(t *testing.T) { } handlerErr = nil - if err := ih.ServeHTTP(w, r, h); err != nil { + if err := ih.ServeHTTP(w, r); err != nil { t.Errorf("Received unexpected error: %v", err) } // an empty handler - no errors, no header written - mh = middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error { + emptyHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return nil }) - ih = newMetricsInstrumentedHandler(ctx, "empty", mh, metrics) + ih = newMetricsInstrumentedRoute(ctx, "empty", emptyHandler, metrics) r = httptest.NewRequest("GET", "/", nil) w = httptest.NewRecorder() - if err := ih.ServeHTTP(w, r, h); err != nil { + if err := ih.ServeHTTP(w, r); err != nil { t.Errorf("Received unexpected error: %v", err) } if actual := w.Result().StatusCode; actual != 200 { @@ -262,16 +257,16 @@ func TestMetricsInstrumentedHandlerPerHost(t *testing.T) { } // handler returning an error with an HTTP status - mh = middlewareHandlerFunc(func(w http.ResponseWriter, r *http.Request, h Handler) error { + errHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { return Error(http.StatusTooManyRequests, nil) }) - ih = newMetricsInstrumentedHandler(ctx, "foo", mh, metrics) + ih = newMetricsInstrumentedRoute(ctx, "foo", errHandler, metrics) r = httptest.NewRequest("GET", "/", nil) w = httptest.NewRecorder() - if err := ih.ServeHTTP(w, r, nil); err == nil { + if err := ih.ServeHTTP(w, r); err == nil { t.Errorf("expected error to be propagated") } @@ -379,8 +374,208 @@ func TestMetricsInstrumentedHandlerPerHost(t *testing.T) { } } -type middlewareHandlerFunc func(http.ResponseWriter, *http.Request, Handler) error +func TestMetricsCardinalityProtection(t *testing.T) { + ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()}) -func (f middlewareHandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request, h Handler) error { - return f(w, r, h) + // Test 1: Without AllowCatchAllHosts, arbitrary hosts should be mapped to "_other" + metrics := &Metrics{ + PerHost: true, + ObserveCatchallHosts: false, // Default - should map unknown hosts to "_other" + init: sync.Once{}, + httpMetrics: &httpMetrics{}, + allowedHosts: make(map[string]struct{}), + } + + // Add one allowed host + metrics.allowedHosts["allowed.com"] = struct{}{} + + h := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + w.Write([]byte("hello")) + return nil + }) + + ih := newMetricsInstrumentedRoute(ctx, "test", h, metrics) + + // Test request to allowed host + r1 := httptest.NewRequest("GET", "http://allowed.com/", nil) + r1.Host = "allowed.com" + w1 := httptest.NewRecorder() + ih.ServeHTTP(w1, r1) + + // Test request to unknown host (should be mapped to "_other") + r2 := httptest.NewRequest("GET", "http://attacker.com/", nil) + r2.Host = "attacker.com" + w2 := httptest.NewRecorder() + ih.ServeHTTP(w2, r2) + + // Test request to another unknown host (should also be mapped to "_other") + r3 := httptest.NewRequest("GET", "http://evil.com/", nil) + r3.Host = "evil.com" + w3 := httptest.NewRecorder() + ih.ServeHTTP(w3, r3) + + // Check that metrics contain: + // - One entry for "allowed.com" + // - One entry for "_other" (aggregating attacker.com and evil.com) + expected := ` + # HELP caddy_http_requests_total Counter of HTTP(S) requests made. + # TYPE caddy_http_requests_total counter + caddy_http_requests_total{handler="test",host="_other",server="UNKNOWN"} 2 + caddy_http_requests_total{handler="test",host="allowed.com",server="UNKNOWN"} 1 + ` + + if err := testutil.GatherAndCompare(ctx.GetMetricsRegistry(), strings.NewReader(expected), + "caddy_http_requests_total", + ); err != nil { + t.Errorf("Cardinality protection test failed: %s", err) + } +} + +func TestMetricsHTTPSCatchAll(t *testing.T) { + ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()}) + + // Test that HTTPS requests allow catch-all even when AllowCatchAllHosts is false + metrics := &Metrics{ + PerHost: true, + ObserveCatchallHosts: false, + hasHTTPSServer: true, // Simulate having HTTPS servers + init: sync.Once{}, + httpMetrics: &httpMetrics{}, + allowedHosts: make(map[string]struct{}), // Empty - no explicitly allowed hosts + } + + h := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + w.Write([]byte("hello")) + return nil + }) + + ih := newMetricsInstrumentedRoute(ctx, "test", h, metrics) + + // Test HTTPS request (should be allowed even though not in allowedHosts) + r1 := httptest.NewRequest("GET", "https://unknown.com/", nil) + r1.Host = "unknown.com" + r1.TLS = &tls.ConnectionState{} // Mark as TLS/HTTPS + w1 := httptest.NewRecorder() + ih.ServeHTTP(w1, r1) + + // Test HTTP request (should be mapped to "_other") + r2 := httptest.NewRequest("GET", "http://unknown.com/", nil) + r2.Host = "unknown.com" + // No TLS field = HTTP request + w2 := httptest.NewRecorder() + ih.ServeHTTP(w2, r2) + + // Check that HTTPS request gets real host, HTTP gets "_other" + expected := ` + # HELP caddy_http_requests_total Counter of HTTP(S) requests made. + # TYPE caddy_http_requests_total counter + caddy_http_requests_total{handler="test",host="_other",server="UNKNOWN"} 1 + caddy_http_requests_total{handler="test",host="unknown.com",server="UNKNOWN"} 1 + ` + + if err := testutil.GatherAndCompare(ctx.GetMetricsRegistry(), strings.NewReader(expected), + "caddy_http_requests_total", + ); err != nil { + t.Errorf("HTTPS catch-all test failed: %s", err) + } +} + +func TestMetricsInstrumentedRoute(t *testing.T) { + ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()}) + m := &Metrics{ + init: sync.Once{}, + httpMetrics: &httpMetrics{}, + } + + handlerErr := errors.New("oh noes") + response := []byte("hello world!") + innerHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + if actual := testutil.ToFloat64(m.httpMetrics.requestInFlight); actual != 1.0 { + t.Errorf("Expected requestInFlight to be 1.0, got %v", actual) + } + if handlerErr == nil { + w.Write(response) + } + return handlerErr + }) + + ih := newMetricsInstrumentedRoute(ctx, "test_handler", innerHandler, m) + + r := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + // Test with error + if actual := ih.ServeHTTP(w, r); actual != handlerErr { + t.Errorf("Expected error %v, got %v", handlerErr, actual) + } + if actual := testutil.ToFloat64(m.httpMetrics.requestInFlight); actual != 0.0 { + t.Errorf("Expected requestInFlight to be 0.0 after request, got %v", actual) + } + if actual := testutil.ToFloat64(m.httpMetrics.requestErrors); actual != 1.0 { + t.Errorf("Expected requestErrors to be 1.0, got %v", actual) + } + + // Test without error + handlerErr = nil + w = httptest.NewRecorder() + if err := ih.ServeHTTP(w, r); err != nil { + t.Errorf("Unexpected error: %v", err) + } +} + +func BenchmarkMetricsInstrumentedRoute(b *testing.B) { + ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()}) + m := &Metrics{ + init: sync.Once{}, + httpMetrics: &httpMetrics{}, + } + + noopHandler := HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + w.Write([]byte("ok")) + return nil + }) + + ih := newMetricsInstrumentedRoute(ctx, "bench_handler", noopHandler, m) + + r := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + ih.ServeHTTP(w, r) + } +} + +// BenchmarkSingleRouteMetrics simulates the new behavior where metrics +// are collected once for the entire route. +func BenchmarkSingleRouteMetrics(b *testing.B) { + ctx, _ := caddy.NewContext(caddy.Context{Context: context.Background()}) + m := &Metrics{ + init: sync.Once{}, + httpMetrics: &httpMetrics{}, + } + + // Build a chain of 5 plain middleware handlers (no per-handler metrics) + var next Handler = HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return nil + }) + for i := 0; i < 5; i++ { + capturedNext := next + next = HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return capturedNext.ServeHTTP(w, r) + }) + } + + // Wrap the entire chain with a single route-level metrics handler + ih := newMetricsInstrumentedRoute(ctx, "handler", next, m) + + r := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + b.ResetTimer() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + ih.ServeHTTP(w, r) + } } diff --git a/modules/caddyhttp/push/caddyfile.go b/modules/caddyhttp/push/caddyfile.go index f56db81f9..e931849a4 100644 --- a/modules/caddyhttp/push/caddyfile.go +++ b/modules/caddyhttp/push/caddyfile.go @@ -64,6 +64,7 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) var err error // include current token, which we treat as an argument here + // nolint:prealloc args := []string{h.Val()} args = append(args, h.RemainingArgs()...) diff --git a/modules/caddyhttp/replacer.go b/modules/caddyhttp/replacer.go index 9c3ab85f2..e7974a561 100644 --- a/modules/caddyhttp/replacer.go +++ b/modules/caddyhttp/replacer.go @@ -229,6 +229,21 @@ func addHTTPVarsToReplacer(repl *caddy.Replacer, req *http.Request, w http.Respo req.Body = io.NopCloser(buf) // replace real body with buffered data return buf.String(), true + case "http.request.body_base64": + if req.Body == nil { + return "", true + } + // normally net/http will close the body for us, but since we + // are replacing it with a fake one, we have to ensure we close + // the real body ourselves when we're done + defer req.Body.Close() + // read the request body into a buffer (can't pool because we + // don't know its lifetime and would have to make a copy anyway) + buf := new(bytes.Buffer) + _, _ = io.Copy(buf, req.Body) // can't handle error, so just ignore it + req.Body = io.NopCloser(buf) // replace real body with buffered data + return base64.StdEncoding.EncodeToString(buf.Bytes()), true + // original request, before any internal changes case "http.request.orig_method": or, _ := req.Context().Value(OriginalRequestCtxKey).(http.Request) @@ -405,7 +420,16 @@ func getReqTLSReplacement(req *http.Request, key string) (any, bool) { if strings.HasPrefix(field, "client.") { cert := getTLSPeerCert(req.TLS) if cert == nil { - return nil, false + // Instead of returning (nil, false) here, we set it to a dummy + // value to fix #7530. This way, even if there is no client cert, + // evaluating placeholders with ReplaceKnown() will still remove + // the placeholder, which would be expected. It is not expected + // for the placeholder to sometimes get removed based on whether + // the client presented a cert. We also do not return true here + // because we probably should remain accurate about whether a + // placeholder is, in fact, known or not. + // (This allocation may be slightly inefficient.) + cert = new(x509.Certificate) } // subject alternate names (SANs) @@ -511,6 +535,8 @@ func getReqTLSReplacement(req *http.Request, key string) (any, bool) { return true, true case "server_name": return req.TLS.ServerName, true + case "ech": + return req.TLS.ECHAccepted, true } return nil, false } diff --git a/modules/caddyhttp/reverseproxy/admin.go b/modules/caddyhttp/reverseproxy/admin.go index 7e72a4cdb..97dd2827d 100644 --- a/modules/caddyhttp/reverseproxy/admin.go +++ b/modules/caddyhttp/reverseproxy/admin.go @@ -73,8 +73,9 @@ func (adminUpstreams) handleUpstreams(w http.ResponseWriter, r *http.Request) er // Collect the results to respond with results := []upstreamStatus{} + knownHosts := make(map[string]struct{}) - // Iterate over the upstream pool (needs to be fast) + // Iterate over the static upstream pool (needs to be fast) var rangeErr error hosts.Range(func(key, val any) bool { address, ok := key.(string) @@ -95,6 +96,8 @@ func (adminUpstreams) handleUpstreams(w http.ResponseWriter, r *http.Request) er return false } + knownHosts[address] = struct{}{} + results = append(results, upstreamStatus{ Address: address, NumRequests: upstream.NumRequests(), @@ -103,11 +106,32 @@ func (adminUpstreams) handleUpstreams(w http.ResponseWriter, r *http.Request) er return true }) - // If an error happened during the range, return it + currentInFlight := getInFlightRequests() + for address, count := range currentInFlight { + if _, exists := knownHosts[address]; !exists && count > 0 { + results = append(results, upstreamStatus{ + Address: address, + NumRequests: int(count), + Fails: 0, + }) + } + } + if rangeErr != nil { return rangeErr } + // Also include dynamic upstreams + dynamicHostsMu.RLock() + for address, entry := range dynamicHosts { + results = append(results, upstreamStatus{ + Address: address, + NumRequests: entry.host.NumRequests(), + Fails: entry.host.Fails(), + }) + } + dynamicHostsMu.RUnlock() + err := enc.Encode(results) if err != nil { return caddy.APIError{ diff --git a/modules/caddyhttp/reverseproxy/admin_test.go b/modules/caddyhttp/reverseproxy/admin_test.go new file mode 100644 index 000000000..de9ac967c --- /dev/null +++ b/modules/caddyhttp/reverseproxy/admin_test.go @@ -0,0 +1,275 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package reverseproxy + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// adminHandlerFixture sets up the global host state for an admin endpoint test +// and returns a cleanup function that must be deferred by the caller. +// +// staticAddrs are inserted into the UsagePool (as a static upstream would be). +// dynamicAddrs are inserted into the dynamicHosts map (as a dynamic upstream would be). +func adminHandlerFixture(t *testing.T, staticAddrs, dynamicAddrs []string) func() { + t.Helper() + + for _, addr := range staticAddrs { + u := &Upstream{Dial: addr} + u.fillHost() + } + + dynamicHostsMu.Lock() + for _, addr := range dynamicAddrs { + dynamicHosts[addr] = dynamicHostEntry{host: new(Host), lastSeen: time.Now()} + } + dynamicHostsMu.Unlock() + + return func() { + // Remove static entries from the UsagePool. + for _, addr := range staticAddrs { + _, _ = hosts.Delete(addr) + } + // Remove dynamic entries. + dynamicHostsMu.Lock() + for _, addr := range dynamicAddrs { + delete(dynamicHosts, addr) + } + dynamicHostsMu.Unlock() + } +} + +// callAdminUpstreams fires a GET against handleUpstreams and returns the +// decoded response body. +func callAdminUpstreams(t *testing.T) []upstreamStatus { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/reverse_proxy/upstreams", nil) + w := httptest.NewRecorder() + + handler := adminUpstreams{} + if err := handler.handleUpstreams(w, req); err != nil { + t.Fatalf("handleUpstreams returned unexpected error: %v", err) + } + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("expected Content-Type application/json, got %q", ct) + } + + var results []upstreamStatus + if err := json.NewDecoder(w.Body).Decode(&results); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + return results +} + +// resultsByAddress indexes a slice of upstreamStatus by address for easier +// lookup in assertions. +func resultsByAddress(statuses []upstreamStatus) map[string]upstreamStatus { + m := make(map[string]upstreamStatus, len(statuses)) + for _, s := range statuses { + m[s.Address] = s + } + return m +} + +// TestAdminUpstreamsMethodNotAllowed verifies that non-GET methods are rejected. +func TestAdminUpstreamsMethodNotAllowed(t *testing.T) { + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} { + req := httptest.NewRequest(method, "/reverse_proxy/upstreams", nil) + w := httptest.NewRecorder() + err := (adminUpstreams{}).handleUpstreams(w, req) + if err == nil { + t.Errorf("method %s: expected an error, got nil", method) + continue + } + apiErr, ok := err.(interface{ HTTPStatus() int }) + if !ok { + // caddy.APIError stores the code in HTTPStatus field, access via the + // exported interface it satisfies indirectly; just check non-nil. + continue + } + if code := apiErr.HTTPStatus(); code != http.StatusMethodNotAllowed { + t.Errorf("method %s: expected 405, got %d", method, code) + } + } +} + +// TestAdminUpstreamsEmpty verifies that an empty response is valid JSON when +// no upstreams are registered. +func TestAdminUpstreamsEmpty(t *testing.T) { + resetDynamicHosts() + + results := callAdminUpstreams(t) + if results == nil { + t.Error("expected non-nil (empty) slice, got nil") + } + if len(results) != 0 { + t.Errorf("expected 0 results with empty pools, got %d", len(results)) + } +} + +// TestAdminUpstreamsStaticOnly verifies that static upstreams (from the +// UsagePool) appear in the response with correct addresses. +func TestAdminUpstreamsStaticOnly(t *testing.T) { + resetDynamicHosts() + cleanup := adminHandlerFixture(t, + []string{"10.0.0.1:80", "10.0.0.2:80"}, + nil, + ) + defer cleanup() + + results := callAdminUpstreams(t) + byAddr := resultsByAddress(results) + + for _, addr := range []string{"10.0.0.1:80", "10.0.0.2:80"} { + if _, ok := byAddr[addr]; !ok { + t.Errorf("expected static upstream %q in response", addr) + } + } + if len(results) != 2 { + t.Errorf("expected exactly 2 results, got %d", len(results)) + } +} + +// TestAdminUpstreamsDynamicOnly verifies that dynamic upstreams (from +// dynamicHosts) appear in the response with correct addresses. +func TestAdminUpstreamsDynamicOnly(t *testing.T) { + resetDynamicHosts() + cleanup := adminHandlerFixture(t, + nil, + []string{"10.0.1.1:80", "10.0.1.2:80"}, + ) + defer cleanup() + + results := callAdminUpstreams(t) + byAddr := resultsByAddress(results) + + for _, addr := range []string{"10.0.1.1:80", "10.0.1.2:80"} { + if _, ok := byAddr[addr]; !ok { + t.Errorf("expected dynamic upstream %q in response", addr) + } + } + if len(results) != 2 { + t.Errorf("expected exactly 2 results, got %d", len(results)) + } +} + +// TestAdminUpstreamsBothPools verifies that static and dynamic upstreams are +// both present in the same response and that there is no overlap or omission. +func TestAdminUpstreamsBothPools(t *testing.T) { + resetDynamicHosts() + cleanup := adminHandlerFixture(t, + []string{"10.0.2.1:80"}, + []string{"10.0.2.2:80"}, + ) + defer cleanup() + + results := callAdminUpstreams(t) + if len(results) != 2 { + t.Fatalf("expected 2 results (1 static + 1 dynamic), got %d", len(results)) + } + + byAddr := resultsByAddress(results) + if _, ok := byAddr["10.0.2.1:80"]; !ok { + t.Error("static upstream missing from response") + } + if _, ok := byAddr["10.0.2.2:80"]; !ok { + t.Error("dynamic upstream missing from response") + } +} + +// TestAdminUpstreamsNoOverlapBetweenPools verifies that an address registered +// only as a static upstream does not also appear as a dynamic entry, and +// vice-versa. +func TestAdminUpstreamsNoOverlapBetweenPools(t *testing.T) { + resetDynamicHosts() + cleanup := adminHandlerFixture(t, + []string{"10.0.3.1:80"}, + []string{"10.0.3.2:80"}, + ) + defer cleanup() + + results := callAdminUpstreams(t) + seen := make(map[string]int) + for _, r := range results { + seen[r.Address]++ + } + for addr, count := range seen { + if count > 1 { + t.Errorf("address %q appeared %d times; expected exactly once", addr, count) + } + } +} + +// TestAdminUpstreamsReportsFailCounts verifies that fail counts accumulated on +// a dynamic upstream's Host are reflected in the response. +func TestAdminUpstreamsReportsFailCounts(t *testing.T) { + resetDynamicHosts() + + const addr = "10.0.4.1:80" + h := new(Host) + _ = h.countFail(3) + + dynamicHostsMu.Lock() + dynamicHosts[addr] = dynamicHostEntry{host: h, lastSeen: time.Now()} + dynamicHostsMu.Unlock() + defer func() { + dynamicHostsMu.Lock() + delete(dynamicHosts, addr) + dynamicHostsMu.Unlock() + }() + + results := callAdminUpstreams(t) + byAddr := resultsByAddress(results) + + status, ok := byAddr[addr] + if !ok { + t.Fatalf("expected %q in response", addr) + } + if status.Fails != 3 { + t.Errorf("expected Fails=3, got %d", status.Fails) + } +} + +// TestAdminUpstreamsReportsNumRequests verifies that the active request count +// for a static upstream is reflected in the response. +func TestAdminUpstreamsReportsNumRequests(t *testing.T) { + resetDynamicHosts() + + const addr = "10.0.4.2:80" + u := &Upstream{Dial: addr} + u.fillHost() + defer func() { _, _ = hosts.Delete(addr) }() + + _ = u.Host.countRequest(2) + defer func() { _ = u.Host.countRequest(-2) }() + + results := callAdminUpstreams(t) + byAddr := resultsByAddress(results) + + status, ok := byAddr[addr] + if !ok { + t.Fatalf("expected %q in response", addr) + } + if status.NumRequests != 2 { + t.Errorf("expected NumRequests=2, got %d", status.NumRequests) + } +} diff --git a/modules/caddyhttp/reverseproxy/caddyfile.go b/modules/caddyhttp/reverseproxy/caddyfile.go index 8439d1d51..7b0b052da 100644 --- a/modules/caddyhttp/reverseproxy/caddyfile.go +++ b/modules/caddyhttp/reverseproxy/caddyfile.go @@ -888,8 +888,11 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { if commonScheme == "http" && te.TLSEnabled() { return d.Errf("upstream address scheme is HTTP but transport is configured for HTTP+TLS (HTTPS)") } - if te, ok := transport.(*HTTPTransport); ok && commonScheme == "h2c" { - te.Versions = []string{"h2c", "2"} + if h2ct, ok := transport.(H2CTransport); ok && commonScheme == "h2c" { + err := h2ct.EnableH2C() + if err != nil { + return err + } } } else if commonScheme == "https" { return d.Errf("upstreams are configured for HTTPS but transport module does not support TLS: %T", transport) @@ -1525,6 +1528,7 @@ func (u *SRVUpstreams) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return d.Errf("bad delay value '%s': %v", d.Val(), err) } u.FallbackDelay = caddy.Duration(dur) + case "grace_period": if !d.NextArg() { return d.ArgErr() diff --git a/modules/caddyhttp/reverseproxy/dynamic_upstreams_test.go b/modules/caddyhttp/reverseproxy/dynamic_upstreams_test.go new file mode 100644 index 000000000..577eccdb6 --- /dev/null +++ b/modules/caddyhttp/reverseproxy/dynamic_upstreams_test.go @@ -0,0 +1,345 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package reverseproxy + +import ( + "sync" + "testing" + "time" + + "github.com/caddyserver/caddy/v2" +) + +// resetDynamicHosts clears global dynamic host state between tests. +func resetDynamicHosts() { + dynamicHostsMu.Lock() + dynamicHosts = make(map[string]dynamicHostEntry) + dynamicHostsMu.Unlock() + // Reset the Once so cleanup goroutine tests can re-trigger if needed. + dynamicHostsCleanerOnce = sync.Once{} +} + +// TestFillDynamicHostCreatesEntry verifies that calling fillDynamicHost on a +// new address inserts an entry into dynamicHosts and assigns a non-nil Host. +func TestFillDynamicHostCreatesEntry(t *testing.T) { + resetDynamicHosts() + + u := &Upstream{Dial: "192.0.2.1:80"} + u.fillDynamicHost() + + if u.Host == nil { + t.Fatal("expected Host to be set after fillDynamicHost") + } + + dynamicHostsMu.RLock() + entry, ok := dynamicHosts["192.0.2.1:80"] + dynamicHostsMu.RUnlock() + + if !ok { + t.Fatal("expected entry in dynamicHosts map") + } + if entry.host != u.Host { + t.Error("dynamicHosts entry host should be the same pointer assigned to Upstream.Host") + } + if entry.lastSeen.IsZero() { + t.Error("expected lastSeen to be set") + } +} + +// TestFillDynamicHostReusesSameHost verifies that two calls for the same address +// return the exact same *Host pointer so that state (e.g. fail counts) is shared. +func TestFillDynamicHostReusesSameHost(t *testing.T) { + resetDynamicHosts() + + u1 := &Upstream{Dial: "192.0.2.2:80"} + u1.fillDynamicHost() + + u2 := &Upstream{Dial: "192.0.2.2:80"} + u2.fillDynamicHost() + + if u1.Host != u2.Host { + t.Error("expected both upstreams to share the same *Host pointer") + } +} + +// TestFillDynamicHostUpdatesLastSeen verifies that a second call for the same +// address advances the lastSeen timestamp. +func TestFillDynamicHostUpdatesLastSeen(t *testing.T) { + resetDynamicHosts() + + u := &Upstream{Dial: "192.0.2.3:80"} + u.fillDynamicHost() + + dynamicHostsMu.RLock() + first := dynamicHosts["192.0.2.3:80"].lastSeen + dynamicHostsMu.RUnlock() + + // Ensure measurable time passes. + time.Sleep(2 * time.Millisecond) + + u2 := &Upstream{Dial: "192.0.2.3:80"} + u2.fillDynamicHost() + + dynamicHostsMu.RLock() + second := dynamicHosts["192.0.2.3:80"].lastSeen + dynamicHostsMu.RUnlock() + + if !second.After(first) { + t.Error("expected lastSeen to be updated on second fillDynamicHost call") + } +} + +// TestFillDynamicHostIndependentAddresses verifies that different addresses get +// independent Host entries. +func TestFillDynamicHostIndependentAddresses(t *testing.T) { + resetDynamicHosts() + + u1 := &Upstream{Dial: "192.0.2.4:80"} + u1.fillDynamicHost() + + u2 := &Upstream{Dial: "192.0.2.5:80"} + u2.fillDynamicHost() + + if u1.Host == u2.Host { + t.Error("different addresses should have different *Host entries") + } +} + +// TestFillDynamicHostPreservesFailCount verifies that fail counts on a dynamic +// host survive across multiple fillDynamicHost calls (simulating sequential +// requests), which is the core behaviour fixed by this change. +func TestFillDynamicHostPreservesFailCount(t *testing.T) { + resetDynamicHosts() + + // First "request": provision and record a failure. + u1 := &Upstream{Dial: "192.0.2.6:80"} + u1.fillDynamicHost() + _ = u1.Host.countFail(1) + + if u1.Host.Fails() != 1 { + t.Fatalf("expected 1 fail, got %d", u1.Host.Fails()) + } + + // Second "request": provision the same address again (new *Upstream, same address). + u2 := &Upstream{Dial: "192.0.2.6:80"} + u2.fillDynamicHost() + + if u2.Host.Fails() != 1 { + t.Errorf("expected fail count to persist across fillDynamicHost calls, got %d", u2.Host.Fails()) + } +} + +// TestProvisionUpstreamDynamic verifies that provisionUpstream with dynamic=true +// uses fillDynamicHost (not the UsagePool) and sets healthCheckPolicy / +// MaxRequests correctly from handler config. +func TestProvisionUpstreamDynamic(t *testing.T) { + resetDynamicHosts() + + passive := &PassiveHealthChecks{ + FailDuration: caddy.Duration(10 * time.Second), + MaxFails: 3, + UnhealthyRequestCount: 5, + } + h := Handler{ + HealthChecks: &HealthChecks{ + Passive: passive, + }, + } + + u := &Upstream{Dial: "192.0.2.7:80"} + h.provisionUpstream(u, true) + + if u.Host == nil { + t.Fatal("Host should be set after provisionUpstream") + } + if u.healthCheckPolicy != passive { + t.Error("healthCheckPolicy should point to the handler's PassiveHealthChecks") + } + if u.MaxRequests != 5 { + t.Errorf("expected MaxRequests=5 from UnhealthyRequestCount, got %d", u.MaxRequests) + } + + // Must be in dynamicHosts, not in the static UsagePool. + dynamicHostsMu.RLock() + _, inDynamic := dynamicHosts["192.0.2.7:80"] + dynamicHostsMu.RUnlock() + if !inDynamic { + t.Error("dynamic upstream should be stored in dynamicHosts") + } + _, inPool := hosts.References("192.0.2.7:80") + if inPool { + t.Error("dynamic upstream should NOT be stored in the static UsagePool") + } +} + +// TestProvisionUpstreamStatic verifies that provisionUpstream with dynamic=false +// uses the UsagePool and does NOT insert into dynamicHosts. +func TestProvisionUpstreamStatic(t *testing.T) { + resetDynamicHosts() + + h := Handler{} + + u := &Upstream{Dial: "192.0.2.8:80"} + h.provisionUpstream(u, false) + + if u.Host == nil { + t.Fatal("Host should be set after provisionUpstream") + } + + refs, inPool := hosts.References("192.0.2.8:80") + if !inPool { + t.Error("static upstream should be in the UsagePool") + } + if refs != 1 { + t.Errorf("expected ref count 1, got %d", refs) + } + + dynamicHostsMu.RLock() + _, inDynamic := dynamicHosts["192.0.2.8:80"] + dynamicHostsMu.RUnlock() + if inDynamic { + t.Error("static upstream should NOT be in dynamicHosts") + } + + // Clean up the pool entry we just added. + _, _ = hosts.Delete("192.0.2.8:80") +} + +// TestDynamicHostHealthyConsultsFails verifies the end-to-end passive health +// check path: after enough failures are recorded against a dynamic upstream's +// shared *Host, Healthy() returns false for a newly provisioned *Upstream with +// the same address. +func TestDynamicHostHealthyConsultsFails(t *testing.T) { + resetDynamicHosts() + + passive := &PassiveHealthChecks{ + FailDuration: caddy.Duration(time.Minute), + MaxFails: 2, + } + h := Handler{ + HealthChecks: &HealthChecks{Passive: passive}, + } + + // First request: provision and record two failures. + u1 := &Upstream{Dial: "192.0.2.9:80"} + h.provisionUpstream(u1, true) + + _ = u1.Host.countFail(1) + _ = u1.Host.countFail(1) + + // Second request: fresh *Upstream, same address. + u2 := &Upstream{Dial: "192.0.2.9:80"} + h.provisionUpstream(u2, true) + + if u2.Healthy() { + t.Error("upstream should be unhealthy after MaxFails failures have been recorded against its shared Host") + } +} + +// TestDynamicHostCleanupEvictsStaleEntries verifies that the cleanup sweep +// removes entries whose lastSeen is older than dynamicHostIdleExpiry. +func TestDynamicHostCleanupEvictsStaleEntries(t *testing.T) { + resetDynamicHosts() + + const addr = "192.0.2.10:80" + + // Insert an entry directly with a lastSeen far in the past. + dynamicHostsMu.Lock() + dynamicHosts[addr] = dynamicHostEntry{ + host: new(Host), + lastSeen: time.Now().Add(-2 * dynamicHostIdleExpiry), + } + dynamicHostsMu.Unlock() + + // Run the cleanup logic inline (same logic as the goroutine). + dynamicHostsMu.Lock() + for a, entry := range dynamicHosts { + if time.Since(entry.lastSeen) > dynamicHostIdleExpiry { + delete(dynamicHosts, a) + } + } + dynamicHostsMu.Unlock() + + dynamicHostsMu.RLock() + _, stillPresent := dynamicHosts[addr] + dynamicHostsMu.RUnlock() + + if stillPresent { + t.Error("stale dynamic host entry should have been evicted by cleanup sweep") + } +} + +// TestDynamicHostCleanupRetainsFreshEntries verifies that the cleanup sweep +// keeps entries whose lastSeen is within dynamicHostIdleExpiry. +func TestDynamicHostCleanupRetainsFreshEntries(t *testing.T) { + resetDynamicHosts() + + const addr = "192.0.2.11:80" + + dynamicHostsMu.Lock() + dynamicHosts[addr] = dynamicHostEntry{ + host: new(Host), + lastSeen: time.Now(), + } + dynamicHostsMu.Unlock() + + // Run the cleanup logic inline. + dynamicHostsMu.Lock() + for a, entry := range dynamicHosts { + if time.Since(entry.lastSeen) > dynamicHostIdleExpiry { + delete(dynamicHosts, a) + } + } + dynamicHostsMu.Unlock() + + dynamicHostsMu.RLock() + _, stillPresent := dynamicHosts[addr] + dynamicHostsMu.RUnlock() + + if !stillPresent { + t.Error("fresh dynamic host entry should be retained by cleanup sweep") + } +} + +// TestDynamicHostConcurrentFillHost verifies that concurrent calls to +// fillDynamicHost for the same address all get the same *Host pointer and +// don't race (run with -race). +func TestDynamicHostConcurrentFillHost(t *testing.T) { + resetDynamicHosts() + + const addr = "192.0.2.12:80" + const goroutines = 50 + + var wg sync.WaitGroup + hosts := make([]*Host, goroutines) + + for i := range goroutines { + wg.Add(1) + go func(idx int) { + defer wg.Done() + u := &Upstream{Dial: addr} + u.fillDynamicHost() + hosts[idx] = u.Host + }(i) + } + wg.Wait() + + first := hosts[0] + for i, h := range hosts { + if h != first { + t.Errorf("goroutine %d got a different *Host pointer; expected all to share the same entry", i) + } + } +} diff --git a/modules/caddyhttp/reverseproxy/fastcgi/client_test.go b/modules/caddyhttp/reverseproxy/fastcgi/client_test.go index f850cfb9d..798ee883e 100644 --- a/modules/caddyhttp/reverseproxy/fastcgi/client_test.go +++ b/modules/caddyhttp/reverseproxy/fastcgi/client_test.go @@ -27,7 +27,7 @@ import ( "fmt" "io" "log" - "math/rand" + "math/rand/v2" "net" "net/http" "net/http/fcgi" @@ -197,7 +197,7 @@ func generateRandFile(size int) (p string, m string) { h := md5.New() for i := 0; i < size/16; i++ { buf := make([]byte, 16) - binary.PutVarint(buf, rand.Int63()) + binary.PutVarint(buf, rand.Int64()) if _, err := fo.Write(buf); err != nil { log.Printf("[ERROR] failed to write buffer: %v\n", err) } diff --git a/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go b/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go index d451dd380..c4279d9a0 100644 --- a/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go +++ b/modules/caddyhttp/reverseproxy/fastcgi/fastcgi.go @@ -16,6 +16,7 @@ package fastcgi import ( "crypto/tls" + "errors" "fmt" "net" "net/http" @@ -23,9 +24,12 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "go.uber.org/zap" "go.uber.org/zap/zapcore" + "golang.org/x/text/language" + "golang.org/x/text/search" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/modules/caddyhttp" @@ -33,7 +37,11 @@ import ( "github.com/caddyserver/caddy/v2/modules/caddytls" ) -var noopLogger = zap.NewNop() +var ( + ErrInvalidSplitPath = errors.New("split path contains non-ASCII characters") + + noopLogger = zap.NewNop() +) func init() { caddy.RegisterModule(Transport{}) @@ -50,6 +58,9 @@ type Transport struct { // 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. @@ -109,9 +120,45 @@ func (t *Transport) Provision(ctx caddy.Context) error { t.DialTimeout = caddy.Duration(3 * time.Second) } + var b strings.Builder + + for i, split := range t.SplitPath { + b.Grow(len(split)) + + for j := 0; j < len(split); j++ { + c := split[j] + if c >= utf8.RuneSelf { + return ErrInvalidSplitPath + } + + if 'A' <= c && c <= 'Z' { + b.WriteByte(c + 'a' - 'A') + } else { + b.WriteByte(c) + } + } + + t.SplitPath[i] = b.String() + b.Reset() + } + return nil } +// DefaultBufferSizes enables request buffering for fastcgi if not configured. +// This is because most fastcgi servers are php-fpm that require the content length to be set to read the body, golang +// std has fastcgi implementation that doesn't need this value to process the body, but we can safely assume that's +// not used. +// http3 requests have a negative content length for GET and HEAD requests, if that header is not sent. +// see: https://github.com/caddyserver/caddy/issues/6678#issuecomment-2472224182 +// Though it appears even if CONTENT_LENGTH is invalid, php-fpm can handle just fine if the body is empty (no Stdin records sent). +// php-fpm will hang if there is any data in the body though, https://github.com/caddyserver/caddy/issues/5420#issuecomment-2415943516 + +// TODO: better default buffering for fastcgi requests without content length, in theory a value of 1 should be enough, make it bigger anyway +func (t Transport) DefaultBufferSizes() (int64, int64) { + return 4096, 0 +} + // RoundTrip implements http.RoundTripper. func (t Transport) RoundTrip(r *http.Request) (*http.Response, error) { server := r.Context().Value(caddyhttp.ServerCtxKey).(*caddyhttp.Server) @@ -371,8 +418,15 @@ func (t Transport) buildEnv(r *http.Request) (envVars, error) { return env, nil } +var splitSearchNonASCII = search.New(language.Und, search.IgnoreCase) + // splitPos returns the index where path should // be split based on t.SplitPath. +// +// example: if splitPath is [".php"] +// "/path/to/script.php/some/path": ("/path/to/script.php", "/some/path") +// +// Adapted from FrankenPHP's code (copyright 2026 Kévin Dunglas, MIT license) func (t Transport) splitPos(path string) int { // TODO: from v1... // if httpserver.CaseSensitivePath { @@ -382,12 +436,54 @@ func (t Transport) splitPos(path string) int { 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 Provision(). for _, split := range t.SplitPath { - if idx := strings.Index(lowerPath, strings.ToLower(split)); idx > -1 { - return idx + len(split) + splitLen := len(split) + + for i := range pathLen { + 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 := range splitLen { + 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 } @@ -427,6 +523,7 @@ var headerNameReplacer = strings.NewReplacer(" ", "_", "-", "_") var ( _ zapcore.ObjectMarshaler = (*loggableEnv)(nil) - _ caddy.Provisioner = (*Transport)(nil) - _ http.RoundTripper = (*Transport)(nil) + _ caddy.Provisioner = (*Transport)(nil) + _ http.RoundTripper = (*Transport)(nil) + _ reverseproxy.BufferedTransport = (*Transport)(nil) ) diff --git a/modules/caddyhttp/reverseproxy/fastcgi/fastcgi_test.go b/modules/caddyhttp/reverseproxy/fastcgi/fastcgi_test.go new file mode 100644 index 000000000..7097ff790 --- /dev/null +++ b/modules/caddyhttp/reverseproxy/fastcgi/fastcgi_test.go @@ -0,0 +1,246 @@ +package fastcgi + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/caddyserver/caddy/v2" +) + +func TestProvisionSplitPath(t *testing.T) { + 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) { + tr := Transport{SplitPath: tt.splitPath} + err := tr.Provision(caddy.Context{}) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantSplitPath, tr.SplitPath) + }) + } +} + +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 := Transport{SplitPath: tt.splitPath}.splitPos(tt.path) + 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 := Transport{SplitPath: split}.splitPos(path) + + // 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/modules/caddyhttp/reverseproxy/fastcgi/writer.go b/modules/caddyhttp/reverseproxy/fastcgi/writer.go index 3af00d9a1..225d8f5f8 100644 --- a/modules/caddyhttp/reverseproxy/fastcgi/writer.go +++ b/modules/caddyhttp/reverseproxy/fastcgi/writer.go @@ -112,7 +112,7 @@ func encodeSize(b []byte, size uint32) int { binary.BigEndian.PutUint32(b, size) return 4 } - b[0] = byte(size) + b[0] = byte(size) //nolint:gosec // false positive; b is made 8 bytes long, then this function is always called with b being at least 4 or 1 byte long return 1 } diff --git a/modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go b/modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go index f838c8702..1273e906c 100644 --- a/modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go +++ b/modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go @@ -208,6 +208,24 @@ func parseCaddyfile(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) for _, from := range sortedHeadersToCopy { to := http.CanonicalHeaderKey(headersToCopy[from]) placeholderName := "http.reverse_proxy.header." + http.CanonicalHeaderKey(from) + + // Always delete the client-supplied header before conditionally setting + // it from the auth response. Without this, a client that pre-supplies a + // header listed in copy_headers can inject arbitrary values when the auth + // service does not return that header: the MatchNot guard below would + // skip the Set entirely, leaving the original client-controlled value + // intact and forwarding it to the backend. + copyHeaderRoutes = append(copyHeaderRoutes, caddyhttp.Route{ + HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject( + &headers.Handler{ + Request: &headers.HeaderOps{ + Delete: []string{to}, + }, + }, + "handler", "headers", nil, + )}, + }) + handler := &headers.Handler{ Request: &headers.HeaderOps{ Set: http.Header{ diff --git a/modules/caddyhttp/reverseproxy/headers_test.go b/modules/caddyhttp/reverseproxy/headers_test.go new file mode 100644 index 000000000..9385468f6 --- /dev/null +++ b/modules/caddyhttp/reverseproxy/headers_test.go @@ -0,0 +1,127 @@ +package reverseproxy + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/caddyserver/caddy/v2/modules/caddyhttp" +) + +func TestAddForwardedHeadersNonIP(t *testing.T) { + h := Handler{} + + // Simulate a request with a non-IP remote address (e.g. SCION, abstract socket, or hostname) + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "my-weird-network:12345" + + // Mock the context variables required by Caddy. + // We need to inject the variable map manually since we aren't running the full server. + vars := map[string]interface{}{ + caddyhttp.TrustedProxyVarKey: false, + } + ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars) + req = req.WithContext(ctx) + + // Execute the unexported function + err := h.addForwardedHeaders(req) + + // Expectation: No error should be returned for non-IP addresses. + // The function should simply skip the trusted proxy check. + if err != nil { + t.Errorf("expected no error for non-IP address, got: %v", err) + } +} + +func TestAddForwardedHeaders_UnixSocketTrusted(t *testing.T) { + h := Handler{} + + req := httptest.NewRequest("GET", "http://example.com/", nil) + req.RemoteAddr = "@" + req.Header.Set("X-Forwarded-For", "1.2.3.4, 10.0.0.1") + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Host", "original.example.com") + + vars := map[string]interface{}{ + caddyhttp.TrustedProxyVarKey: true, + caddyhttp.ClientIPVarKey: "1.2.3.4", + } + ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars) + req = req.WithContext(ctx) + + err := h.addForwardedHeaders(req) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if got := req.Header.Get("X-Forwarded-For"); got != "1.2.3.4, 10.0.0.1" { + t.Errorf("X-Forwarded-For = %q, want %q", got, "1.2.3.4, 10.0.0.1") + } + if got := req.Header.Get("X-Forwarded-Proto"); got != "https" { + t.Errorf("X-Forwarded-Proto = %q, want %q", got, "https") + } + if got := req.Header.Get("X-Forwarded-Host"); got != "original.example.com" { + t.Errorf("X-Forwarded-Host = %q, want %q", got, "original.example.com") + } +} + +func TestAddForwardedHeaders_UnixSocketUntrusted(t *testing.T) { + h := Handler{} + + req := httptest.NewRequest("GET", "http://example.com/", nil) + req.RemoteAddr = "@" + req.Header.Set("X-Forwarded-For", "1.2.3.4") + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Host", "spoofed.example.com") + + vars := map[string]interface{}{ + caddyhttp.TrustedProxyVarKey: false, + caddyhttp.ClientIPVarKey: "", + } + ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars) + req = req.WithContext(ctx) + + err := h.addForwardedHeaders(req) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if got := req.Header.Get("X-Forwarded-For"); got != "" { + t.Errorf("X-Forwarded-For should be deleted, got %q", got) + } + if got := req.Header.Get("X-Forwarded-Proto"); got != "" { + t.Errorf("X-Forwarded-Proto should be deleted, got %q", got) + } + if got := req.Header.Get("X-Forwarded-Host"); got != "" { + t.Errorf("X-Forwarded-Host should be deleted, got %q", got) + } +} + +func TestAddForwardedHeaders_UnixSocketTrustedNoExistingHeaders(t *testing.T) { + h := Handler{} + + req := httptest.NewRequest("GET", "http://example.com/", nil) + req.RemoteAddr = "@" + + vars := map[string]interface{}{ + caddyhttp.TrustedProxyVarKey: true, + caddyhttp.ClientIPVarKey: "5.6.7.8", + } + ctx := context.WithValue(req.Context(), caddyhttp.VarsCtxKey, vars) + req = req.WithContext(ctx) + + err := h.addForwardedHeaders(req) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if got := req.Header.Get("X-Forwarded-For"); got != "" { + t.Errorf("X-Forwarded-For should be empty when no prior XFF exists, got %q", got) + } + if got := req.Header.Get("X-Forwarded-Proto"); got != "http" { + t.Errorf("X-Forwarded-Proto = %q, want %q", got, "http") + } + if got := req.Header.Get("X-Forwarded-Host"); got != "example.com" { + t.Errorf("X-Forwarded-Host = %q, want %q", got, "example.com") + } +} diff --git a/modules/caddyhttp/reverseproxy/healthchecks.go b/modules/caddyhttp/reverseproxy/healthchecks.go index ac42570b2..73604f916 100644 --- a/modules/caddyhttp/reverseproxy/healthchecks.go +++ b/modules/caddyhttp/reverseproxy/healthchecks.go @@ -23,7 +23,6 @@ import ( "net/url" "regexp" "runtime/debug" - "slices" "strconv" "strings" "time" @@ -360,6 +359,12 @@ func (h *Handler) doActiveHealthCheckForAllHosts() { dialInfoUpstream = &Upstream{ Dial: h.HealthChecks.Active.Upstream, } + } else if upstream.activeHealthCheckPort != 0 { + // health_port overrides the port; addr has already been updated + // with the health port, so use its address for dialing + dialInfoUpstream = &Upstream{ + Dial: addr.JoinHostPort(0), + } } dialInfo, _ := dialInfoUpstream.fillDialInfo(repl) @@ -405,14 +410,9 @@ func (h *Handler) doActiveHealthCheck(dialInfo DialInfo, hostAddr string, networ u.Host = net.JoinHostPort(host, port) } - // this is kind of a hacky way to know if we should use HTTPS, but whatever - if tt, ok := h.Transport.(TLSTransport); ok && tt.TLSEnabled() { - u.Scheme = "https" - - // if the port is in the except list, flip back to HTTP - if ht, ok := h.Transport.(*HTTPTransport); ok && slices.Contains(ht.TLS.ExceptPorts, port) { - u.Scheme = "http" - } + // override health check schemes if applicable + if hcsot, ok := h.Transport.(HealthCheckSchemeOverriderTransport); ok { + hcsot.OverrideHealthCheckScheme(u, port) } // if we have a provisioned uri, use that, otherwise use @@ -506,7 +506,7 @@ func (h *Handler) doActiveHealthCheck(dialInfo DialInfo, hostAddr string, networ } // do the request, being careful to tame the response body - resp, err := h.HealthChecks.Active.httpClient.Do(req) + resp, err := h.HealthChecks.Active.httpClient.Do(req) //nolint:gosec // no SSRF if err != nil { if c := h.HealthChecks.Active.logger.Check(zapcore.InfoLevel, "HTTP request failed"); c != nil { c.Write( diff --git a/modules/caddyhttp/reverseproxy/hosts.go b/modules/caddyhttp/reverseproxy/hosts.go index 6d35ed821..8139a7b50 100644 --- a/modules/caddyhttp/reverseproxy/hosts.go +++ b/modules/caddyhttp/reverseproxy/hosts.go @@ -19,7 +19,9 @@ import ( "fmt" "net/netip" "strconv" + "sync" "sync/atomic" + "time" "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/modules/caddyhttp" @@ -132,6 +134,43 @@ func (u *Upstream) fillHost() { u.Host = host } +// fillDynamicHost is like fillHost, but stores the host in the separate +// dynamicHosts map rather than the reference-counted UsagePool. Dynamic +// hosts are not reference-counted; instead, they are retained as long as +// they are actively seen and are evicted by a background cleanup goroutine +// after dynamicHostIdleExpiry of inactivity. This preserves health state +// (e.g. passive fail counts) across sequential requests. +func (u *Upstream) fillDynamicHost() { + dynamicHostsMu.Lock() + entry, ok := dynamicHosts[u.String()] + if ok { + entry.lastSeen = time.Now() + dynamicHosts[u.String()] = entry + u.Host = entry.host + } else { + h := new(Host) + dynamicHosts[u.String()] = dynamicHostEntry{host: h, lastSeen: time.Now()} + u.Host = h + } + dynamicHostsMu.Unlock() + + // ensure the cleanup goroutine is running + dynamicHostsCleanerOnce.Do(func() { + go func() { + for { + time.Sleep(dynamicHostCleanupInterval) + dynamicHostsMu.Lock() + for addr, entry := range dynamicHosts { + if time.Since(entry.lastSeen) > dynamicHostIdleExpiry { + delete(dynamicHosts, addr) + } + } + dynamicHostsMu.Unlock() + } + }() + }) +} + // Host is the basic, in-memory representation of the state of a remote host. // Its fields are accessed atomically and Host values must not be copied. type Host struct { @@ -268,6 +307,28 @@ func GetDialInfo(ctx context.Context) (DialInfo, bool) { // through config reloads. var hosts = caddy.NewUsagePool() +// dynamicHosts tracks hosts that were provisioned from dynamic upstream +// sources. Unlike static upstreams which are reference-counted via the +// UsagePool, dynamic upstream hosts are not reference-counted. Instead, +// their last-seen time is updated on each request, and a background +// goroutine evicts entries that have been idle for dynamicHostIdleExpiry. +// This preserves health state (e.g. passive fail counts) across requests +// to the same dynamic backend. +var ( + dynamicHosts = make(map[string]dynamicHostEntry) + dynamicHostsMu sync.RWMutex + dynamicHostsCleanerOnce sync.Once + dynamicHostCleanupInterval = 5 * time.Minute + dynamicHostIdleExpiry = time.Hour +) + +// dynamicHostEntry holds a Host and the last time it was seen +// in a set of dynamic upstreams returned for a request. +type dynamicHostEntry struct { + host *Host + lastSeen time.Time +} + // dialInfoVarKey is the key used for the variable that holds // the dial info for the upstream connection. const dialInfoVarKey = "reverse_proxy.dial_info" @@ -285,3 +346,6 @@ type ProxyProtocolInfo struct { // tlsH1OnlyVarKey is the key used that indicates the connection will use h1 only for TLS. // https://github.com/caddyserver/caddy/issues/7292 const tlsH1OnlyVarKey = "reverse_proxy.tls_h1_only" + +// proxyVarKey is the key used that indicates the proxy server used for a request. +const proxyVarKey = "reverse_proxy.proxy" diff --git a/modules/caddyhttp/reverseproxy/httptransport.go b/modules/caddyhttp/reverseproxy/httptransport.go index 3031bda46..c65bd6185 100644 --- a/modules/caddyhttp/reverseproxy/httptransport.go +++ b/modules/caddyhttp/reverseproxy/httptransport.go @@ -21,9 +21,10 @@ import ( "encoding/base64" "encoding/json" "fmt" - weakrand "math/rand" + weakrand "math/rand/v2" "net" "net/http" + "net/url" "os" "reflect" "slices" @@ -39,6 +40,7 @@ import ( "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig" "github.com/caddyserver/caddy/v2/modules/caddyhttp" + "github.com/caddyserver/caddy/v2/modules/caddyhttp/headers" "github.com/caddyserver/caddy/v2/modules/caddytls" "github.com/caddyserver/caddy/v2/modules/internal/network" ) @@ -159,8 +161,7 @@ type HTTPTransport struct { // `HTTPS_PROXY`, and `NO_PROXY` environment variables. NetworkProxyRaw json.RawMessage `json:"network_proxy,omitempty" caddy:"namespace=caddy.network_proxy inline_key=from"` - h2cTransport *http2.Transport - h3Transport *http3.Transport // TODO: EXPERIMENTAL (May 2024) + h3Transport *http3.Transport // TODO: EXPERIMENTAL (May 2024) } // CaddyModule returns the Caddy module information. @@ -204,11 +205,16 @@ func (h *HTTPTransport) Provision(ctx caddy.Context) error { func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, error) { // Set keep-alive defaults if it wasn't otherwise configured if h.KeepAlive == nil { - h.KeepAlive = &KeepAlive{ - ProbeInterval: caddy.Duration(30 * time.Second), - IdleConnTimeout: caddy.Duration(2 * time.Minute), - MaxIdleConnsPerHost: 32, // seems about optimal, see #2805 - } + h.KeepAlive = new(KeepAlive) + } + if h.KeepAlive.ProbeInterval == 0 { + h.KeepAlive.ProbeInterval = caddy.Duration(30 * time.Second) + } + if h.KeepAlive.IdleConnTimeout == 0 { + h.KeepAlive.IdleConnTimeout = caddy.Duration(2 * time.Minute) + } + if h.KeepAlive.MaxIdleConnsPerHost == 0 { + h.KeepAlive.MaxIdleConnsPerHost = 32 // seems about optimal, see #2805 } // Set a relatively short default dial timeout. @@ -260,22 +266,22 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { //nolint:gosec - addr := h.Resolver.netAddrs[weakrand.Intn(len(h.Resolver.netAddrs))] + addr := h.Resolver.netAddrs[weakrand.IntN(len(h.Resolver.netAddrs))] return d.DialContext(ctx, addr.Network, addr.JoinHostPort(0)) }, } } dialContext := func(ctx context.Context, network, address string) (net.Conn, error) { - // For unix socket upstreams, we need to recover the dial info from - // the request's context, because the Host on the request's URL - // will have been modified by directing the request, overwriting - // the unix socket filename. - // Also, we need to avoid overwriting the address at this point - // when not necessary, because http.ProxyFromEnvironment may have - // modified the address according to the user's env proxy config. + // The network is usually tcp, and the address is the host in http.Request.URL.Host + // and that's been overwritten in directRequest + // However, if proxy is used according to http.ProxyFromEnvironment or proxy providers, + // address will be the address of the proxy server. + + // This means we can safely use the address in dialInfo if proxy is not used (the address and network will be same any way) + // or if the upstream is unix (because there is no way socks or http proxy can be used for unix address). if dialInfo, ok := GetDialInfo(ctx); ok { - if strings.HasPrefix(dialInfo.Network, "unix") { + if caddyhttp.GetVar(ctx, proxyVarKey) == nil || strings.HasPrefix(dialInfo.Network, "unix") { network = dialInfo.Network address = dialInfo.Address } @@ -376,9 +382,22 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e return nil, fmt.Errorf("network_proxy module is not `(func(*http.Request) (*url.URL, error))``") } } + // we need to keep track if a proxy is used for a request + proxyWrapper := func(req *http.Request) (*url.URL, error) { + if proxy == nil { + return nil, nil + } + u, err := proxy(req) + if u == nil || err != nil { + return u, err + } + // there must be a proxy for this request + caddyhttp.SetVar(req.Context(), proxyVarKey, u) + return u, nil + } rt := &http.Transport{ - Proxy: proxy, + Proxy: proxyWrapper, DialContext: dialContext, MaxConnsPerHost: h.MaxConnsPerHost, ResponseHeaderTimeout: time.Duration(h.ResponseHeaderTimeout), @@ -396,8 +415,13 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e return nil, fmt.Errorf("making TLS client config: %v", err) } - // servername has a placeholder, so we need to replace it - if strings.Contains(h.TLS.ServerName, "{") { + serverNameHasPlaceholder := strings.Contains(h.TLS.ServerName, "{") + + // We need to use custom DialTLSContext if: + // 1. ServerName has a placeholder that needs to be replaced at request-time, OR + // 2. ProxyProtocol is enabled, because req.URL.Host is modified to include + // client address info with "->" separator which breaks Go's address parsing + if serverNameHasPlaceholder || h.ProxyProtocol != "" { rt.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) { // reuses the dialer from above to establish a plaintext connection conn, err := dialContext(ctx, network, addr) @@ -406,9 +430,11 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e } // but add our own handshake logic - repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer) tlsConfig := rt.TLSClientConfig.Clone() - tlsConfig.ServerName = repl.ReplaceAll(tlsConfig.ServerName, "") + if serverNameHasPlaceholder { + repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + tlsConfig.ServerName = repl.ReplaceAll(tlsConfig.ServerName, "") + } // h1 only if caddyhttp.GetVar(ctx, tlsH1OnlyVarKey) == true { @@ -422,7 +448,7 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e // complete the handshake before returning the connection if rt.TLSHandshakeTimeout != 0 { var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, rt.TLSHandshakeTimeout) + ctx, cancel = context.WithTimeoutCause(ctx, rt.TLSHandshakeTimeout, fmt.Errorf("HTTP transport TLS handshake %ds timeout", int(rt.TLSHandshakeTimeout.Seconds()))) defer cancel() } err = tlsConn.HandshakeContext(ctx) @@ -457,24 +483,10 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e rt.IdleConnTimeout = time.Duration(h.KeepAlive.IdleConnTimeout) } - // The proxy protocol header can only be sent once right after opening the connection. - // So single connection must not be used for multiple requests, which can potentially - // come from different clients. - if !rt.DisableKeepAlives && h.ProxyProtocol != "" { - caddyCtx.Logger().Warn("disabling keepalives, they are incompatible with using PROXY protocol") - rt.DisableKeepAlives = true - } - if h.Compression != nil { rt.DisableCompression = !*h.Compression } - if slices.Contains(h.Versions, "2") { - if err := http2.ConfigureTransport(rt); err != nil { - return nil, err - } - } - // configure HTTP/3 transport if enabled; however, this does not // automatically fall back to lower versions like most web browsers // do (that'd add latency and complexity, besides, we expect that @@ -492,30 +504,49 @@ func (h *HTTPTransport) NewTransport(caddyCtx caddy.Context) (*http.Transport, e return nil, fmt.Errorf("if HTTP/3 is enabled to the upstream, no other HTTP versions are supported") } - // if h2c is enabled, configure its transport (std lib http.Transport - // does not "HTTP/2 over cleartext TCP") - if slices.Contains(h.Versions, "h2c") { - // crafting our own http2.Transport doesn't allow us to utilize - // most of the customizations/preferences on the http.Transport, - // because, for some reason, only http2.ConfigureTransport() - // is allowed to set the unexported field that refers to a base - // http.Transport config; oh well - h2t := &http2.Transport{ - // kind of a hack, but for plaintext/H2C requests, pretend to dial TLS - DialTLSContext: func(ctx context.Context, network, address string, _ *tls.Config) (net.Conn, error) { - return dialContext(ctx, network, address) - }, - AllowHTTP: true, + // if h2/c is enabled, configure it explicitly + if slices.Contains(h.Versions, "2") || slices.Contains(h.Versions, "h2c") { + if err := http2.ConfigureTransport(rt); err != nil { + return nil, err } - if h.Compression != nil { - h2t.DisableCompression = !*h.Compression + + // DisableCompression from h2 is configured by http2.ConfigureTransport + // Likewise, DisableKeepAlives from h1 is used too. + + // Protocols field is only used when the request is not using TLS, + // http1/2 over tls is still allowed + if slices.Contains(h.Versions, "h2c") { + rt.Protocols = new(http.Protocols) + rt.Protocols.SetUnencryptedHTTP2(true) + rt.Protocols.SetHTTP1(false) } - h.h2cTransport = h2t } return rt, nil } +// RequestHeaderOps implements TransportHeaderOpsProvider. It returns header +// operations for requests when the transport's configuration indicates they +// should be applied. In particular, when TLS is enabled for this transport, +// return an operation to set the Host header to the upstream host:port +// placeholder so HTTPS upstreams get the proper Host by default. +// +// Note: this is a provision-time hook; the Handler will call this during +// its Provision and cache the resulting HeaderOps. The HeaderOps are +// applied per-request (so placeholders are expanded at request time). +func (h *HTTPTransport) RequestHeaderOps() *headers.HeaderOps { + // If TLS is not configured for this transport, don't inject Host + // defaults. TLS being non-nil indicates HTTPS to the upstream. + if h.TLS == nil { + return nil + } + return &headers.HeaderOps{ + Set: http.Header{ + "Host": []string{"{http.reverse_proxy.upstream.hostport}"}, + }, + } +} + // RoundTrip implements http.RoundTripper. func (h *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { h.SetScheme(req) @@ -525,15 +556,6 @@ func (h *HTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) { return h.h3Transport.RoundTrip(req) } - // if H2C ("HTTP/2 over cleartext") is enabled and the upstream request is - // HTTP without TLS, use the alternate H2C-capable transport instead - if req.URL.Scheme == "http" && h.h2cTransport != nil { - // There is no dedicated DisableKeepAlives field in *http2.Transport. - // This is an alternative way to disable keep-alive. - req.Close = h.Transport.DisableKeepAlives - return h.h2cTransport.RoundTrip(req) - } - return h.Transport.RoundTrip(req) } @@ -575,6 +597,26 @@ func (h *HTTPTransport) EnableTLS(base *TLSConfig) error { return nil } +// EnableH2C enables H2C (HTTP/2 over Cleartext) on the transport. +func (h *HTTPTransport) EnableH2C() error { + h.Versions = []string{"h2c", "2"} + return nil +} + +// OverrideHealthCheckScheme overrides the scheme of the given URL +// used for health checks. +func (h HTTPTransport) OverrideHealthCheckScheme(base *url.URL, port string) { + // if tls is enabled and the port isn't in the except list, use HTTPs + if h.TLSEnabled() && !slices.Contains(h.TLS.ExceptPorts, port) { + base.Scheme = "https" + } +} + +// ProxyProtocolEnabled returns true if proxy protocol is enabled. +func (h HTTPTransport) ProxyProtocolEnabled() bool { + return h.ProxyProtocol != "" +} + // Cleanup implements caddy.CleanerUpper and closes any idle connections. func (h HTTPTransport) Cleanup() error { if h.Transport == nil { @@ -831,8 +873,11 @@ func decodeBase64DERCert(certStr string) (*x509.Certificate, error) { // Interface guards var ( - _ caddy.Provisioner = (*HTTPTransport)(nil) - _ http.RoundTripper = (*HTTPTransport)(nil) - _ caddy.CleanerUpper = (*HTTPTransport)(nil) - _ TLSTransport = (*HTTPTransport)(nil) + _ caddy.Provisioner = (*HTTPTransport)(nil) + _ http.RoundTripper = (*HTTPTransport)(nil) + _ caddy.CleanerUpper = (*HTTPTransport)(nil) + _ TLSTransport = (*HTTPTransport)(nil) + _ H2CTransport = (*HTTPTransport)(nil) + _ HealthCheckSchemeOverriderTransport = (*HTTPTransport)(nil) + _ ProxyProtocolTransport = (*HTTPTransport)(nil) ) diff --git a/modules/caddyhttp/reverseproxy/httptransport_test.go b/modules/caddyhttp/reverseproxy/httptransport_test.go index 46931c8b1..88ac9d591 100644 --- a/modules/caddyhttp/reverseproxy/httptransport_test.go +++ b/modules/caddyhttp/reverseproxy/httptransport_test.go @@ -1,11 +1,13 @@ package reverseproxy import ( + "context" "encoding/json" "fmt" "reflect" "testing" + "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" ) @@ -94,3 +96,102 @@ func TestHTTPTransportUnmarshalCaddyFileWithCaPools(t *testing.T) { }) } } + +func TestHTTPTransport_RequestHeaderOps_TLS(t *testing.T) { + var ht HTTPTransport + // When TLS is nil, expect no header ops + if ops := ht.RequestHeaderOps(); ops != nil { + t.Fatalf("expected nil HeaderOps when TLS is nil, got: %#v", ops) + } + + // When TLS is configured, expect a HeaderOps that sets Host + ht.TLS = &TLSConfig{} + ops := ht.RequestHeaderOps() + if ops == nil { + t.Fatal("expected non-nil HeaderOps when TLS is set") + } + if ops.Set == nil { + t.Fatalf("expected ops.Set to be non-nil, got nil") + } + if got := ops.Set.Get("Host"); got != "{http.reverse_proxy.upstream.hostport}" { + t.Fatalf("unexpected Host value; want placeholder, got: %s", got) + } +} + +// TestHTTPTransport_DialTLSContext_ProxyProtocol verifies that when TLS and +// ProxyProtocol are both enabled, DialTLSContext is set. This is critical because +// ProxyProtocol modifies req.URL.Host to include client info with "->" separator +// (e.g., "[2001:db8::1]:12345->127.0.0.1:443"), which breaks Go's address parsing. +// Without a custom DialTLSContext, Go's HTTP library would fail with +// "too many colons in address" when trying to parse the mangled host. +func TestHTTPTransport_DialTLSContext_ProxyProtocol(t *testing.T) { + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) + defer cancel() + + tests := []struct { + name string + tls *TLSConfig + proxyProtocol string + serverNameHasPlaceholder bool + expectDialTLSContext bool + }{ + { + name: "no TLS, no proxy protocol", + tls: nil, + proxyProtocol: "", + expectDialTLSContext: false, + }, + { + name: "TLS without proxy protocol", + tls: &TLSConfig{}, + proxyProtocol: "", + expectDialTLSContext: false, + }, + { + name: "TLS with proxy protocol v1", + tls: &TLSConfig{}, + proxyProtocol: "v1", + expectDialTLSContext: true, + }, + { + name: "TLS with proxy protocol v2", + tls: &TLSConfig{}, + proxyProtocol: "v2", + expectDialTLSContext: true, + }, + { + name: "TLS with placeholder ServerName", + tls: &TLSConfig{ServerName: "{http.request.host}"}, + proxyProtocol: "", + serverNameHasPlaceholder: true, + expectDialTLSContext: true, + }, + { + name: "TLS with placeholder ServerName and proxy protocol", + tls: &TLSConfig{ServerName: "{http.request.host}"}, + proxyProtocol: "v2", + serverNameHasPlaceholder: true, + expectDialTLSContext: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ht := &HTTPTransport{ + TLS: tt.tls, + ProxyProtocol: tt.proxyProtocol, + } + + rt, err := ht.NewTransport(ctx) + if err != nil { + t.Fatalf("NewTransport() error = %v", err) + } + + hasDialTLSContext := rt.DialTLSContext != nil + if hasDialTLSContext != tt.expectDialTLSContext { + t.Errorf("DialTLSContext set = %v, want %v", hasDialTLSContext, tt.expectDialTLSContext) + } + }) + } +} + diff --git a/modules/caddyhttp/reverseproxy/passive_health_test.go b/modules/caddyhttp/reverseproxy/passive_health_test.go new file mode 100644 index 000000000..0bd6da181 --- /dev/null +++ b/modules/caddyhttp/reverseproxy/passive_health_test.go @@ -0,0 +1,391 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package reverseproxy + +import ( + "context" + "testing" + "time" + + "github.com/caddyserver/caddy/v2" +) + +// newPassiveHandler builds a minimal Handler with passive health checks +// configured and a live caddy.Context so the fail-forgetter goroutine can +// be cancelled cleanly. The caller must call cancel() when done. +func newPassiveHandler(t *testing.T, maxFails int, failDuration time.Duration) (*Handler, context.CancelFunc) { + t.Helper() + caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) + h := &Handler{ + ctx: caddyCtx, + HealthChecks: &HealthChecks{ + Passive: &PassiveHealthChecks{ + MaxFails: maxFails, + FailDuration: caddy.Duration(failDuration), + }, + }, + } + return h, cancel +} + +// provisionedStaticUpstream creates a static upstream, registers it in the +// UsagePool, and returns a cleanup func that removes it from the pool. +func provisionedStaticUpstream(t *testing.T, h *Handler, addr string) (*Upstream, func()) { + t.Helper() + u := &Upstream{Dial: addr} + h.provisionUpstream(u, false) + return u, func() { _, _ = hosts.Delete(addr) } +} + +// provisionedDynamicUpstream creates a dynamic upstream, registers it in +// dynamicHosts, and returns a cleanup func that removes it. +func provisionedDynamicUpstream(t *testing.T, h *Handler, addr string) (*Upstream, func()) { + t.Helper() + u := &Upstream{Dial: addr} + h.provisionUpstream(u, true) + return u, func() { + dynamicHostsMu.Lock() + delete(dynamicHosts, addr) + dynamicHostsMu.Unlock() + } +} + +// --- countFailure behaviour --- + +// TestCountFailureNoopWhenNoHealthChecks verifies that countFailure is a no-op +// when HealthChecks is nil. +func TestCountFailureNoopWhenNoHealthChecks(t *testing.T) { + resetDynamicHosts() + h := &Handler{} + u := &Upstream{Dial: "10.1.0.1:80", Host: new(Host)} + + h.countFailure(u) + + if u.Host.Fails() != 0 { + t.Errorf("expected 0 fails with no HealthChecks config, got %d", u.Host.Fails()) + } +} + +// TestCountFailureNoopWhenZeroDuration verifies that countFailure is a no-op +// when FailDuration is 0 (the zero value disables passive checks). +func TestCountFailureNoopWhenZeroDuration(t *testing.T) { + resetDynamicHosts() + caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) + defer cancel() + h := &Handler{ + ctx: caddyCtx, + HealthChecks: &HealthChecks{ + Passive: &PassiveHealthChecks{MaxFails: 1, FailDuration: 0}, + }, + } + u := &Upstream{Dial: "10.1.0.2:80", Host: new(Host)} + + h.countFailure(u) + + if u.Host.Fails() != 0 { + t.Errorf("expected 0 fails with zero FailDuration, got %d", u.Host.Fails()) + } +} + +// TestCountFailureIncrementsCount verifies that countFailure increments the +// fail count on the upstream's Host. +func TestCountFailureIncrementsCount(t *testing.T) { + resetDynamicHosts() + h, cancel := newPassiveHandler(t, 2, time.Minute) + defer cancel() + u := &Upstream{Dial: "10.1.0.3:80", Host: new(Host)} + + h.countFailure(u) + + if u.Host.Fails() != 1 { + t.Errorf("expected 1 fail after countFailure, got %d", u.Host.Fails()) + } +} + +// TestCountFailureDecrementsAfterDuration verifies that the fail count is +// decremented back after FailDuration elapses. +func TestCountFailureDecrementsAfterDuration(t *testing.T) { + resetDynamicHosts() + const failDuration = 50 * time.Millisecond + h, cancel := newPassiveHandler(t, 2, failDuration) + defer cancel() + u := &Upstream{Dial: "10.1.0.4:80", Host: new(Host)} + + h.countFailure(u) + if u.Host.Fails() != 1 { + t.Fatalf("expected 1 fail immediately after countFailure, got %d", u.Host.Fails()) + } + + // Wait long enough for the forgetter goroutine to fire. + time.Sleep(3 * failDuration) + + if u.Host.Fails() != 0 { + t.Errorf("expected fail count to return to 0 after FailDuration, got %d", u.Host.Fails()) + } +} + +// TestCountFailureCancelledContextForgets verifies that cancelling the handler +// context (simulating a config unload) also triggers the forgetter to run, +// decrementing the fail count. +func TestCountFailureCancelledContextForgets(t *testing.T) { + resetDynamicHosts() + h, cancel := newPassiveHandler(t, 2, time.Hour) // very long duration + u := &Upstream{Dial: "10.1.0.5:80", Host: new(Host)} + + h.countFailure(u) + if u.Host.Fails() != 1 { + t.Fatalf("expected 1 fail immediately after countFailure, got %d", u.Host.Fails()) + } + + // Cancelling the context should cause the forgetter goroutine to exit and + // decrement the count. + cancel() + time.Sleep(50 * time.Millisecond) + + if u.Host.Fails() != 0 { + t.Errorf("expected fail count to be decremented after context cancel, got %d", u.Host.Fails()) + } +} + +// --- static upstream passive health check --- + +// TestStaticUpstreamHealthyWithNoFailures verifies that a static upstream with +// no recorded failures is considered healthy. +func TestStaticUpstreamHealthyWithNoFailures(t *testing.T) { + resetDynamicHosts() + h, cancel := newPassiveHandler(t, 2, time.Minute) + defer cancel() + + u, cleanup := provisionedStaticUpstream(t, h, "10.2.0.1:80") + defer cleanup() + + if !u.Healthy() { + t.Error("upstream with no failures should be healthy") + } +} + +// TestStaticUpstreamUnhealthyAtMaxFails verifies that a static upstream is +// marked unhealthy once its fail count reaches MaxFails. +func TestStaticUpstreamUnhealthyAtMaxFails(t *testing.T) { + resetDynamicHosts() + h, cancel := newPassiveHandler(t, 2, time.Minute) + defer cancel() + + u, cleanup := provisionedStaticUpstream(t, h, "10.2.0.2:80") + defer cleanup() + + h.countFailure(u) + if !u.Healthy() { + t.Error("upstream should still be healthy after 1 of 2 allowed failures") + } + + h.countFailure(u) + if u.Healthy() { + t.Error("upstream should be unhealthy after reaching MaxFails=2") + } +} + +// TestStaticUpstreamRecoversAfterFailDuration verifies that a static upstream +// returns to healthy once its failures expire. +func TestStaticUpstreamRecoversAfterFailDuration(t *testing.T) { + resetDynamicHosts() + const failDuration = 50 * time.Millisecond + h, cancel := newPassiveHandler(t, 1, failDuration) + defer cancel() + + u, cleanup := provisionedStaticUpstream(t, h, "10.2.0.3:80") + defer cleanup() + + h.countFailure(u) + if u.Healthy() { + t.Fatal("upstream should be unhealthy immediately after MaxFails failure") + } + + time.Sleep(3 * failDuration) + + if !u.Healthy() { + t.Errorf("upstream should recover to healthy after FailDuration, Fails=%d", u.Host.Fails()) + } +} + +// TestStaticUpstreamHealthPersistedAcrossReprovisioning verifies that static +// upstreams share a Host via the UsagePool, so a second call to provisionUpstream +// for the same address (as happens on config reload) sees the accumulated state. +func TestStaticUpstreamHealthPersistedAcrossReprovisioning(t *testing.T) { + resetDynamicHosts() + h, cancel := newPassiveHandler(t, 2, time.Minute) + defer cancel() + + u1, cleanup1 := provisionedStaticUpstream(t, h, "10.2.0.4:80") + defer cleanup1() + + h.countFailure(u1) + h.countFailure(u1) + + // Simulate a second handler instance referencing the same upstream + // (e.g. after a config reload that keeps the same backend address). + u2, cleanup2 := provisionedStaticUpstream(t, h, "10.2.0.4:80") + defer cleanup2() + + if u1.Host != u2.Host { + t.Fatal("expected both Upstream structs to share the same *Host via UsagePool") + } + if u2.Healthy() { + t.Error("re-provisioned upstream should still see the prior fail count and be unhealthy") + } +} + +// --- dynamic upstream passive health check --- + +// TestDynamicUpstreamHealthyWithNoFailures verifies that a freshly provisioned +// dynamic upstream is healthy. +func TestDynamicUpstreamHealthyWithNoFailures(t *testing.T) { + resetDynamicHosts() + h, cancel := newPassiveHandler(t, 2, time.Minute) + defer cancel() + + u, cleanup := provisionedDynamicUpstream(t, h, "10.3.0.1:80") + defer cleanup() + + if !u.Healthy() { + t.Error("dynamic upstream with no failures should be healthy") + } +} + +// TestDynamicUpstreamUnhealthyAtMaxFails verifies that a dynamic upstream is +// marked unhealthy once its fail count reaches MaxFails. +func TestDynamicUpstreamUnhealthyAtMaxFails(t *testing.T) { + resetDynamicHosts() + h, cancel := newPassiveHandler(t, 2, time.Minute) + defer cancel() + + u, cleanup := provisionedDynamicUpstream(t, h, "10.3.0.2:80") + defer cleanup() + + h.countFailure(u) + if !u.Healthy() { + t.Error("dynamic upstream should still be healthy after 1 of 2 allowed failures") + } + + h.countFailure(u) + if u.Healthy() { + t.Error("dynamic upstream should be unhealthy after reaching MaxFails=2") + } +} + +// TestDynamicUpstreamFailCountPersistedBetweenRequests is the core regression +// test: it simulates two sequential (non-concurrent) requests to the same +// dynamic upstream. Before the fix, the UsagePool entry would be deleted +// between requests, wiping the fail count. Now it should survive. +func TestDynamicUpstreamFailCountPersistedBetweenRequests(t *testing.T) { + resetDynamicHosts() + h, cancel := newPassiveHandler(t, 2, time.Minute) + defer cancel() + + // --- first request --- + u1 := &Upstream{Dial: "10.3.0.3:80"} + h.provisionUpstream(u1, true) + h.countFailure(u1) + + if u1.Host.Fails() != 1 { + t.Fatalf("expected 1 fail after first request, got %d", u1.Host.Fails()) + } + + // Simulate end of first request: no delete from any pool (key difference + // vs. the old behaviour where hosts.Delete was deferred). + + // --- second request: brand-new *Upstream struct, same dial address --- + u2 := &Upstream{Dial: "10.3.0.3:80"} + h.provisionUpstream(u2, true) + + if u1.Host != u2.Host { + t.Fatal("expected both requests to share the same *Host pointer from dynamicHosts") + } + if u2.Host.Fails() != 1 { + t.Errorf("expected fail count to persist across requests, got %d", u2.Host.Fails()) + } + + // A second failure now tips it over MaxFails=2. + h.countFailure(u2) + if u2.Healthy() { + t.Error("upstream should be unhealthy after accumulated failures across requests") + } + + // Cleanup. + dynamicHostsMu.Lock() + delete(dynamicHosts, "10.3.0.3:80") + dynamicHostsMu.Unlock() +} + +// TestDynamicUpstreamRecoveryAfterFailDuration verifies that a dynamic +// upstream's fail count expires and it returns to healthy. +func TestDynamicUpstreamRecoveryAfterFailDuration(t *testing.T) { + resetDynamicHosts() + const failDuration = 50 * time.Millisecond + h, cancel := newPassiveHandler(t, 1, failDuration) + defer cancel() + + u, cleanup := provisionedDynamicUpstream(t, h, "10.3.0.4:80") + defer cleanup() + + h.countFailure(u) + if u.Healthy() { + t.Fatal("upstream should be unhealthy immediately after MaxFails failure") + } + + time.Sleep(3 * failDuration) + + // Re-provision (as a new request would) to get fresh *Upstream with policy set. + u2 := &Upstream{Dial: "10.3.0.4:80"} + h.provisionUpstream(u2, true) + + if !u2.Healthy() { + t.Errorf("dynamic upstream should recover to healthy after FailDuration, Fails=%d", u2.Host.Fails()) + } +} + +// TestDynamicUpstreamMaxRequestsFromUnhealthyRequestCount verifies that +// UnhealthyRequestCount is copied into MaxRequests so Full() works correctly. +func TestDynamicUpstreamMaxRequestsFromUnhealthyRequestCount(t *testing.T) { + resetDynamicHosts() + caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) + defer cancel() + h := &Handler{ + ctx: caddyCtx, + HealthChecks: &HealthChecks{ + Passive: &PassiveHealthChecks{ + UnhealthyRequestCount: 3, + }, + }, + } + + u, cleanup := provisionedDynamicUpstream(t, h, "10.3.0.5:80") + defer cleanup() + + if u.MaxRequests != 3 { + t.Errorf("expected MaxRequests=3 from UnhealthyRequestCount, got %d", u.MaxRequests) + } + + // Should not be full with fewer requests than the limit. + _ = u.Host.countRequest(2) + if u.Full() { + t.Error("upstream should not be full with 2 of 3 allowed requests") + } + + _ = u.Host.countRequest(1) + if !u.Full() { + t.Error("upstream should be full at UnhealthyRequestCount concurrent requests") + } +} diff --git a/modules/caddyhttp/reverseproxy/retries_test.go b/modules/caddyhttp/reverseproxy/retries_test.go new file mode 100644 index 000000000..056223d4c --- /dev/null +++ b/modules/caddyhttp/reverseproxy/retries_test.go @@ -0,0 +1,257 @@ +package reverseproxy + +import ( + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "go.uber.org/zap" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" +) + +// prepareTestRequest injects the context values that ServeHTTP and +// proxyLoopIteration require (caddy.ReplacerCtxKey, VarsCtxKey, etc.) using +// the same helper that the real HTTP server uses. +// +// A zero-value Server is passed so that caddyhttp.ServerCtxKey is set to a +// non-nil pointer; reverseProxy dereferences it to check ShouldLogCredentials. +func prepareTestRequest(req *http.Request) *http.Request { + repl := caddy.NewReplacer() + return caddyhttp.PrepareRequest(req, repl, nil, &caddyhttp.Server{}) +} + +// closeOnCloseReader is an io.ReadCloser whose Close method actually makes +// subsequent reads fail, mimicking the behaviour of a real HTTP request body +// (as opposed to io.NopCloser, whose Close is a no-op and would mask the bug +// we are testing). +type closeOnCloseReader struct { + mu sync.Mutex + r *strings.Reader + closed bool +} + +func newCloseOnCloseReader(s string) *closeOnCloseReader { + return &closeOnCloseReader{r: strings.NewReader(s)} +} + +func (c *closeOnCloseReader) Read(p []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return 0, errors.New("http: invalid Read on closed Body") + } + return c.r.Read(p) +} + +func (c *closeOnCloseReader) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + c.closed = true + return nil +} + +// deadUpstreamAddr returns a TCP address that is guaranteed to refuse +// connections: we bind a listener, note its address, close it immediately, +// and return the address. Any dial to that address will get ECONNREFUSED. +func deadUpstreamAddr(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to create dead upstream listener: %v", err) + } + addr := ln.Addr().String() + ln.Close() + return addr +} + +// testTransport wraps http.Transport to: +// 1. Set the URL scheme to "http" when it is empty (matching what +// HTTPTransport.SetScheme does in production; cloneRequest strips the +// scheme intentionally so a plain *http.Transport would fail with +// "unsupported protocol scheme"). +// 2. Wrap dial errors as DialError so that tryAgain correctly identifies them +// as safe-to-retry regardless of request method (as HTTPTransport does in +// production via its custom dialer). +type testTransport struct{ *http.Transport } + +func (t testTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.Scheme == "" { + req.URL.Scheme = "http" + } + resp, err := t.Transport.RoundTrip(req) + if err != nil { + // Wrap dial errors as DialError to match production behaviour. + // Without this wrapping, tryAgain treats ECONNREFUSED on a POST + // request as non-retryable (only GET is retried by default when + // the error is not a DialError). + var opErr *net.OpError + if errors.As(err, &opErr) && opErr.Op == "dial" { + return nil, DialError{err} + } + } + return resp, err +} + +// minimalHandler returns a Handler with only the fields required by ServeHTTP +// set directly, bypassing Provision (which requires a full Caddy runtime). +// RoundRobinSelection is used so that successive iterations of the proxy loop +// advance through the upstream pool in a predictable order. +func minimalHandler(retries int, upstreams ...*Upstream) *Handler { + return &Handler{ + logger: zap.NewNop(), + Transport: testTransport{&http.Transport{}}, + Upstreams: upstreams, + LoadBalancing: &LoadBalancing{ + Retries: retries, + SelectionPolicy: &RoundRobinSelection{}, + // RetryMatch intentionally nil: dial errors are always retried + // regardless of RetryMatch or request method. + }, + // ctx, connections, connectionsMu, events: zero/nil values are safe + // for the code paths exercised by these tests (TryInterval=0 so + // ctx.Done() is never consulted; no WebSocket hijacking; no passive + // health-check event emission). + } +} + +// TestDialErrorBodyRetry verifies that a POST request whose body has NOT been +// pre-buffered via request_buffers can still be retried after a dial error. +// +// Before the fix, a dial error caused Go's transport to close the shared body +// (via cloneRequest's shallow copy), so the retry attempt would read from an +// already-closed io.ReadCloser and produce: +// +// http: invalid Read on closed Body → HTTP 502 +// +// After the fix the handler wraps the body in noCloseBody when retries are +// configured, preventing the transport's Close() from propagating to the +// shared body. Since dial errors never read any bytes, the body remains at +// position 0 for the retry. +func TestDialErrorBodyRetry(t *testing.T) { + // Good upstream: echoes the request body with 200 OK. + goodServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read body: "+err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + })) + t.Cleanup(goodServer.Close) + + const requestBody = "hello, retry" + + tests := []struct { + name string + method string + body string + retries int + wantStatus int + wantBody string + }{ + { + // Core regression case: POST with a body, no request_buffers, + // dial error on first upstream → retry to second upstream succeeds. + name: "POST body retried after dial error", + method: http.MethodPost, + body: requestBody, + retries: 1, + wantStatus: http.StatusOK, + wantBody: requestBody, + }, + { + // Dial errors are always retried regardless of method, but there + // is no body to re-read, so GET has always worked. Keep it as a + // sanity check that we did not break the no-body path. + name: "GET without body retried after dial error", + method: http.MethodGet, + body: "", + retries: 1, + wantStatus: http.StatusOK, + wantBody: "", + }, + { + // Without any retry configuration the handler must give up on the + // first dial error and return a 502. Confirms no wrapping occurs + // in the no-retry path. + name: "no retries configured returns 502 on dial error", + method: http.MethodPost, + body: requestBody, + retries: 0, + wantStatus: http.StatusBadGateway, + wantBody: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dead := deadUpstreamAddr(t) + + // Build the upstream pool. RoundRobinSelection starts its + // counter at 0 and increments before returning, so with a + // two-element pool it picks index 1 first, then index 0. + // Put the good upstream at index 0 and the dead one at + // index 1 so that: + // attempt 1 → pool[1] = dead → DialError (ECONNREFUSED) + // attempt 2 → pool[0] = good → 200 + upstreams := []*Upstream{ + {Host: new(Host), Dial: goodServer.Listener.Addr().String()}, + {Host: new(Host), Dial: dead}, + } + if tc.retries == 0 { + // For the "no retries" case use only the dead upstream so + // there is nowhere to retry to. + upstreams = []*Upstream{ + {Host: new(Host), Dial: dead}, + } + } + + h := minimalHandler(tc.retries, upstreams...) + + // Use closeOnCloseReader so that Close() truly prevents further + // reads, matching real http.body semantics. io.NopCloser would + // mask the bug because its Close is a no-op. + var bodyReader io.ReadCloser + if tc.body != "" { + bodyReader = newCloseOnCloseReader(tc.body) + } + req := httptest.NewRequest(tc.method, "http://example.com/", bodyReader) + if bodyReader != nil { + // httptest.NewRequest wraps the reader in NopCloser; replace + // it with our close-aware reader so Close() is propagated. + req.Body = bodyReader + req.ContentLength = int64(len(tc.body)) + } + req = prepareTestRequest(req) + + rec := httptest.NewRecorder() + err := h.ServeHTTP(rec, req, caddyhttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { + return nil + })) + + // For error cases (e.g. 502) ServeHTTP returns a HandlerError + // rather than writing the status itself. + gotStatus := rec.Code + if err != nil { + if herr, ok := err.(caddyhttp.HandlerError); ok { + gotStatus = herr.StatusCode + } + } + + if gotStatus != tc.wantStatus { + t.Errorf("status: got %d, want %d (err=%v)", gotStatus, tc.wantStatus, err) + } + if tc.wantBody != "" && rec.Body.String() != tc.wantBody { + t.Errorf("body: got %q, want %q", rec.Body.String(), tc.wantBody) + } + }) + } +} diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index 0c4028ce7..2169d1717 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -32,6 +32,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" "go.uber.org/zap" @@ -46,6 +47,31 @@ import ( "github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite" ) +// inFlightRequests uses sync.Map with atomic.Int64 for lock-free updates on the hot path +var inFlightRequests sync.Map + +func incInFlightRequest(address string) { + v, _ := inFlightRequests.LoadOrStore(address, new(atomic.Int64)) + v.(*atomic.Int64).Add(1) +} + +func decInFlightRequest(address string) { + if v, ok := inFlightRequests.Load(address); ok { + if v.(*atomic.Int64).Add(-1) <= 0 { + inFlightRequests.Delete(address) + } + } +} + +func getInFlightRequests() map[string]int64 { + copyMap := make(map[string]int64) + inFlightRequests.Range(func(key, value any) bool { + copyMap[key.(string)] = value.(*atomic.Int64).Load() + return true + }) + return copyMap +} + func init() { caddy.RegisterModule(Handler{}) } @@ -192,6 +218,13 @@ type Handler struct { CB CircuitBreaker `json:"-"` DynamicUpstreams UpstreamSource `json:"-"` + // transportHeaderOps is a set of header operations provided + // by the transport at provision time, if the transport + // implements TransportHeaderOpsProvider. These ops are + // applied before any user-configured header ops so the + // user can override transport defaults. + transportHeaderOps *headers.HeaderOps + // Holds the parsed CIDR ranges from TrustedProxies trustedProxies []netip.Prefix @@ -243,18 +276,16 @@ func (h *Handler) Provision(ctx caddy.Context) error { return fmt.Errorf("loading transport: %v", err) } h.Transport = mod.(http.RoundTripper) - // enable request buffering for fastcgi if not configured - // This is because most fastcgi servers are php-fpm that require the content length to be set to read the body, golang - // std has fastcgi implementation that doesn't need this value to process the body, but we can safely assume that's - // not used. - // http3 requests have a negative content length for GET and HEAD requests, if that header is not sent. - // see: https://github.com/caddyserver/caddy/issues/6678#issuecomment-2472224182 - // Though it appears even if CONTENT_LENGTH is invalid, php-fpm can handle just fine if the body is empty (no Stdin records sent). - // php-fpm will hang if there is any data in the body though, https://github.com/caddyserver/caddy/issues/5420#issuecomment-2415943516 - // TODO: better default buffering for fastcgi requests without content length, in theory a value of 1 should be enough, make it bigger anyway - if module, ok := h.Transport.(caddy.Module); ok && module.CaddyModule().ID.Name() == "fastcgi" && h.RequestBuffers == 0 { - h.RequestBuffers = 4096 + // set default buffer sizes if applicable + if bt, ok := h.Transport.(BufferedTransport); ok { + reqBuffers, respBuffers := bt.DefaultBufferSizes() + if h.RequestBuffers == 0 { + h.RequestBuffers = reqBuffers + } + if h.ResponseBuffers == 0 { + h.ResponseBuffers = respBuffers + } } } if h.LoadBalancing != nil && h.LoadBalancing.SelectionPolicyRaw != nil { @@ -324,6 +355,18 @@ func (h *Handler) Provision(ctx caddy.Context) error { h.Transport = t } + // If the transport can provide header ops, cache them now so we don't + // have to compute them per-request. Provision the HeaderOps if present + // so any runtime artifacts (like precompiled regex) are prepared. + if tph, ok := h.Transport.(RequestHeaderOpsTransport); ok { + h.transportHeaderOps = tph.RequestHeaderOps() + if h.transportHeaderOps != nil { + if err := h.transportHeaderOps.Provision(ctx); err != nil { + return fmt.Errorf("provisioning transport header ops: %v", err) + } + } + } + // set up load balancing if h.LoadBalancing == nil { h.LoadBalancing = new(LoadBalancing) @@ -349,7 +392,7 @@ func (h *Handler) Provision(ctx caddy.Context) error { // set up upstreams for _, u := range h.Upstreams { - h.provisionUpstream(u) + h.provisionUpstream(u, false) } if h.HealthChecks != nil { @@ -439,6 +482,33 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht reqHost := clonedReq.Host reqHeader := clonedReq.Header + // When retries are configured and there is a body, wrap it in + // io.NopCloser to prevent Go's transport from closing it on dial + // errors. cloneRequest does a shallow copy, so clonedReq.Body and + // r.Body share the same io.ReadCloser — a dial-failure Close() + // would kill the original body for all subsequent retry attempts. + // The real body is closed by the HTTP server when the handler + // returns. + // + // If the body was already fully buffered (via request_buffers), + // we also extract the buffer so the retry loop can replay it + // from the beginning on each attempt. (see #6259, #7546) + var bufferedReqBody *bytes.Buffer + if clonedReq.Body != nil && h.LoadBalancing != nil && + (h.LoadBalancing.Retries > 0 || h.LoadBalancing.TryDuration > 0) { + if reqBodyBuf, ok := clonedReq.Body.(bodyReadCloser); ok && reqBodyBuf.body == nil && reqBodyBuf.buf != nil { + bufferedReqBody = reqBodyBuf.buf + reqBodyBuf.buf = nil + clonedReq.Body = io.NopCloser(bytes.NewReader(bufferedReqBody.Bytes())) + defer func() { + bufferedReqBody.Reset() + bufPool.Put(bufferedReqBody) + }() + } else { + clonedReq.Body = io.NopCloser(clonedReq.Body) + } + } + start := time.Now() defer func() { // total proxying duration, including time spent on LB and retries @@ -457,8 +527,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht // and reusable, so if a backend partially or fully reads the body but then // produces an error, the request can be repeated to the next backend with // the full body (retries should only happen for idempotent requests) (see #6259) - if reqBodyBuf, ok := r.Body.(bodyReadCloser); ok && reqBodyBuf.body == nil { - r.Body = io.NopCloser(bytes.NewReader(reqBodyBuf.buf.Bytes())) + if bufferedReqBody != nil { + clonedReq.Body = io.NopCloser(bytes.NewReader(bufferedReqBody.Bytes())) } var done bool @@ -506,18 +576,11 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h } else { upstreams = dUpstreams for _, dUp := range dUpstreams { - h.provisionUpstream(dUp) + h.provisionUpstream(dUp, true) } if c := h.logger.Check(zapcore.DebugLevel, "provisioned dynamic upstreams"); c != nil { c.Write(zap.Int("count", len(dUpstreams))) } - defer func() { - // these upstreams are dynamic, so they are only used for this iteration - // of the proxy loop; be sure to let them go away when we're done with them - for _, upstream := range dUpstreams { - _, _ = hosts.Delete(upstream.String()) - } - }() } } @@ -563,14 +626,26 @@ func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w h repl.Set("http.reverse_proxy.upstream.fails", upstream.Host.Fails()) // mutate request headers according to this upstream; - // because we're in a retry loop, we have to copy - // headers (and the r.Host value) from the original - // so that each retry is identical to the first - if h.Headers != nil && h.Headers.Request != nil { + // because we're in a retry loop, we have to copy headers + // (and the r.Host value) from the original so that each + // retry is identical to the first. If either transport or + // user ops exist, apply them in order (transport first, + // then user, so user's config wins). + var userOps *headers.HeaderOps + if h.Headers != nil { + userOps = h.Headers.Request + } + transportOps := h.transportHeaderOps + if transportOps != nil || userOps != nil { r.Header = make(http.Header) copyHeader(r.Header, reqHeader) r.Host = reqHost - h.Headers.Request.ApplyToRequest(r) + if transportOps != nil { + transportOps.ApplyToRequest(r) + } + if userOps != nil { + userOps.ApplyToRequest(r) + } } // proxy the request to that upstream @@ -758,48 +833,71 @@ func (h Handler) prepareRequest(req *http.Request, repl *caddy.Replacer) (*http. // the headers at all, then they will be added with the values // that we can glean from the request. func (h Handler) addForwardedHeaders(req *http.Request) error { - // Parse the remote IP, ignore the error as non-fatal, - // but the remote IP is required to continue, so we - // just return early. This should probably never happen - // though, unless some other module manipulated the request's - // remote address and used an invalid value. - clientIP, _, err := net.SplitHostPort(req.RemoteAddr) - if err != nil { - // Remove the `X-Forwarded-*` headers to avoid upstreams - // potentially trusting a header that came from the client - req.Header.Del("X-Forwarded-For") - req.Header.Del("X-Forwarded-Proto") - req.Header.Del("X-Forwarded-Host") - return nil - } - - // Client IP may contain a zone if IPv6, so we need - // to pull that out before parsing the IP - clientIP, _, _ = strings.Cut(clientIP, "%") - ipAddr, err := netip.ParseAddr(clientIP) - if err != nil { - return fmt.Errorf("invalid IP address: '%s': %v", clientIP, err) - } - // Check if the client is a trusted proxy trusted := caddyhttp.GetVar(req.Context(), caddyhttp.TrustedProxyVarKey).(bool) - for _, ipRange := range h.trustedProxies { - if ipRange.Contains(ipAddr) { - trusted = true - break + + var clientIP string + + if req.RemoteAddr == "@" { + // For Unix socket connections, RemoteAddr is "@" which cannot + // be parsed as host:port. If untrusted, strip forwarded headers + // for security. If trusted, there is no peer IP to append to + // X-Forwarded-For, so clientIP stays empty. + if !trusted { + req.Header.Del("X-Forwarded-For") + req.Header.Del("X-Forwarded-Proto") + req.Header.Del("X-Forwarded-Host") + return nil + } + } else { + // Parse the remote IP, ignore the error as non-fatal, + // but the remote IP is required to continue, so we + // just return early. This should probably never happen + // though, unless some other module manipulated the request's + // remote address and used an invalid value. + var err error + clientIP, _, err = net.SplitHostPort(req.RemoteAddr) + if err != nil { + // Remove the `X-Forwarded-*` headers to avoid upstreams + // potentially trusting a header that came from the client + req.Header.Del("X-Forwarded-For") + req.Header.Del("X-Forwarded-Proto") + req.Header.Del("X-Forwarded-Host") + return nil + } + + // Client IP may contain a zone if IPv6, so we need + // to pull that out before parsing the IP + clientIP, _, _ = strings.Cut(clientIP, "%") + ipAddr, err := netip.ParseAddr(clientIP) + + // If ParseAddr fails (e.g. non-IP network like SCION), we cannot check + // if it is a trusted proxy by IP range. In this case, we ignore the + // error and treat the connection as untrusted (or retain existing status). + if err == nil { + for _, ipRange := range h.trustedProxies { + if ipRange.Contains(ipAddr) { + trusted = true + break + } + } } } // If we aren't the first proxy, and the proxy is trusted, // retain prior X-Forwarded-For information as a comma+space // separated list and fold multiple headers into one. - clientXFF := clientIP prior, ok, omit := allHeaderValues(req.Header, "X-Forwarded-For") - if trusted && ok && prior != "" { - clientXFF = prior + ", " + clientXFF - } if !omit { - req.Header.Set("X-Forwarded-For", clientXFF) + if trusted && ok && prior != "" { + if clientIP != "" { + req.Header.Set("X-Forwarded-For", prior+", "+clientIP) + } else { + req.Header.Set("X-Forwarded-For", prior) + } + } else if clientIP != "" { + req.Header.Set("X-Forwarded-For", clientIP) + } } // Set X-Forwarded-Proto; many backend apps expect this, @@ -838,8 +936,16 @@ func (h Handler) addForwardedHeaders(req *http.Request) error { // Go standard library which was used as the foundation.) func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origReq *http.Request, repl *caddy.Replacer, di DialInfo, next caddyhttp.Handler) error { _ = di.Upstream.Host.countRequest(1) + + // Increment the in-flight request count + incInFlightRequest(di.Address) + //nolint:errcheck - defer di.Upstream.Host.countRequest(-1) + defer func() { + di.Upstream.Host.countRequest(-1) + // Decrement the in-flight request count + decInFlightRequest(di.Address) + }() // point the request to this upstream h.directRequest(req, di) @@ -1198,7 +1304,7 @@ func (lb LoadBalancing) tryAgain(ctx caddy.Context, start time.Time, retries int // directRequest modifies only req.URL so that it points to the upstream // in the given DialInfo. It must modify ONLY the request URL. -func (Handler) directRequest(req *http.Request, di DialInfo) { +func (h *Handler) directRequest(req *http.Request, di DialInfo) { // we need a host, so set the upstream's host address reqHost := di.Address @@ -1209,12 +1315,31 @@ func (Handler) directRequest(req *http.Request, di DialInfo) { reqHost = di.Host } + // add client address to the host to let transport differentiate requests from different clients + if ppt, ok := h.Transport.(ProxyProtocolTransport); ok && ppt.ProxyProtocolEnabled() { + if proxyProtocolInfo, ok := caddyhttp.GetVar(req.Context(), proxyProtocolInfoVarKey).(ProxyProtocolInfo); ok { + // encode the request so it plays well with h2 transport, it's unnecessary for h1 but anyway + // The issue is that h2 transport will use the address to determine if new connections are needed + // to roundtrip requests but the without escaping, new connections are constantly created and closed until + // file descriptors are exhausted. + // see: https://github.com/caddyserver/caddy/issues/7529 + reqHost = url.QueryEscape(proxyProtocolInfo.AddrPort.String() + "->" + reqHost) + } + } + req.URL.Host = reqHost } -func (h Handler) provisionUpstream(upstream *Upstream) { - // create or get the host representation for this upstream - upstream.fillHost() +func (h Handler) provisionUpstream(upstream *Upstream, dynamic bool) { + // create or get the host representation for this upstream; + // dynamic upstreams are tracked in a separate map with last-seen + // timestamps so their health state persists across requests without + // being reference-counted (and thus discarded between requests). + if dynamic { + upstream.fillDynamicHost() + } else { + upstream.fillHost() + } // give it the circuit breaker, if any upstream.cb = h.CB @@ -1494,6 +1619,43 @@ type TLSTransport interface { EnableTLS(base *TLSConfig) error } +// H2CTransport is implemented by transports +// that are capable of using h2c. +type H2CTransport interface { + EnableH2C() error +} + +// ProxyProtocolTransport is implemented by transports +// that are capable of using proxy protocol. +type ProxyProtocolTransport interface { + ProxyProtocolEnabled() bool +} + +// HealthCheckSchemeOverriderTransport is implemented by transports +// that can override the scheme used for health checks. +type HealthCheckSchemeOverriderTransport interface { + OverrideHealthCheckScheme(base *url.URL, port string) +} + +// BufferedTransport is implemented by transports +// that needs to buffer requests and/or responses. +type BufferedTransport interface { + // DefaultBufferSizes returns the default buffer sizes + // for requests and responses, respectively if buffering isn't enabled. + DefaultBufferSizes() (int64, int64) +} + +// RequestHeaderOpsTransport may be implemented by a transport to provide +// header operations to apply to requests immediately before the RoundTrip. +// For example, overriding the default Host when TLS is enabled. +type RequestHeaderOpsTransport interface { + // RequestHeaderOps allows a transport to provide header operations + // to apply to the request. The transport is asked at provision time + // to return a HeaderOps (or nil) that will be applied before + // user-configured header ops. + RequestHeaderOps() *headers.HeaderOps +} + // roundtripSucceededError is an error type that is returned if the // roundtrip succeeded, but an error occurred after-the-fact. type roundtripSucceededError struct{ error } @@ -1507,7 +1669,12 @@ type bodyReadCloser struct { } func (brc bodyReadCloser) Close() error { - bufPool.Put(brc.buf) + // Inside this package this will be set to nil for fully-buffered + // requests due to the possibility of retrial. + if brc.buf != nil { + bufPool.Put(brc.buf) + } + // For fully-buffered bodies, body is nil, so Close is a no-op. if brc.body != nil { return brc.body.Close() } diff --git a/modules/caddyhttp/reverseproxy/selectionpolicies.go b/modules/caddyhttp/reverseproxy/selectionpolicies.go index 585fc3400..cd1e469f4 100644 --- a/modules/caddyhttp/reverseproxy/selectionpolicies.go +++ b/modules/caddyhttp/reverseproxy/selectionpolicies.go @@ -20,7 +20,7 @@ import ( "encoding/hex" "encoding/json" "fmt" - weakrand "math/rand" + weakrand "math/rand/v2" "net" "net/http" "strconv" @@ -225,7 +225,7 @@ func (r RandomChoiceSelection) Select(pool UpstreamPool, _ *http.Request, _ http if !upstream.Available() { continue } - j := weakrand.Intn(i + 1) //nolint:gosec + j := weakrand.IntN(i + 1) //nolint:gosec if j < k { choices[j] = upstream } @@ -274,7 +274,7 @@ func (LeastConnSelection) Select(pool UpstreamPool, _ *http.Request, _ http.Resp // sample: https://en.wikipedia.org/wiki/Reservoir_sampling if numReqs == leastReqs { count++ - if count == 1 || (weakrand.Int()%count) == 0 { //nolint:gosec + if count == 1 || weakrand.IntN(count) == 0 { //nolint:gosec bestHost = host } } @@ -312,7 +312,7 @@ func (r *RoundRobinSelection) Select(pool UpstreamPool, _ *http.Request, _ http. if n == 0 { return nil } - for i := uint32(0); i < n; i++ { + for range n { robin := atomic.AddUint32(&r.robin, 1) host := pool[robin%n] if host.Available() { @@ -617,7 +617,7 @@ type CookieHashSelection struct { // The HTTP cookie name whose value is to be hashed and used for upstream selection. Name string `json:"name,omitempty"` // Secret to hash (Hmac256) chosen upstream in cookie - Secret string `json:"secret,omitempty"` + Secret string `json:"secret,omitempty"` //nolint:gosec // yes it's exported because it needs to encode to JSON // The cookie's Max-Age before it expires. Default is no expiry. MaxAge caddy.Duration `json:"max_age,omitempty"` @@ -788,7 +788,7 @@ func selectRandomHost(pool []*Upstream) *Upstream { // upstream will always be chosen if there is at // least one available count++ - if (weakrand.Int() % count) == 0 { //nolint:gosec + if weakrand.IntN(count) == 0 { //nolint:gosec randomHost = upstream } } @@ -827,7 +827,7 @@ func leastRequests(upstreams []*Upstream) *Upstream { if len(best) == 1 { return best[0] } - return best[weakrand.Intn(len(best))] //nolint:gosec + return best[weakrand.IntN(len(best))] //nolint:gosec } // hostByHashing returns an available host from pool based on a hashable string s. diff --git a/modules/caddyhttp/reverseproxy/streaming.go b/modules/caddyhttp/reverseproxy/streaming.go index 66dd106d5..64b6d39d1 100644 --- a/modules/caddyhttp/reverseproxy/streaming.go +++ b/modules/caddyhttp/reverseproxy/streaming.go @@ -24,7 +24,7 @@ import ( "errors" "fmt" "io" - weakrand "math/rand" + weakrand "math/rand/v2" "mime" "net/http" "sync" @@ -214,7 +214,10 @@ func (h *Handler) handleUpgradeResponse(logger *zap.Logger, wg *sync.WaitGroup, timeoutc = timer.C } - errc := make(chan error, 1) + // when a stream timeout is encountered, no error will be read from errc + // a buffer size of 2 will allow both the read and write goroutines to send the error and exit + // see: https://github.com/caddyserver/caddy/issues/7418 + errc := make(chan error, 2) wg.Add(2) go spc.copyToBackend(errc) go spc.copyFromBackend(errc) @@ -526,14 +529,14 @@ func maskBytes(key [4]byte, pos int, b []byte) int { // Create aligned word size key. var k [wordSize]byte for i := range k { - k[i] = key[(pos+i)&3] + k[i] = key[(pos+i)&3] // nolint:gosec // false positive, impossible to be out of bounds; see: https://github.com/securego/gosec/issues/1525 } kw := *(*uintptr)(unsafe.Pointer(&k)) // Mask one word at a time. n := (len(b) / wordSize) * wordSize for i := 0; i < n; i += wordSize { - *(*uintptr)(unsafe.Pointer(uintptr(unsafe.Pointer(&b[0])) + uintptr(i))) ^= kw + *(*uintptr)(unsafe.Add(unsafe.Pointer(&b[0]), i)) ^= kw } // Mask one byte at a time for remaining bytes. diff --git a/modules/caddyhttp/reverseproxy/upstreams.go b/modules/caddyhttp/reverseproxy/upstreams.go index e9eb7e60a..e9120725a 100644 --- a/modules/caddyhttp/reverseproxy/upstreams.go +++ b/modules/caddyhttp/reverseproxy/upstreams.go @@ -4,7 +4,7 @@ import ( "context" "encoding/json" "fmt" - weakrand "math/rand" + weakrand "math/rand/v2" "net" "net/http" "strconv" @@ -70,6 +70,11 @@ type SRVUpstreams struct { // A negative value disables this. FallbackDelay caddy.Duration `json:"dial_fallback_delay,omitempty"` + // Specific network to dial when connecting to the upstream(s) + // provided by SRV records upstream. See Go's net package for + // accepted values. For example, to restrict to IPv4, use "tcp4". + DialNetwork string `json:"dial_network,omitempty"` + resolver *net.Resolver logger *zap.Logger @@ -102,7 +107,7 @@ func (su *SRVUpstreams) Provision(ctx caddy.Context) error { PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { //nolint:gosec - addr := su.Resolver.netAddrs[weakrand.Intn(len(su.Resolver.netAddrs))] + addr := su.Resolver.netAddrs[weakrand.IntN(len(su.Resolver.netAddrs))] return d.DialContext(ctx, addr.Network, addr.JoinHostPort(0)) }, } @@ -177,6 +182,9 @@ func (su SRVUpstreams) GetUpstreams(r *http.Request) ([]*Upstream, error) { ) } addr := net.JoinHostPort(rec.Target, strconv.Itoa(int(rec.Port))) + if su.DialNetwork != "" { + addr = su.DialNetwork + "/" + addr + } upstreams[i] = Upstream{Dial: addr} } @@ -322,7 +330,7 @@ func (au *AUpstreams) Provision(ctx caddy.Context) error { PreferGo: true, Dial: func(ctx context.Context, _, _ string) (net.Conn, error) { //nolint:gosec - addr := au.Resolver.netAddrs[weakrand.Intn(len(au.Resolver.netAddrs))] + addr := au.Resolver.netAddrs[weakrand.IntN(len(au.Resolver.netAddrs))] return d.DialContext(ctx, addr.Network, addr.JoinHostPort(0)) }, } diff --git a/modules/caddyhttp/rewrite/caddyfile.go b/modules/caddyhttp/rewrite/caddyfile.go index 5f9b97adf..0f406161a 100644 --- a/modules/caddyhttp/rewrite/caddyfile.go +++ b/modules/caddyhttp/rewrite/caddyfile.go @@ -173,6 +173,7 @@ func parseCaddyfileURI(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, err if hasArgs { return nil, h.Err("Cannot specify uri query rewrites in both argument and block") } + // nolint:prealloc queryArgs := []string{h.Val()} queryArgs = append(queryArgs, h.RemainingArgs()...) err := applyQueryOps(h, rewr.Query, queryArgs) diff --git a/modules/caddyhttp/rewrite/rewrite.go b/modules/caddyhttp/rewrite/rewrite.go index 2b18744db..ca5f63bac 100644 --- a/modules/caddyhttp/rewrite/rewrite.go +++ b/modules/caddyhttp/rewrite/rewrite.go @@ -247,6 +247,7 @@ func (rewr Rewrite) Rewrite(r *http.Request, repl *caddy.Replacer) bool { } else { r.URL.Path = path } + r.URL.RawPath = "" // force recomputing when EscapedPath() is called } if qsStart >= 0 { r.URL.RawQuery = newQuery diff --git a/modules/caddyhttp/rewrite/rewrite_test.go b/modules/caddyhttp/rewrite/rewrite_test.go index 81360baee..c3b4c1f6c 100644 --- a/modules/caddyhttp/rewrite/rewrite_test.go +++ b/modules/caddyhttp/rewrite/rewrite_test.go @@ -224,6 +224,11 @@ func TestRewrite(t *testing.T) { input: newRequest(t, "GET", "/foo#fragFirst?c=d"), expect: newRequest(t, "GET", "/bar#fragFirst?c=d"), }, + { + rule: Rewrite{URI: "/api/admin/panel"}, + input: newRequest(t, "GET", "/api/admin%2Fpanel"), + expect: newRequest(t, "GET", "/api/admin/panel"), + }, { rule: Rewrite{StripPathPrefix: "/prefix"}, diff --git a/modules/caddyhttp/routes.go b/modules/caddyhttp/routes.go index 3dd770938..ce2287488 100644 --- a/modules/caddyhttp/routes.go +++ b/modules/caddyhttp/routes.go @@ -18,6 +18,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "github.com/caddyserver/caddy/v2" ) @@ -96,7 +97,10 @@ type Route struct { MatcherSets MatcherSets `json:"-"` Handlers []MiddlewareHandler `json:"-"` - middleware []Middleware + middleware []Middleware + metrics *Metrics + metricsCtx caddy.Context + handlerName string } // Empty returns true if the route has all zero/default values. @@ -110,14 +114,16 @@ func (r Route) Empty() bool { } func (r Route) String() string { - handlersRaw := "[" + var handlersRaw strings.Builder + handlersRaw.WriteByte('[') for _, hr := range r.HandlersRaw { - handlersRaw += " " + string(hr) + handlersRaw.WriteByte(' ') + handlersRaw.WriteString(string(hr)) } - handlersRaw += "]" + handlersRaw.WriteByte(']') return fmt.Sprintf(`{Group:"%s" MatcherSetsRaw:%s HandlersRaw:%s Terminal:%t}`, - r.Group, r.MatcherSetsRaw, handlersRaw, r.Terminal) + r.Group, r.MatcherSetsRaw, handlersRaw.String(), r.Terminal) } // Provision sets up both the matchers and handlers in the route. @@ -159,12 +165,20 @@ func (r *Route) ProvisionHandlers(ctx caddy.Context, metrics *Metrics) error { r.Handlers = append(r.Handlers, handler.(MiddlewareHandler)) } + // Store metrics info for route-level instrumentation (applied once + // per route in wrapRoute, instead of per-handler which was redundant). + r.metrics = metrics + r.metricsCtx = ctx + if len(r.Handlers) > 0 { + r.handlerName = caddy.GetModuleName(r.Handlers[0]) + } + // Make ProvisionHandlers idempotent by clearing the middleware field r.middleware = []Middleware{} // pre-compile the middleware handler chain for _, midhandler := range r.Handlers { - r.middleware = append(r.middleware, wrapMiddleware(ctx, midhandler, metrics)) + r.middleware = append(r.middleware, wrapMiddleware(ctx, midhandler)) } return nil } @@ -295,6 +309,16 @@ func wrapRoute(route Route) Middleware { nextCopy = route.middleware[i](nextCopy) } + // Apply metrics instrumentation once for the entire route, + // rather than wrapping each individual handler. This avoids + // redundant metrics collection that caused significant CPU + // overhead (see issue #4644). + if route.metrics != nil { + nextCopy = newMetricsInstrumentedRoute( + route.metricsCtx, route.handlerName, nextCopy, route.metrics, + ) + } + return nextCopy.ServeHTTP(rw, req) }) } @@ -303,20 +327,14 @@ func wrapRoute(route Route) Middleware { // wrapMiddleware wraps mh such that it can be correctly // appended to a list of middleware in preparation for // compiling into a handler chain. -func wrapMiddleware(ctx caddy.Context, mh MiddlewareHandler, metrics *Metrics) Middleware { - handlerToUse := mh - if metrics != nil { - // wrap the middleware with metrics instrumentation - handlerToUse = newMetricsInstrumentedHandler(ctx, caddy.GetModuleName(mh), mh, metrics) - } - +func wrapMiddleware(ctx caddy.Context, mh MiddlewareHandler) Middleware { return func(next Handler) Handler { return HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { // EXPERIMENTAL: Trace each module that gets invoked if server, ok := r.Context().Value(ServerCtxKey).(*Server); ok && server != nil { - server.logTrace(handlerToUse) + server.logTrace(mh) } - return handlerToUse.ServeHTTP(w, r, next) + return mh.ServeHTTP(w, r, next) }) } } @@ -440,13 +458,15 @@ func (ms *MatcherSets) FromInterface(matcherSets any) error { // TODO: Is this used? func (ms MatcherSets) String() string { - result := "[" + var result strings.Builder + result.WriteByte('[') for _, matcherSet := range ms { for _, matcher := range matcherSet { - result += fmt.Sprintf(" %#v", matcher) + fmt.Fprintf(&result, " %#v", matcher) } } - return result + " ]" + result.WriteByte(']') + return result.String() } var routeGroupCtxKey = caddy.CtxKey("route_group") diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index ac30f4028..41a8e55b0 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -18,6 +18,7 @@ import ( "context" "crypto/tls" "encoding/json" + "errors" "fmt" "io" "net" @@ -33,7 +34,7 @@ import ( "github.com/caddyserver/certmagic" "github.com/quic-go/quic-go" "github.com/quic-go/quic-go/http3" - "github.com/quic-go/quic-go/qlog" + h3qlog "github.com/quic-go/quic-go/http3/qlog" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -55,6 +56,10 @@ type Server struct { // of the base listener. They are applied in the given order. ListenerWrappersRaw []json.RawMessage `json:"listener_wrappers,omitempty" caddy:"namespace=caddy.listeners inline_key=wrapper"` + // A list of packet conn wrapper modules, which can modify the behavior + // of the base packet conn. They are applied in the given order. + PacketConnWrappersRaw []json.RawMessage `json:"packet_conn_wrappers,omitempty" caddy:"namespace=caddy.packetconns inline_key=wrapper"` + // How long to allow a read from a client's upload. Setting this // to a short, non-zero value can mitigate slowloris attacks, but // may also affect legitimately slow clients. @@ -248,6 +253,16 @@ type Server struct { // A nil value or element indicates that Protocols will be used instead. ListenProtocols [][]string `json:"listen_protocols,omitempty"` + // If set, overrides whether QUIC listeners allow 0-RTT (early data). + // If nil, the default behavior is used (currently allowed). + // + // One reason to disable 0-RTT is if a remote IP matcher is used, + // which introduces a dependency on the remote address being verified + // if routing happens before the TLS handshake completes. An HTTP 425 + // response is written in that case, but some clients misbehave and + // don't perform a retry, so disabling 0-RTT can smooth it out. + Allow0RTT *bool `json:"allow_0rtt,omitempty"` + // If set, metrics observations will be enabled. // This setting is EXPERIMENTAL and subject to change. // DEPRECATED: Use the app-level `metrics` field. @@ -258,7 +273,8 @@ type Server struct { primaryHandlerChain Handler errorHandlerChain Handler listenerWrappers []caddy.ListenerWrapper - listeners []net.Listener // stdlib http.Server will close these + packetConnWrappers []caddy.PacketConnWrapper + listeners []net.Listener quicListeners []http3.QUICListener // http3 now leave the quic.Listener management to us tlsApp *caddytls.TLS @@ -285,8 +301,15 @@ type Server struct { onStopFuncs []func(context.Context) error // TODO: Experimental (Nov. 2023) } +var ( + ServerHeader = "Caddy" + serverHeader = []string{ServerHeader} +) + // ServeHTTP is the entry point for all HTTP requests. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + start := time.Now() + // If there are listener wrappers that process tls connections but don't return a *tls.Conn, this field will be nil. if r.TLS == nil { if tlsConnStateFunc, ok := r.Context().Value(tlsConnectionStateFuncCtxKey).(func() *tls.ConnectionState); ok { @@ -294,55 +317,37 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } - w.Header().Set("Server", "Caddy") - - // advertise HTTP/3, if enabled - if s.h3server != nil { - if r.ProtoMajor < 3 { - err := s.h3server.SetQUICHeaders(w.Header()) - if err != nil { - if c := s.logger.Check(zapcore.ErrorLevel, "setting HTTP/3 Alt-Svc header"); c != nil { - c.Write(zap.Error(err)) - } - } - } - } - - // reject very long methods; probably a mistake or an attack - if len(r.Method) > 32 { - if s.shouldLogRequest(r) { - if c := s.accessLogger.Check(zapcore.DebugLevel, "rejecting request with long method"); c != nil { - c.Write( - zap.String("method_trunc", r.Method[:32]), - zap.String("remote_addr", r.RemoteAddr), - ) - } - } - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - - repl := caddy.NewReplacer() - r = PrepareRequest(r, repl, w, s) - // enable full-duplex for HTTP/1, ensuring the entire // request body gets consumed before writing the response if s.EnableFullDuplex && r.ProtoMajor == 1 { - //nolint:bodyclose - err := http.NewResponseController(w).EnableFullDuplex() - if err != nil { + if err := http.NewResponseController(w).EnableFullDuplex(); err != nil { //nolint:bodyclose if c := s.logger.Check(zapcore.WarnLevel, "failed to enable full duplex"); c != nil { c.Write(zap.Error(err)) } } } - // clone the request for logging purposes before - // it enters any handler chain; this is necessary - // to capture the original request in case it gets - // modified during handling - // cloning the request and using .WithLazy is considerably faster - // than using .With, which will JSON encode the request immediately + // set the Server header + h := w.Header() + h["Server"] = serverHeader + + // advertise HTTP/3, if enabled + if s.h3server != nil && r.ProtoMajor < 3 { + if err := s.h3server.SetQUICHeaders(h); err != nil { + if c := s.logger.Check(zapcore.ErrorLevel, "setting HTTP/3 Alt-Svc header"); c != nil { + c.Write(zap.Error(err)) + } + } + } + + // prepare internals of the request for the handler pipeline + repl := caddy.NewReplacer() + r = PrepareRequest(r, repl, w, s) + + // clone the request for logging purposes before it enters any handler chain; + // this is necessary to capture the original request in case it gets modified + // during handling (cloning the request and using .WithLazy is considerably + // faster than using .With, which will JSON-encode the request immediately) shouldLogCredentials := s.Logs != nil && s.Logs.ShouldLogCredentials loggableReq := zap.Object("request", LoggableHTTPRequest{ Request: r.Clone(r.Context()), @@ -370,36 +375,33 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // capture the original version of the request - accLog := s.accessLogger.With(loggableReq) + accLog := s.accessLogger.WithLazy(loggableReq) defer s.logRequest(accLog, r, wrec, &duration, repl, bodyReader, shouldLogCredentials) } - start := time.Now() - - // guarantee ACME HTTP challenges; handle them - // separately from any user-defined handlers + // guarantee ACME HTTP challenges; handle them separately from any user-defined handlers if s.tlsApp.HandleHTTPChallenge(w, r) { duration = time.Since(start) return } - // execute the primary handler chain - err := s.primaryHandlerChain.ServeHTTP(w, r) + err := s.serveHTTP(w, r) duration = time.Since(start) - // if no errors, we're done! if err == nil { return } // restore original request before invoking error handler chain (issue #3717) - // TODO: this does not restore original headers, if modified (for efficiency) - origReq := r.Context().Value(OriginalRequestCtxKey).(http.Request) - r.Method = origReq.Method - r.RemoteAddr = origReq.RemoteAddr - r.RequestURI = origReq.RequestURI - cloneURL(origReq.URL, r.URL) + // NOTE: this does not restore original headers if modified (for efficiency) + origReq, ok := r.Context().Value(OriginalRequestCtxKey).(http.Request) + if ok { + r.Method = origReq.Method + r.RemoteAddr = origReq.RemoteAddr + r.RequestURI = origReq.RequestURI + cloneURL(origReq.URL, r.URL) + } // prepare the error log errLog = errLog.With(zap.Duration("duration", duration)) @@ -417,10 +419,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { var fields []zapcore.Field if s.Errors != nil && len(s.Errors.Routes) > 0 { // execute user-defined error handling route - err2 := s.errorHandlerChain.ServeHTTP(w, r) - if err2 == nil { - // user's error route handled the error response - // successfully, so now just log the error + if err2 := s.errorHandlerChain.ServeHTTP(w, r); err2 == nil { + // user's error route handled the error response successfully, so now just log the error for _, logger := range errLoggers { if c := logger.Check(zapcore.DebugLevel, errMsg); c != nil { if fields == nil { @@ -468,6 +468,35 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) error { + // reject very long methods; probably a mistake or an attack + if len(r.Method) > 32 { + if s.shouldLogRequest(r) { + if c := s.accessLogger.Check(zapcore.DebugLevel, "rejecting request with long method"); c != nil { + c.Write( + zap.String("method_trunc", r.Method[:32]), + zap.String("remote_addr", r.RemoteAddr), + ) + } + } + return HandlerError{StatusCode: http.StatusMethodNotAllowed} + } + + // RFC 9112 section 3.2: "A server MUST respond with a 400 (Bad Request) status + // code to any HTTP/1.1 request message that lacks a Host header field and to any + // request message that contains more than one Host header field line or a Host + // header field with an invalid field value." + if r.ProtoMajor == 1 && r.ProtoMinor == 1 && r.Host == "" { + return HandlerError{ + Err: errors.New("rfc9112 forbids empty Host"), + StatusCode: http.StatusBadRequest, + } + } + + // execute the primary handler chain + return s.primaryHandlerChain.ServeHTTP(w, r) +} + // wrapPrimaryRoute wraps stack (a compiled middleware handler chain) // in s.enforcementHandler which performs crucial security checks, etc. func (s *Server) wrapPrimaryRoute(stack Handler) Handler { @@ -551,15 +580,21 @@ func (s *Server) hasListenerAddress(fullAddr string) bool { // The second issue seems very similar to a discussion here: // https://github.com/nodejs/node/issues/9390 // - // This is very easy to reproduce by creating an HTTP server - // that listens to both addresses or just one with a host - // interface; or for a more confusing reproduction, try - // listening on "127.0.0.1:80" and ":443" and you'll see - // the error, if you take away the GOOS condition below. - // - // So, an address is equivalent if the port is in the port - // range, and if not on Linux, the host is the same... sigh. - if (runtime.GOOS == "linux" || thisAddrs.Host == laddrs.Host) && + // However, binding to *different specific* interfaces + // (e.g. 127.0.0.2:80 and 127.0.0.3:80) IS allowed on Linux. + // The conflict only happens when mixing specific IPs with + // wildcards (0.0.0.0 or ::). + + // Hosts match exactly (e.g. 127.0.0.2 == 127.0.0.2) -> Conflict. + hostMatch := thisAddrs.Host == laddrs.Host + + // On Linux, specific IP vs Wildcard fails to bind. + // So if we are on Linux AND either host is empty (wildcard), we treat + // it as a match (conflict). But if both are specific and different + // (127.0.0.2 vs 127.0.0.3), this remains false (no conflict). + linuxWildcardConflict := runtime.GOOS == "linux" && (thisAddrs.Host == "" || laddrs.Host == "") + + if (hostMatch || linuxWildcardConflict) && (laddrs.StartPort <= thisAddrs.EndPort) && (laddrs.StartPort >= thisAddrs.StartPort) { return true @@ -625,7 +660,7 @@ func (s *Server) serveHTTP3(addr caddy.NetworkAddress, tlsCfg *tls.Config) error return fmt.Errorf("starting HTTP/3 QUIC listener: %v", err) } addr.Network = h3net - h3ln, err := addr.ListenQUIC(s.ctx, 0, net.ListenConfig{}, tlsCfg) + h3ln, err := addr.ListenQUIC(s.ctx, 0, net.ListenConfig{}, tlsCfg, s.packetConnWrappers, s.Allow0RTT) if err != nil { return fmt.Errorf("starting HTTP/3 QUIC listener: %v", err) } @@ -638,7 +673,7 @@ func (s *Server) serveHTTP3(addr caddy.NetworkAddress, tlsCfg *tls.Config) error MaxHeaderBytes: s.MaxHeaderBytes, QUICConfig: &quic.Config{ Versions: []quic.Version{quic.Version1, quic.Version2}, - Tracer: qlog.DefaultConnectionTracer, + Tracer: h3qlog.DefaultConnectionTracer, }, IdleTimeout: time.Duration(s.IdleTimeout), } @@ -763,9 +798,11 @@ func (s *Server) shouldLogRequest(r *http.Request) bool { hostWithoutPort = r.Host } - if _, ok := s.Logs.LoggerNames[hostWithoutPort]; ok { - // this host is mapped to a particular logger name - return true + for loggerName := range s.Logs.LoggerNames { + if certmagic.MatchWildcard(hostWithoutPort, loggerName) { + // this host is mapped to a particular logger name + return true + } } for _, dh := range s.Logs.SkipHosts { // logging for this particular host is disabled @@ -793,8 +830,10 @@ func (s *Server) logRequest( accLog *zap.Logger, r *http.Request, wrec ResponseRecorder, duration *time.Duration, repl *caddy.Replacer, bodyReader *lengthReader, shouldLogCredentials bool, ) { + ctx := r.Context() + // this request may be flagged as omitted from the logs - if skip, ok := GetVar(r.Context(), LogSkipVar).(bool); ok && skip { + if skip, ok := GetVar(ctx, LogSkipVar).(bool); ok && skip { return } @@ -812,7 +851,7 @@ func (s *Server) logRequest( } message := "handled request" - if nop, ok := GetVar(r.Context(), "unhandled").(bool); ok && nop { + if nop, ok := GetVar(ctx, "unhandled").(bool); ok && nop { message = "NOP" } @@ -836,7 +875,7 @@ func (s *Server) logRequest( reqBodyLength = bodyReader.Length } - extra := r.Context().Value(ExtraLogFieldsCtxKey).(*ExtraLogFields) + extra := ctx.Value(ExtraLogFieldsCtxKey).(*ExtraLogFields) fieldCount := 6 fields = make([]zapcore.Field, 0, fieldCount+len(extra.fields)) @@ -1001,6 +1040,7 @@ func isTrustedClientIP(ipAddr netip.Addr, trusted []netip.Prefix) bool { // then the first value from those headers is used. func trustedRealClientIP(r *http.Request, headers []string, clientIP string) string { // Read all the values of the configured client IP headers, in order + // nolint:prealloc var values []string for _, field := range headers { values = append(values, r.Header.Values(field)...) diff --git a/modules/caddyhttp/staticresp.go b/modules/caddyhttp/staticresp.go index d783d1b04..439ba4f1f 100644 --- a/modules/caddyhttp/staticresp.go +++ b/modules/caddyhttp/staticresp.go @@ -246,7 +246,7 @@ func (s StaticResponse) ServeHTTP(w http.ResponseWriter, r *http.Request, next H // write response body if statusCode != http.StatusEarlyHints && body != "" { - fmt.Fprint(w, body) + fmt.Fprint(w, body) //nolint:gosec // no XSS unless you sabatoge your own config } // continue handling after Early Hints as they are not the final response @@ -257,7 +257,16 @@ func (s StaticResponse) ServeHTTP(w http.ResponseWriter, r *http.Request, next H return nil } -func buildHTTPServer(i int, port uint, addr string, statusCode int, hdr http.Header, body string, accessLog bool) (*Server, error) { +func buildHTTPServer( + i int, + port uint, + addr string, + statusCode int, + hdr http.Header, + body string, + accessLog bool, +) (*Server, error) { + // nolint:prealloc var handlers []json.RawMessage // response body supports a basic template; evaluate it diff --git a/modules/caddyhttp/templates/templates.go b/modules/caddyhttp/templates/templates.go index eb6488659..994beefab 100644 --- a/modules/caddyhttp/templates/templates.go +++ b/modules/caddyhttp/templates/templates.go @@ -306,6 +306,13 @@ func init() { // find the documentation on time layouts [in Go's docs](https://pkg.go.dev/time#pkg-constants). // The default time layout is `RFC1123Z`, i.e. `Mon, 02 Jan 2006 15:04:05 -0700`. // +// ``` +// {{humanize "size" "2048000"}} +// {{placeholder "http.response.header.Content-Length" | humanize "size"}} +// {{humanize "time" "Fri, 05 May 2022 15:04:05 +0200"}} +// {{humanize "time:2006-Jan-02" "2022-May-05"}} +// ``` +// // ##### `pathEscape` // // Passes a string through `url.PathEscape`, replacing characters that have @@ -318,11 +325,22 @@ func init() { // {{pathEscape "50%_valid_filename?.jpg"}} // ``` // +// ##### `maybe` +// +// Invokes a custom template function only if it is registered (plugged-in) +// in the `http.handlers.templates.functions.*` namespace. +// +// The first argument is the function name, and any subsequent arguments +// are forwarded to that function. If the named function is not available, +// the invocation is ignored and a log message is emitted. +// +// This is useful for templates that optionally use components which may +// not be present in every build or environment. +// +// NOTE: This function is EXPERIMENTAL and subject to change or removal. +// // ``` -// {{humanize "size" "2048000"}} -// {{placeholder "http.response.header.Content-Length" | humanize "size"}} -// {{humanize "time" "Fri, 05 May 2022 15:04:05 +0200"}} -// {{humanize "time:2006-Jan-02" "2022-May-05"}} +// {{ maybe "myOptionalFunc" "arg1" 2 }} // ``` type Templates struct { // The root path from which to load files. Required if template functions diff --git a/modules/caddyhttp/tracing/module.go b/modules/caddyhttp/tracing/module.go index 85fd63002..c312bfd8a 100644 --- a/modules/caddyhttp/tracing/module.go +++ b/modules/caddyhttp/tracing/module.go @@ -27,6 +27,9 @@ type Tracing struct { // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/api.md#span SpanName string `json:"span"` + // SpanAttributes are custom key-value pairs to be added to spans + SpanAttributes map[string]string `json:"span_attributes,omitempty"` + // otel implements opentelemetry related logic. otel openTelemetryWrapper @@ -46,7 +49,7 @@ func (ot *Tracing) Provision(ctx caddy.Context) error { ot.logger = ctx.Logger() var err error - ot.otel, err = newOpenTelemetryWrapper(ctx, ot.SpanName) + ot.otel, err = newOpenTelemetryWrapper(ctx, ot.SpanName, ot.SpanAttributes) return err } @@ -69,6 +72,10 @@ func (ot *Tracing) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyh // // tracing { // [span ] +// [span_attributes { +// attr1 value1 +// attr2 value2 +// }] // } func (ot *Tracing) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { setParameter := func(d *caddyfile.Dispenser, val *string) error { @@ -94,12 +101,30 @@ func (ot *Tracing) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } for d.NextBlock(0) { - if dst, ok := paramsMap[d.Val()]; ok { - if err := setParameter(d, dst); err != nil { - return err + switch d.Val() { + case "span_attributes": + if ot.SpanAttributes == nil { + ot.SpanAttributes = make(map[string]string) + } + for d.NextBlock(1) { + key := d.Val() + if !d.NextArg() { + return d.ArgErr() + } + value := d.Val() + if d.NextArg() { + return d.ArgErr() + } + ot.SpanAttributes[key] = value + } + default: + if dst, ok := paramsMap[d.Val()]; ok { + if err := setParameter(d, dst); err != nil { + return err + } + } else { + return d.ArgErr() } - } else { - return d.ArgErr() } } return nil diff --git a/modules/caddyhttp/tracing/module_test.go b/modules/caddyhttp/tracing/module_test.go index 2a775fc18..a35ea3b35 100644 --- a/modules/caddyhttp/tracing/module_test.go +++ b/modules/caddyhttp/tracing/module_test.go @@ -2,12 +2,16 @@ package tracing import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" + "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/modules/caddyhttp" @@ -15,17 +19,26 @@ import ( func TestTracing_UnmarshalCaddyfile(t *testing.T) { tests := []struct { - name string - spanName string - d *caddyfile.Dispenser - wantErr bool + name string + spanName string + spanAttributes map[string]string + d *caddyfile.Dispenser + wantErr bool }{ { name: "Full config", spanName: "my-span", + spanAttributes: map[string]string{ + "attr1": "value1", + "attr2": "value2", + }, d: caddyfile.NewTestDispenser(` tracing { span my-span + span_attributes { + attr1 value1 + attr2 value2 + } }`), wantErr: false, }, @@ -42,6 +55,21 @@ tracing { name: "Empty config", d: caddyfile.NewTestDispenser(` tracing { +}`), + wantErr: false, + }, + { + name: "Only span attributes", + spanAttributes: map[string]string{ + "service.name": "my-service", + "service.version": "1.0.0", + }, + d: caddyfile.NewTestDispenser(` +tracing { + span_attributes { + service.name my-service + service.version 1.0.0 + } }`), wantErr: false, }, @@ -56,6 +84,20 @@ tracing { if ot.SpanName != tt.spanName { t.Errorf("UnmarshalCaddyfile() SpanName = %v, want SpanName %v", ot.SpanName, tt.spanName) } + + if len(tt.spanAttributes) > 0 { + if ot.SpanAttributes == nil { + t.Errorf("UnmarshalCaddyfile() SpanAttributes is nil, expected %v", tt.spanAttributes) + } else { + for key, expectedValue := range tt.spanAttributes { + if actualValue, exists := ot.SpanAttributes[key]; !exists { + t.Errorf("UnmarshalCaddyfile() SpanAttributes missing key %v", key) + } else if actualValue != expectedValue { + t.Errorf("UnmarshalCaddyfile() SpanAttributes[%v] = %v, want %v", key, actualValue, expectedValue) + } + } + } + } }) } } @@ -79,6 +121,26 @@ func TestTracing_UnmarshalCaddyfile_Error(t *testing.T) { d: caddyfile.NewTestDispenser(` tracing { span +}`), + wantErr: true, + }, + { + name: "Span attributes missing value", + d: caddyfile.NewTestDispenser(` +tracing { + span_attributes { + key + } +}`), + wantErr: true, + }, + { + name: "Span attributes too many arguments", + d: caddyfile.NewTestDispenser(` +tracing { + span_attributes { + key value extra + } }`), wantErr: true, }, @@ -181,6 +243,160 @@ func TestTracing_ServeHTTP_Next_Error(t *testing.T) { } } +func TestTracing_JSON_Configuration(t *testing.T) { + // Test that our struct correctly marshals to and from JSON + original := &Tracing{ + SpanName: "test-span", + SpanAttributes: map[string]string{ + "service.name": "test-service", + "service.version": "1.0.0", + "env": "test", + }, + } + + jsonData, err := json.Marshal(original) + if err != nil { + t.Fatalf("Failed to marshal to JSON: %v", err) + } + + var unmarshaled Tracing + if err := json.Unmarshal(jsonData, &unmarshaled); err != nil { + t.Fatalf("Failed to unmarshal from JSON: %v", err) + } + + if unmarshaled.SpanName != original.SpanName { + t.Errorf("Expected SpanName %s, got %s", original.SpanName, unmarshaled.SpanName) + } + + if len(unmarshaled.SpanAttributes) != len(original.SpanAttributes) { + t.Errorf("Expected %d span attributes, got %d", len(original.SpanAttributes), len(unmarshaled.SpanAttributes)) + } + + for key, expectedValue := range original.SpanAttributes { + if actualValue, exists := unmarshaled.SpanAttributes[key]; !exists { + t.Errorf("Expected span attribute %s to exist", key) + } else if actualValue != expectedValue { + t.Errorf("Expected span attribute %s = %s, got %s", key, expectedValue, actualValue) + } + } + + t.Logf("JSON representation: %s", string(jsonData)) +} + +func TestTracing_OpenTelemetry_Span_Attributes(t *testing.T) { + // Create an in-memory span recorder to capture actual span data + spanRecorder := tracetest.NewSpanRecorder() + provider := trace.NewTracerProvider( + trace.WithSpanProcessor(spanRecorder), + ) + + // Create our tracing module with span attributes that include placeholders + ot := &Tracing{ + SpanName: "test-span", + SpanAttributes: map[string]string{ + "static": "test-service", + "request-placeholder": "{http.request.method}", + "response-placeholder": "{http.response.header.X-Some-Header}", + "mixed": "prefix-{http.request.method}-{http.response.header.X-Some-Header}", + }, + } + + // Create a specific request to test against + req, _ := http.NewRequest("POST", "https://api.example.com/v1/users?id=123", nil) + req.Host = "api.example.com" + + w := httptest.NewRecorder() + + // Set up the replacer + repl := caddy.NewReplacer() + ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl) + ctx = context.WithValue(ctx, caddyhttp.VarsCtxKey, make(map[string]any)) + req = req.WithContext(ctx) + + // Set up request placeholders + repl.Set("http.request.method", req.Method) + repl.Set("http.request.uri", req.URL.RequestURI()) + + // Handler to generate the response + var handler caddyhttp.HandlerFunc = func(writer http.ResponseWriter, request *http.Request) error { + writer.Header().Set("X-Some-Header", "some-value") + writer.WriteHeader(200) + + // Make response headers available to replacer + repl.Set("http.response.header.X-Some-Header", writer.Header().Get("X-Some-Header")) + + return nil + } + + // Set up Caddy context + caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) + defer cancel() + + // Override the global tracer provider with our test provider + // This is a bit hacky but necessary to capture the actual spans + originalProvider := globalTracerProvider + globalTracerProvider = &tracerProvider{ + tracerProvider: provider, + tracerProvidersCounter: 1, // Simulate one user + } + defer func() { + globalTracerProvider = originalProvider + }() + + // Provision the tracing module + if err := ot.Provision(caddyCtx); err != nil { + t.Errorf("Provision error: %v", err) + t.FailNow() + } + + // Execute the request + if err := ot.ServeHTTP(w, req, handler); err != nil { + t.Errorf("ServeHTTP error: %v", err) + } + + // Get the recorded spans + spans := spanRecorder.Ended() + if len(spans) == 0 { + t.Fatal("Expected at least one span to be recorded") + } + + // Find our span (should be the one with our test span name) + var testSpan trace.ReadOnlySpan + for _, span := range spans { + if span.Name() == "test-span" { + testSpan = span + break + } + } + + if testSpan == nil { + t.Fatal("Could not find test span in recorded spans") + } + + // Verify that the span attributes were set correctly with placeholder replacement + expectedAttributes := map[string]string{ + "static": "test-service", + "request-placeholder": "POST", + "response-placeholder": "some-value", + "mixed": "prefix-POST-some-value", + } + + actualAttributes := make(map[string]string) + for _, attr := range testSpan.Attributes() { + actualAttributes[string(attr.Key)] = attr.Value.AsString() + } + + for key, expectedValue := range expectedAttributes { + if actualValue, exists := actualAttributes[key]; !exists { + t.Errorf("Expected span attribute %s to be set", key) + } else if actualValue != expectedValue { + t.Errorf("Expected span attribute %s = %s, got %s", key, expectedValue, actualValue) + } + } + + t.Logf("Recorded span attributes: %+v", actualAttributes) +} + func createRequestWithContext(method string, url string) *http.Request { r, _ := http.NewRequest(method, url, nil) repl := caddy.NewReplacer() diff --git a/modules/caddyhttp/tracing/tracer.go b/modules/caddyhttp/tracing/tracer.go index 261952aa6..bb0f81fc3 100644 --- a/modules/caddyhttp/tracing/tracer.go +++ b/modules/caddyhttp/tracing/tracer.go @@ -5,9 +5,10 @@ import ( "fmt" "net/http" + "go.opentelemetry.io/contrib/exporters/autoexport" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/contrib/propagators/autoprop" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" @@ -37,20 +38,23 @@ type openTelemetryWrapper struct { handler http.Handler - spanName string + spanName string + spanAttributes map[string]string } // newOpenTelemetryWrapper is responsible for the openTelemetryWrapper initialization using provided configuration. func newOpenTelemetryWrapper( ctx context.Context, spanName string, + spanAttributes map[string]string, ) (openTelemetryWrapper, error) { if spanName == "" { spanName = defaultSpanName } ot := openTelemetryWrapper{ - spanName: spanName, + spanName: spanName, + spanAttributes: spanAttributes, } version, _ := caddy.Version() @@ -59,7 +63,7 @@ func newOpenTelemetryWrapper( return ot, fmt.Errorf("creating resource error: %w", err) } - traceExporter, err := otlptracegrpc.New(ctx) + traceExporter, err := autoexport.NewSpanExporter(ctx) if err != nil { return ot, fmt.Errorf("creating trace exporter error: %w", err) } @@ -99,8 +103,22 @@ func (ot *openTelemetryWrapper) serveHTTP(w http.ResponseWriter, r *http.Request extra.Add(zap.String("spanID", spanID)) } } + next := ctx.Value(nextCallCtxKey).(*nextCall) next.err = next.next.ServeHTTP(w, r) + + // Add custom span attributes to the current span + span := trace.SpanFromContext(ctx) + if span.IsRecording() && len(ot.spanAttributes) > 0 { + replacer := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + attributes := make([]attribute.KeyValue, 0, len(ot.spanAttributes)) + for key, value := range ot.spanAttributes { + // Allow placeholder replacement in attribute values + replacedValue := replacer.ReplaceAll(value, "") + attributes = append(attributes, attribute.String(key, replacedValue)) + } + span.SetAttributes(attributes...) + } } // ServeHTTP propagates call to the by wrapped by `otelhttp` next handler. diff --git a/modules/caddyhttp/tracing/tracer_test.go b/modules/caddyhttp/tracing/tracer_test.go index 36a32ff46..5ca423aa9 100644 --- a/modules/caddyhttp/tracing/tracer_test.go +++ b/modules/caddyhttp/tracing/tracer_test.go @@ -16,6 +16,7 @@ func TestOpenTelemetryWrapper_newOpenTelemetryWrapper(t *testing.T) { if otw, err = newOpenTelemetryWrapper(ctx, "", + nil, ); err != nil { t.Errorf("newOpenTelemetryWrapper() error = %v", err) t.FailNow() diff --git a/modules/caddyhttp/vars.go b/modules/caddyhttp/vars.go index d01f4a431..68aaca331 100644 --- a/modules/caddyhttp/vars.go +++ b/modules/caddyhttp/vars.go @@ -181,33 +181,46 @@ func (m VarsMatcher) MatchWithError(r *http.Request) (bool, error) { vars := r.Context().Value(VarsCtxKey).(map[string]any) repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + var fromPlaceholder bool + var matcherValExpanded, valExpanded, varStr, v string + var varValue any for key, vals := range m { - var varValue any if strings.HasPrefix(key, "{") && strings.HasSuffix(key, "}") && strings.Count(key, "{") == 1 { varValue, _ = repl.Get(strings.Trim(key, "{}")) + fromPlaceholder = true } else { varValue = vars[key] + fromPlaceholder = false + } + + switch vv := varValue.(type) { + case string: + varStr = vv + case fmt.Stringer: + varStr = vv.String() + case error: + varStr = vv.Error() + case nil: + varStr = "" + default: + varStr = fmt.Sprintf("%v", vv) + } + + // Only expand placeholders in values from literal variable names + // (e.g. map outputs). Values resolved from placeholder keys are + // already final and must not be re-expanded, as that would allow + // user input like {env.SECRET} to be evaluated. + valExpanded = varStr + if !fromPlaceholder { + valExpanded = repl.ReplaceAll(varStr, "") } // see if any of the values given in the matcher match the actual value - for _, v := range vals { - matcherValExpanded := repl.ReplaceAll(v, "") - var varStr string - switch vv := varValue.(type) { - case string: - varStr = vv - case fmt.Stringer: - varStr = vv.String() - case error: - varStr = vv.Error() - case nil: - varStr = "" - default: - varStr = fmt.Sprintf("%v", vv) - } - if varStr == matcherValExpanded { + for _, v = range vals { + matcherValExpanded = repl.ReplaceAll(v, "") + if valExpanded == matcherValExpanded { return true, nil } } @@ -310,17 +323,21 @@ func (m MatchVarsRE) Match(r *http.Request) bool { func (m MatchVarsRE) MatchWithError(r *http.Request) (bool, error) { vars := r.Context().Value(VarsCtxKey).(map[string]any) repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + + var fromPlaceholder, match bool + var valExpanded, varStr string + var varValue any for key, val := range m { - var varValue any if strings.HasPrefix(key, "{") && strings.HasSuffix(key, "}") && strings.Count(key, "{") == 1 { varValue, _ = repl.Get(strings.Trim(key, "{}")) + fromPlaceholder = true } else { varValue = vars[key] + fromPlaceholder = false } - var varStr string switch vv := varValue.(type) { case string: varStr = vv @@ -334,8 +351,15 @@ func (m MatchVarsRE) MatchWithError(r *http.Request) (bool, error) { varStr = fmt.Sprintf("%v", vv) } - valExpanded := repl.ReplaceAll(varStr, "") - if match := val.Match(valExpanded, repl); match { + // Only expand placeholders in values from literal variable names + // (e.g. map outputs). Values resolved from placeholder keys are + // already final and must not be re-expanded, as that would allow + // user input like {env.SECRET} to be evaluated. + valExpanded = varStr + if !fromPlaceholder { + valExpanded = repl.ReplaceAll(varStr, "") + } + if match = val.Match(valExpanded, repl); match { return match, nil } } diff --git a/modules/caddypki/acmeserver/acmeserver.go b/modules/caddypki/acmeserver/acmeserver.go index aeb4eab8e..446fd456f 100644 --- a/modules/caddypki/acmeserver/acmeserver.go +++ b/modules/caddypki/acmeserver/acmeserver.go @@ -17,7 +17,7 @@ package acmeserver import ( "context" "fmt" - weakrand "math/rand" + weakrand "math/rand/v2" "net" "net/http" "os" @@ -40,6 +40,7 @@ import ( "github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2/modules/caddyhttp" "github.com/caddyserver/caddy/v2/modules/caddypki" + "github.com/caddyserver/caddy/v2/modules/caddytls" ) func init() { @@ -140,6 +141,8 @@ func (ash *Handler) Provision(ctx caddy.Context) error { } } + ash.warnIfPolicyAllowsAll() + // get a reference to the configured CA appModule, err := ctx.App("pki") if err != nil { @@ -214,6 +217,21 @@ func (ash *Handler) Provision(ctx caddy.Context) error { return nil } +func (ash *Handler) warnIfPolicyAllowsAll() { + allow := ash.Policy.normalizeAllowRules() + deny := ash.Policy.normalizeDenyRules() + if allow != nil || deny != nil { + return + } + + allowWildcardNames := ash.Policy != nil && ash.Policy.AllowWildcardNames + ash.logger.Warn( + "acme_server policy has no allow/deny rules; order identifiers are unrestricted (allow-all)", + zap.String("ca", ash.CA), + zap.Bool("allow_wildcard_names", allowWildcardNames), + ) +} + func (ash Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { if strings.HasPrefix(r.URL.Path, ash.PathPrefix) { acmeCtx := acme.NewContext( @@ -287,7 +305,19 @@ func (ash Handler) openDatabase() (*db.AuthDB, error) { // makeClient creates an ACME client which will use a custom // resolver instead of net.DefaultResolver. func (ash Handler) makeClient() (acme.Client, error) { - for _, v := range ash.Resolvers { + // If no local resolvers are configured, check for global resolvers from TLS app + resolversToUse := ash.Resolvers + if len(resolversToUse) == 0 { + tlsAppIface, err := ash.ctx.App("tls") + if err == nil { + tlsApp := tlsAppIface.(*caddytls.TLS) + if len(tlsApp.Resolvers) > 0 { + resolversToUse = tlsApp.Resolvers + } + } + } + + for _, v := range resolversToUse { addr, err := caddy.ParseNetworkAddressWithDefaults(v, "udp", 53) if err != nil { return nil, err @@ -307,7 +337,7 @@ func (ash Handler) makeClient() (acme.Client, error) { PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { //nolint:gosec - addr := ash.resolvers[weakrand.Intn(len(ash.resolvers))] + addr := ash.resolvers[weakrand.IntN(len(ash.resolvers))] return dialer.DialContext(ctx, addr.Network, addr.JoinHostPort(0)) }, } diff --git a/modules/caddypki/acmeserver/acmeserver_test.go b/modules/caddypki/acmeserver/acmeserver_test.go new file mode 100644 index 000000000..ca54012eb --- /dev/null +++ b/modules/caddypki/acmeserver/acmeserver_test.go @@ -0,0 +1,94 @@ +package acmeserver + +import ( + "strings" + "testing" + + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" +) + +func TestHandler_warnIfPolicyAllowsAll(t *testing.T) { + tests := []struct { + name string + policy *Policy + wantWarns int + wantAllowWildcard bool + }{ + { + name: "warns when policy is nil", + policy: nil, + wantWarns: 1, + wantAllowWildcard: false, + }, + { + name: "warns when allow/deny rules are empty", + policy: &Policy{}, + wantWarns: 1, + wantAllowWildcard: false, + }, + { + name: "warns when only allow_wildcard_names is true", + policy: &Policy{ + AllowWildcardNames: true, + }, + wantWarns: 1, + wantAllowWildcard: true, + }, + { + name: "does not warn when allow rules are configured", + policy: &Policy{ + Allow: &RuleSet{ + Domains: []string{"example.com"}, + }, + }, + wantWarns: 0, + wantAllowWildcard: false, + }, + { + name: "does not warn when deny rules are configured", + policy: &Policy{ + Deny: &RuleSet{ + Domains: []string{"bad.example.com"}, + }, + }, + wantWarns: 0, + wantAllowWildcard: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + core, logs := observer.New(zap.WarnLevel) + ash := &Handler{ + CA: "local", + Policy: tt.policy, + logger: zap.New(core), + } + + ash.warnIfPolicyAllowsAll() + if logs.Len() != tt.wantWarns { + t.Fatalf("expected %d warning logs, got %d", tt.wantWarns, logs.Len()) + } + + if tt.wantWarns == 0 { + return + } + + entry := logs.All()[0] + if entry.Level != zap.WarnLevel { + t.Fatalf("expected warn level, got %v", entry.Level) + } + if !strings.Contains(entry.Message, "policy has no allow/deny rules") { + t.Fatalf("unexpected log message: %q", entry.Message) + } + ctx := entry.ContextMap() + if ctx["ca"] != "local" { + t.Fatalf("expected ca=local, got %v", ctx["ca"]) + } + if ctx["allow_wildcard_names"] != tt.wantAllowWildcard { + t.Fatalf("expected allow_wildcard_names=%v, got %v", tt.wantAllowWildcard, ctx["allow_wildcard_names"]) + } + }) + } +} diff --git a/modules/caddypki/adminapi.go b/modules/caddypki/adminapi.go index 463e31f35..dcd3b5816 100644 --- a/modules/caddypki/adminapi.go +++ b/modules/caddypki/adminapi.go @@ -163,9 +163,9 @@ func (a *adminAPI) handleCACerts(w http.ResponseWriter, r *http.Request) error { } w.Header().Set("Content-Type", "application/pem-certificate-chain") - _, err = w.Write(interCert) + _, err = w.Write(interCert) //nolint:gosec // false positive... no XSS in a PEM for cryin' out loud if err == nil { - _, _ = w.Write(rootCert) + _, _ = w.Write(rootCert) //nolint:gosec // false positive... no XSS in a PEM for cryin' out loud } return nil @@ -222,11 +222,16 @@ func rootAndIntermediatePEM(ca *CA) (root, inter []byte, err error) { if err != nil { return root, inter, err } - inter, err = pemEncodeCert(ca.IntermediateCertificate().Raw) - if err != nil { - return root, inter, err + + for _, interCert := range ca.IntermediateCertificateChain() { + pemBytes, err := pemEncodeCert(interCert.Raw) + if err != nil { + return nil, nil, err + } + inter = append(inter, pemBytes...) } - return root, inter, err + + return } // caInfo is the response structure for the CA info API endpoint. diff --git a/modules/caddypki/ca.go b/modules/caddypki/ca.go index 5b17518ca..4b98244aa 100644 --- a/modules/caddypki/ca.go +++ b/modules/caddypki/ca.go @@ -63,6 +63,15 @@ type CA struct { // The intermediate (signing) certificate; if null, one will be generated. Intermediate *KeyPair `json:"intermediate,omitempty"` + // How often to check if intermediate (and root, when applicable) certificates need renewal. + // Default: 10m. + MaintenanceInterval caddy.Duration `json:"maintenance_interval,omitempty"` + + // The fraction of certificate lifetime (0.0–1.0) after which renewal is attempted. + // For example, 0.2 means renew when 20% of the lifetime remains (e.g. ~73 days for a 1-year cert). + // Default: 0.2. + RenewalWindowRatio float64 `json:"renewal_window_ratio,omitempty"` + // Optionally configure a separate storage module associated with this // issuer, instead of using Caddy's global/default-configured storage. // This can be useful if you want to keep your signing keys in a @@ -75,10 +84,11 @@ type CA struct { // and module provisioning. ID string `json:"-"` - storage certmagic.Storage - root, inter *x509.Certificate - interKey any // TODO: should we just store these as crypto.Signer? - mu *sync.RWMutex + storage certmagic.Storage + root *x509.Certificate + interChain []*x509.Certificate + interKey crypto.Signer + mu *sync.RWMutex rootCertPath string // mainly used for logging purposes if trusting log *zap.Logger @@ -125,16 +135,24 @@ func (ca *CA) Provision(ctx caddy.Context, id string, log *zap.Logger) error { if ca.IntermediateLifetime == 0 { ca.IntermediateLifetime = caddy.Duration(defaultIntermediateLifetime) } + if ca.MaintenanceInterval == 0 { + ca.MaintenanceInterval = caddy.Duration(defaultMaintenanceInterval) + } + if ca.RenewalWindowRatio <= 0 || ca.RenewalWindowRatio > 1 { + ca.RenewalWindowRatio = defaultRenewalWindowRatio + } // load the certs and key that will be used for signing - var rootCert, interCert *x509.Certificate + var rootCert *x509.Certificate + var rootCertChain, interCertChain []*x509.Certificate var rootKey, interKey crypto.Signer var err error if ca.Root != nil { if ca.Root.Format == "" || ca.Root.Format == "pem_file" { ca.rootCertPath = ca.Root.Certificate } - rootCert, rootKey, err = ca.Root.Load() + rootCertChain, rootKey, err = ca.Root.Load() + rootCert = rootCertChain[0] } else { ca.rootCertPath = "storage:" + ca.storageKeyRootCert() rootCert, rootKey, err = ca.loadOrGenRoot() @@ -142,21 +160,23 @@ func (ca *CA) Provision(ctx caddy.Context, id string, log *zap.Logger) error { if err != nil { return err } - actualRootLifetime := time.Until(rootCert.NotAfter) - if time.Duration(ca.IntermediateLifetime) >= actualRootLifetime { - return fmt.Errorf("intermediate certificate lifetime must be less than actual root certificate lifetime (%s)", actualRootLifetime) - } + if ca.Intermediate != nil { - interCert, interKey, err = ca.Intermediate.Load() + interCertChain, interKey, err = ca.Intermediate.Load() } else { - interCert, interKey, err = ca.loadOrGenIntermediate(rootCert, rootKey) + actualRootLifetime := time.Until(rootCert.NotAfter) + if time.Duration(ca.IntermediateLifetime) >= actualRootLifetime { + return fmt.Errorf("intermediate certificate lifetime must be less than actual root certificate lifetime (%s)", actualRootLifetime) + } + + interCertChain, interKey, err = ca.loadOrGenIntermediate(rootCert, rootKey) } if err != nil { return err } ca.mu.Lock() - ca.root, ca.inter, ca.interKey = rootCert, interCert, interKey + ca.root, ca.interChain, ca.interKey = rootCert, interCertChain, interKey ca.mu.Unlock() return nil @@ -172,21 +192,21 @@ func (ca CA) RootCertificate() *x509.Certificate { // RootKey returns the CA's root private key. Since the root key is // not cached in memory long-term, it needs to be loaded from storage, // which could yield an error. -func (ca CA) RootKey() (any, error) { +func (ca CA) RootKey() (crypto.Signer, error) { _, rootKey, err := ca.loadOrGenRoot() return rootKey, err } -// IntermediateCertificate returns the CA's intermediate -// certificate (public key). -func (ca CA) IntermediateCertificate() *x509.Certificate { +// IntermediateCertificateChain returns the CA's intermediate +// certificate chain. +func (ca CA) IntermediateCertificateChain() []*x509.Certificate { ca.mu.RLock() defer ca.mu.RUnlock() - return ca.inter + return ca.interChain } // IntermediateKey returns the CA's intermediate private key. -func (ca CA) IntermediateKey() any { +func (ca CA) IntermediateKey() crypto.Signer { ca.mu.RLock() defer ca.mu.RUnlock() return ca.interKey @@ -207,26 +227,27 @@ func (ca *CA) NewAuthority(authorityConfig AuthorityConfig) (*authority.Authorit // cert/key directly, since it's unlikely to expire // while Caddy is running (long lifetime) var issuerCert *x509.Certificate - var issuerKey any + var issuerKey crypto.Signer issuerCert = rootCert var err error issuerKey, err = ca.RootKey() if err != nil { return nil, fmt.Errorf("loading signing key: %v", err) } - signerOption = authority.WithX509Signer(issuerCert, issuerKey.(crypto.Signer)) + signerOption = authority.WithX509Signer(issuerCert, issuerKey) } else { // if we're signing with intermediate, we need to make // sure it's always fresh, because the intermediate may // renew while Caddy is running (medium lifetime) signerOption = authority.WithX509SignerFunc(func() ([]*x509.Certificate, crypto.Signer, error) { - issuerCert := ca.IntermediateCertificate() - issuerKey := ca.IntermediateKey().(crypto.Signer) + issuerChain := ca.IntermediateCertificateChain() + issuerCert := issuerChain[0] + issuerKey := ca.IntermediateKey() ca.log.Debug("using intermediate signer", zap.String("serial", issuerCert.SerialNumber.String()), zap.String("not_before", issuerCert.NotBefore.String()), zap.String("not_after", issuerCert.NotAfter.String())) - return []*x509.Certificate{issuerCert}, issuerKey, nil + return issuerChain, issuerKey, nil }) } @@ -252,7 +273,11 @@ func (ca *CA) NewAuthority(authorityConfig AuthorityConfig) (*authority.Authorit func (ca CA) loadOrGenRoot() (rootCert *x509.Certificate, rootKey crypto.Signer, err error) { if ca.Root != nil { - return ca.Root.Load() + rootChain, rootSigner, err := ca.Root.Load() + if err != nil { + return nil, nil, err + } + return rootChain[0], rootSigner, nil } rootCertPEM, err := ca.storage.Load(ca.ctx, ca.storageKeyRootCert()) if err != nil { @@ -268,7 +293,7 @@ func (ca CA) loadOrGenRoot() (rootCert *x509.Certificate, rootKey crypto.Signer, } if rootCert == nil { - rootCert, err = pemDecodeSingleCert(rootCertPEM) + rootCert, err = pemDecodeCertificate(rootCertPEM) if err != nil { return nil, nil, fmt.Errorf("parsing root certificate PEM: %v", err) } @@ -314,7 +339,8 @@ func (ca CA) genRoot() (rootCert *x509.Certificate, rootKey crypto.Signer, err e return rootCert, rootKey, nil } -func (ca CA) loadOrGenIntermediate(rootCert *x509.Certificate, rootKey crypto.Signer) (interCert *x509.Certificate, interKey crypto.Signer, err error) { +func (ca CA) loadOrGenIntermediate(rootCert *x509.Certificate, rootKey crypto.Signer) (interCertChain []*x509.Certificate, interKey crypto.Signer, err error) { + var interCert *x509.Certificate interCertPEM, err := ca.storage.Load(ca.ctx, ca.storageKeyIntermediateCert()) if err != nil { if !errors.Is(err, fs.ErrNotExist) { @@ -326,10 +352,12 @@ func (ca CA) loadOrGenIntermediate(rootCert *x509.Certificate, rootKey crypto.Si if err != nil { return nil, nil, fmt.Errorf("generating new intermediate cert: %v", err) } + + interCertChain = append(interCertChain, interCert) } - if interCert == nil { - interCert, err = pemDecodeSingleCert(interCertPEM) + if len(interCertChain) == 0 { + interCertChain, err = pemDecodeCertificateChain(interCertPEM) if err != nil { return nil, nil, fmt.Errorf("decoding intermediate certificate PEM: %v", err) } @@ -346,7 +374,7 @@ func (ca CA) loadOrGenIntermediate(rootCert *x509.Certificate, rootKey crypto.Si } } - return interCert, interKey, nil + return interCertChain, interKey, nil } func (ca CA) genIntermediate(rootCert *x509.Certificate, rootKey crypto.Signer) (interCert *x509.Certificate, interKey crypto.Signer, err error) { @@ -443,4 +471,6 @@ const ( defaultRootLifetime = 24 * time.Hour * 30 * 12 * 10 defaultIntermediateLifetime = 24 * time.Hour * 7 + defaultMaintenanceInterval = 10 * time.Minute + defaultRenewalWindowRatio = 0.2 ) diff --git a/modules/caddypki/crypto.go b/modules/caddypki/crypto.go index 324a4fcfa..cfe46cd6d 100644 --- a/modules/caddypki/crypto.go +++ b/modules/caddypki/crypto.go @@ -17,15 +17,20 @@ package caddypki import ( "bytes" "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rsa" "crypto/x509" "encoding/pem" + "errors" "fmt" "os" "github.com/caddyserver/certmagic" + "go.step.sm/crypto/pemutil" ) -func pemDecodeSingleCert(pemDER []byte) (*x509.Certificate, error) { +func pemDecodeCertificate(pemDER []byte) (*x509.Certificate, error) { pemBlock, remaining := pem.Decode(pemDER) if pemBlock == nil { return nil, fmt.Errorf("no PEM block found") @@ -39,6 +44,15 @@ func pemDecodeSingleCert(pemDER []byte) (*x509.Certificate, error) { return x509.ParseCertificate(pemBlock.Bytes) } +func pemDecodeCertificateChain(pemDER []byte) ([]*x509.Certificate, error) { + chain, err := pemutil.ParseCertificateBundle(pemDER) + if err != nil { + return nil, fmt.Errorf("failed parsing certificate chain: %w", err) + } + + return chain, nil +} + func pemEncodeCert(der []byte) ([]byte, error) { return pemEncode("CERTIFICATE", der) } @@ -63,22 +77,25 @@ type KeyPair struct { // The private key. By default, this should be the path to // a PEM file unless format is something else. - PrivateKey string `json:"private_key,omitempty"` + PrivateKey string `json:"private_key,omitempty"` //nolint:gosec // false positive: yes it's exported, since it needs to encode/decode as JSON; and is often just a filepath // The format in which the certificate and private // key are provided. Default: pem_file Format string `json:"format,omitempty"` } -// Load loads the certificate and key. -func (kp KeyPair) Load() (*x509.Certificate, crypto.Signer, error) { +// Load loads the certificate chain and (optional) private key from +// the corresponding files, using the configured format. If a +// private key is read, it will be verified to belong to the first +// certificate in the chain. +func (kp KeyPair) Load() ([]*x509.Certificate, crypto.Signer, error) { switch kp.Format { case "", "pem_file": certData, err := os.ReadFile(kp.Certificate) if err != nil { return nil, nil, err } - cert, err := pemDecodeSingleCert(certData) + chain, err := pemDecodeCertificateChain(certData) if err != nil { return nil, nil, err } @@ -93,11 +110,49 @@ func (kp KeyPair) Load() (*x509.Certificate, crypto.Signer, error) { if err != nil { return nil, nil, err } + if err := verifyKeysMatch(chain[0], key); err != nil { + return nil, nil, err + } } - return cert, key, nil + return chain, key, nil default: return nil, nil, fmt.Errorf("unsupported format: %s", kp.Format) } } + +// verifyKeysMatch verifies that the public key in the [x509.Certificate] matches +// the public key of the [crypto.Signer]. +func verifyKeysMatch(crt *x509.Certificate, signer crypto.Signer) error { + switch pub := crt.PublicKey.(type) { + case *rsa.PublicKey: + pk, ok := signer.Public().(*rsa.PublicKey) + if !ok { + return fmt.Errorf("private key type %T does not match issuer public key type %T", signer.Public(), pub) + } + if !pub.Equal(pk) { + return errors.New("private key does not match issuer public key") + } + case *ecdsa.PublicKey: + pk, ok := signer.Public().(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("private key type %T does not match issuer public key type %T", signer.Public(), pub) + } + if !pub.Equal(pk) { + return errors.New("private key does not match issuer public key") + } + case ed25519.PublicKey: + pk, ok := signer.Public().(ed25519.PublicKey) + if !ok { + return fmt.Errorf("private key type %T does not match issuer public key type %T", signer.Public(), pub) + } + if !pub.Equal(pk) { + return errors.New("private key does not match issuer public key") + } + default: + return fmt.Errorf("unsupported key type: %T", pub) + } + + return nil +} diff --git a/modules/caddypki/crypto_test.go b/modules/caddypki/crypto_test.go new file mode 100644 index 000000000..a07763d14 --- /dev/null +++ b/modules/caddypki/crypto_test.go @@ -0,0 +1,314 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package caddypki + +import ( + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "os" + "path/filepath" + "testing" + "time" + + "go.step.sm/crypto/keyutil" + "go.step.sm/crypto/pemutil" +) + +func TestKeyPair_Load(t *testing.T) { + rootSigner, err := keyutil.GenerateDefaultSigner() + if err != nil { + t.Fatalf("Failed creating signer: %v", err) + } + + tmpl := &x509.Certificate{ + Subject: pkix.Name{CommonName: "test-root"}, + IsCA: true, + MaxPathLen: 3, + } + rootBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, rootSigner.Public(), rootSigner) + if err != nil { + t.Fatalf("Creating root certificate failed: %v", err) + } + + root, err := x509.ParseCertificate(rootBytes) + if err != nil { + t.Fatalf("Parsing root certificate failed: %v", err) + } + + intermediateSigner, err := keyutil.GenerateDefaultSigner() + if err != nil { + t.Fatalf("Creating intermedaite signer failed: %v", err) + } + + intermediateBytes, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + Subject: pkix.Name{CommonName: "test-first-intermediate"}, + IsCA: true, + MaxPathLen: 2, + NotAfter: time.Now().Add(time.Hour), + }, root, intermediateSigner.Public(), rootSigner) + if err != nil { + t.Fatalf("Creating intermediate certificate failed: %v", err) + } + + intermediate, err := x509.ParseCertificate(intermediateBytes) + if err != nil { + t.Fatalf("Parsing intermediate certificate failed: %v", err) + } + + var chainContents []byte + chain := []*x509.Certificate{intermediate, root} + for _, cert := range chain { + b, err := pemutil.Serialize(cert) + if err != nil { + t.Fatalf("Failed serializing intermediate certificate: %v", err) + } + chainContents = append(chainContents, pem.EncodeToMemory(b)...) + } + + dir := t.TempDir() + rootCertFile := filepath.Join(dir, "root.pem") + if _, err = pemutil.Serialize(root, pemutil.WithFilename(rootCertFile)); err != nil { + t.Fatalf("Failed serializing root certificate: %v", err) + } + rootKeyFile := filepath.Join(dir, "root.key") + if _, err = pemutil.Serialize(rootSigner, pemutil.WithFilename(rootKeyFile)); err != nil { + t.Fatalf("Failed serializing root key: %v", err) + } + intermediateCertFile := filepath.Join(dir, "intermediate.pem") + if _, err = pemutil.Serialize(intermediate, pemutil.WithFilename(intermediateCertFile)); err != nil { + t.Fatalf("Failed serializing intermediate certificate: %v", err) + } + intermediateKeyFile := filepath.Join(dir, "intermediate.key") + if _, err = pemutil.Serialize(intermediateSigner, pemutil.WithFilename(intermediateKeyFile)); err != nil { + t.Fatalf("Failed serializing intermediate key: %v", err) + } + chainFile := filepath.Join(dir, "chain.pem") + if err := os.WriteFile(chainFile, chainContents, 0644); err != nil { + t.Fatalf("Failed writing intermediate chain: %v", err) + } + + t.Run("ok/single-certificate-without-signer", func(t *testing.T) { + kp := KeyPair{ + Certificate: rootCertFile, + } + chain, signer, err := kp.Load() + if err != nil { + t.Fatalf("Failed loading KeyPair: %v", err) + } + if len(chain) != 1 { + t.Errorf("Expected 1 certificate in chain; got %d", len(chain)) + } + if signer != nil { + t.Error("Expected no signer to be returned") + } + }) + + t.Run("ok/single-certificate-with-signer", func(t *testing.T) { + kp := KeyPair{ + Certificate: rootCertFile, + PrivateKey: rootKeyFile, + } + chain, signer, err := kp.Load() + if err != nil { + t.Fatalf("Failed loading KeyPair: %v", err) + } + if len(chain) != 1 { + t.Errorf("Expected 1 certificate in chain; got %d", len(chain)) + } + if signer == nil { + t.Error("Expected signer to be returned") + } + }) + + t.Run("ok/multiple-certificates-with-signer", func(t *testing.T) { + kp := KeyPair{ + Certificate: chainFile, + PrivateKey: intermediateKeyFile, + } + chain, signer, err := kp.Load() + if err != nil { + t.Fatalf("Failed loading KeyPair: %v", err) + } + if len(chain) != 2 { + t.Errorf("Expected 2 certificates in chain; got %d", len(chain)) + } + if signer == nil { + t.Error("Expected signer to be returned") + } + }) + + t.Run("fail/non-matching-public-key", func(t *testing.T) { + kp := KeyPair{ + Certificate: intermediateCertFile, + PrivateKey: rootKeyFile, + } + chain, signer, err := kp.Load() + if err == nil { + t.Error("Expected loading KeyPair to return an error") + } + if chain != nil { + t.Error("Expected no chain to be returned") + } + if signer != nil { + t.Error("Expected no signer to be returned") + } + }) +} + +func Test_pemDecodeCertificate(t *testing.T) { + signer, err := keyutil.GenerateDefaultSigner() + if err != nil { + t.Fatalf("Failed creating signer: %v", err) + } + + tmpl := &x509.Certificate{ + Subject: pkix.Name{CommonName: "test-cert"}, + IsCA: true, + MaxPathLen: 3, + } + derBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, signer.Public(), signer) + if err != nil { + t.Fatalf("Creating root certificate failed: %v", err) + } + cert, err := x509.ParseCertificate(derBytes) + if err != nil { + t.Fatalf("Parsing root certificate failed: %v", err) + } + + pemBlock, err := pemutil.Serialize(cert) + if err != nil { + t.Fatalf("Failed serializing certificate: %v", err) + } + pemData := pem.EncodeToMemory(pemBlock) + + t.Run("ok", func(t *testing.T) { + cert, err := pemDecodeCertificate(pemData) + if err != nil { + t.Fatalf("Failed decoding PEM data: %v", err) + } + if cert == nil { + t.Errorf("Expected a certificate in PEM data") + } + }) + + t.Run("fail/no-pem-data", func(t *testing.T) { + cert, err := pemDecodeCertificate(nil) + if err == nil { + t.Fatalf("Expected pemDecodeCertificate to return an error") + } + if cert != nil { + t.Errorf("Expected pemDecodeCertificate to return nil") + } + }) + + t.Run("fail/multiple", func(t *testing.T) { + multiplePEMData := append(pemData, pemData...) + cert, err := pemDecodeCertificate(multiplePEMData) + if err == nil { + t.Fatalf("Expected pemDecodeCertificate to return an error") + } + if cert != nil { + t.Errorf("Expected pemDecodeCertificate to return nil") + } + }) + + t.Run("fail/no-pem-certificate", func(t *testing.T) { + pkData := pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: []byte("some-bogus-private-key"), + }) + cert, err := pemDecodeCertificate(pkData) + if err == nil { + t.Fatalf("Expected pemDecodeCertificate to return an error") + } + if cert != nil { + t.Errorf("Expected pemDecodeCertificate to return nil") + } + }) +} + +func Test_pemDecodeCertificateChain(t *testing.T) { + signer, err := keyutil.GenerateDefaultSigner() + if err != nil { + t.Fatalf("Failed creating signer: %v", err) + } + + tmpl := &x509.Certificate{ + Subject: pkix.Name{CommonName: "test-cert"}, + IsCA: true, + MaxPathLen: 3, + } + derBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, signer.Public(), signer) + if err != nil { + t.Fatalf("Creating root certificate failed: %v", err) + } + cert, err := x509.ParseCertificate(derBytes) + if err != nil { + t.Fatalf("Parsing root certificate failed: %v", err) + } + + pemBlock, err := pemutil.Serialize(cert) + if err != nil { + t.Fatalf("Failed serializing certificate: %v", err) + } + pemData := pem.EncodeToMemory(pemBlock) + + t.Run("ok/single", func(t *testing.T) { + certs, err := pemDecodeCertificateChain(pemData) + if err != nil { + t.Fatalf("Failed decoding PEM data: %v", err) + } + if len(certs) != 1 { + t.Errorf("Expected 1 certificate in PEM data; got %d", len(certs)) + } + }) + + t.Run("ok/multiple", func(t *testing.T) { + multiplePEMData := append(pemData, pemData...) + certs, err := pemDecodeCertificateChain(multiplePEMData) + if err != nil { + t.Fatalf("Failed decoding PEM data: %v", err) + } + if len(certs) != 2 { + t.Errorf("Expected 2 certificates in PEM data; got %d", len(certs)) + } + }) + + t.Run("fail/no-pem-certificate", func(t *testing.T) { + pkData := pem.EncodeToMemory(&pem.Block{ + Type: "PRIVATE KEY", + Bytes: []byte("some-bogus-private-key"), + }) + certs, err := pemDecodeCertificateChain(pkData) + if err == nil { + t.Fatalf("Expected pemDecodeCertificateChain to return an error") + } + if len(certs) != 0 { + t.Errorf("Expected 0 certificates in PEM data; got %d", len(certs)) + } + }) + + t.Run("fail/no-der-certificate", func(t *testing.T) { + certs, err := pemDecodeCertificateChain([]byte("invalid-der-data")) + if err == nil { + t.Fatalf("Expected pemDecodeCertificateChain to return an error") + } + if len(certs) != 0 { + t.Errorf("Expected 0 certificates in PEM data; got %d", len(certs)) + } + }) +} diff --git a/modules/caddypki/maintain.go b/modules/caddypki/maintain.go index 31e453ff9..31e4c6a8b 100644 --- a/modules/caddypki/maintain.go +++ b/modules/caddypki/maintain.go @@ -24,20 +24,24 @@ import ( "go.uber.org/zap" ) -func (p *PKI) maintenance() { +func (p *PKI) maintenanceForCA(ca *CA) { defer func() { if err := recover(); err != nil { - log.Printf("[PANIC] PKI maintenance: %v\n%s", err, debug.Stack()) + log.Printf("[PANIC] PKI maintenance for CA %s: %v\n%s", ca.ID, err, debug.Stack()) } }() - ticker := time.NewTicker(10 * time.Minute) // TODO: make configurable + interval := time.Duration(ca.MaintenanceInterval) + if interval <= 0 { + interval = defaultMaintenanceInterval + } + ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ticker.C: - p.renewCerts() + _ = p.renewCertsForCA(ca) case <-p.ctx.Done(): return } @@ -63,19 +67,19 @@ func (p *PKI) renewCertsForCA(ca *CA) error { // only maintain the root if it's not manually provided in the config if ca.Root == nil { - if needsRenewal(ca.root) { + if ca.needsRenewal(ca.root) { // TODO: implement root renewal (use same key) log.Warn("root certificate expiring soon (FIXME: ROOT RENEWAL NOT YET IMPLEMENTED)", - zap.Duration("time_remaining", time.Until(ca.inter.NotAfter)), + zap.Duration("time_remaining", time.Until(ca.interChain[0].NotAfter)), ) } } // only maintain the intermediate if it's not manually provided in the config if ca.Intermediate == nil { - if needsRenewal(ca.inter) { + if ca.needsRenewal(ca.interChain[0]) { log.Info("intermediate expires soon; renewing", - zap.Duration("time_remaining", time.Until(ca.inter.NotAfter)), + zap.Duration("time_remaining", time.Until(ca.interChain[0].NotAfter)), ) rootCert, rootKey, err := ca.loadOrGenRoot() @@ -86,10 +90,10 @@ func (p *PKI) renewCertsForCA(ca *CA) error { if err != nil { return fmt.Errorf("generating new certificate: %v", err) } - ca.inter, ca.interKey = interCert, interKey + ca.interChain, ca.interKey = []*x509.Certificate{interCert}, interKey log.Info("renewed intermediate", - zap.Time("new_expiration", ca.inter.NotAfter), + zap.Time("new_expiration", ca.interChain[0].NotAfter), ) } } @@ -97,11 +101,15 @@ func (p *PKI) renewCertsForCA(ca *CA) error { return nil } -func needsRenewal(cert *x509.Certificate) bool { +// needsRenewal reports whether the certificate is within its renewal window +// (i.e. the fraction of lifetime remaining is less than or equal to RenewalWindowRatio). +func (ca *CA) needsRenewal(cert *x509.Certificate) bool { + ratio := ca.RenewalWindowRatio + if ratio <= 0 { + ratio = defaultRenewalWindowRatio + } lifetime := cert.NotAfter.Sub(cert.NotBefore) - renewalWindow := time.Duration(float64(lifetime) * renewalWindowRatio) + renewalWindow := time.Duration(float64(lifetime) * ratio) renewalWindowStart := cert.NotAfter.Add(-renewalWindow) return time.Now().After(renewalWindowStart) } - -const renewalWindowRatio = 0.2 // TODO: make configurable diff --git a/modules/caddypki/maintain_test.go b/modules/caddypki/maintain_test.go new file mode 100644 index 000000000..d20d1d8a5 --- /dev/null +++ b/modules/caddypki/maintain_test.go @@ -0,0 +1,86 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package caddypki + +import ( + "crypto/x509" + "testing" + "time" +) + +func TestCA_needsRenewal(t *testing.T) { + now := time.Now() + + // cert with 100 days lifetime; last 20% = 20 days before expiry + // So renewal window starts at (NotAfter - 20 days) + makeCert := func(daysUntilExpiry int, lifetimeDays int) *x509.Certificate { + notAfter := now.AddDate(0, 0, daysUntilExpiry) + notBefore := notAfter.AddDate(0, 0, -lifetimeDays) + return &x509.Certificate{NotBefore: notBefore, NotAfter: notAfter} + } + + tests := []struct { + name string + ca *CA + cert *x509.Certificate + expect bool + }{ + { + name: "inside renewal window with ratio 0.2", + ca: &CA{RenewalWindowRatio: 0.2}, + cert: makeCert(10, 100), + expect: true, + }, + { + name: "outside renewal window with ratio 0.2", + ca: &CA{RenewalWindowRatio: 0.2}, + cert: makeCert(50, 100), + expect: false, + }, + { + name: "outside renewal window with 21 days left", + ca: &CA{RenewalWindowRatio: 0.2}, + cert: makeCert(21, 100), + expect: false, + }, + { + name: "just inside renewal window with ratio 0.5", + ca: &CA{RenewalWindowRatio: 0.5}, + cert: makeCert(30, 100), + expect: true, + }, + { + name: "zero ratio uses default", + ca: &CA{RenewalWindowRatio: 0}, + cert: makeCert(10, 100), + expect: true, + }, + { + name: "invalid ratio uses default", + ca: &CA{RenewalWindowRatio: 1.5}, + cert: makeCert(10, 100), + expect: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.ca.needsRenewal(tt.cert) + if got != tt.expect { + t.Errorf("needsRenewal() = %v, want %v", got, tt.expect) + } + }) + } +} diff --git a/modules/caddypki/pki.go b/modules/caddypki/pki.go index 9f974a956..557df74fc 100644 --- a/modules/caddypki/pki.go +++ b/modules/caddypki/pki.go @@ -109,8 +109,10 @@ func (p *PKI) Start() error { // see if root/intermediates need renewal... p.renewCerts() - // ...and keep them renewed - go p.maintenance() + // ...and keep them renewed (one goroutine per CA with its own interval) + for _, ca := range p.CAs { + go p.maintenanceForCA(ca) + } return nil } diff --git a/modules/caddytls/acmeissuer.go b/modules/caddytls/acmeissuer.go index 7f13fd71f..f254f7b2b 100644 --- a/modules/caddytls/acmeissuer.go +++ b/modules/caddytls/acmeissuer.go @@ -178,6 +178,7 @@ func (iss *ACMEIssuer) Provision(ctx caddy.Context) error { PropagationTimeout: time.Duration(iss.Challenges.DNS.PropagationTimeout), Resolvers: iss.Challenges.DNS.Resolvers, OverrideDomain: iss.Challenges.DNS.OverrideDomain, + Logger: iss.logger.Named("dns_manager"), }, } } @@ -336,7 +337,7 @@ func (iss *ACMEIssuer) generateZeroSSLEABCredentials(ctx context.Context, acct a req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", certmagic.UserAgent) - resp, err := http.DefaultClient.Do(req) + resp, err := http.DefaultClient.Do(req) //nolint:gosec // no SSRF since URL is from trusted config if err != nil { return nil, acct, fmt.Errorf("performing EAB credentials request: %v", err) } @@ -671,7 +672,7 @@ func ParseCaddyfilePreferredChainsOptions(d *caddyfile.Dispenser) (*ChainPrefere switch d.Val() { case "root_common_name": rootCommonNameOpt := d.RemainingArgs() - chainPref.RootCommonName = rootCommonNameOpt + chainPref.RootCommonName = append(chainPref.RootCommonName, rootCommonNameOpt...) if rootCommonNameOpt == nil { return nil, d.ArgErr() } @@ -681,7 +682,7 @@ func ParseCaddyfilePreferredChainsOptions(d *caddyfile.Dispenser) (*ChainPrefere case "any_common_name": anyCommonNameOpt := d.RemainingArgs() - chainPref.AnyCommonName = anyCommonNameOpt + chainPref.AnyCommonName = append(chainPref.AnyCommonName, anyCommonNameOpt...) if anyCommonNameOpt == nil { return nil, d.ArgErr() } diff --git a/modules/caddytls/automation.go b/modules/caddytls/automation.go index e69b5ad2f..74125a222 100644 --- a/modules/caddytls/automation.go +++ b/modules/caddytls/automation.go @@ -243,22 +243,49 @@ func (ap *AutomationPolicy) Provision(tlsApp *TLS) error { } } + // build certmagic.Config and attach it to the policy + storage := ap.storage + if storage == nil { + storage = tlsApp.ctx.Storage() + } + cfg, err := ap.makeCertMagicConfig(tlsApp, issuers, storage) + if err != nil { + return err + } + certCacheMu.RLock() + ap.magic = certmagic.New(certCache, cfg) + certCacheMu.RUnlock() + + // give issuers a chance to see the config pointer + for _, issuer := range ap.magic.Issuers { + if annoying, ok := issuer.(ConfigSetter); ok { + annoying.SetConfig(ap.magic) + } + } + + return nil +} + +// makeCertMagicConfig constructs a certmagic.Config for this policy using the +// provided issuers and storage. It encapsulates common logic shared between +// Provision and RebuildCertMagic so we don't duplicate code. +func (ap *AutomationPolicy) makeCertMagicConfig(tlsApp *TLS, issuers []certmagic.Issuer, storage certmagic.Storage) (certmagic.Config, error) { + // key source keyType := ap.KeyType if keyType != "" { var err error keyType, err = caddy.NewReplacer().ReplaceOrErr(ap.KeyType, true, true) if err != nil { - return fmt.Errorf("invalid key type %s: %s", ap.KeyType, err) + return certmagic.Config{}, fmt.Errorf("invalid key type %s: %s", ap.KeyType, err) } if _, ok := supportedCertKeyTypes[keyType]; !ok { - return fmt.Errorf("unrecognized key type: %s", keyType) + return certmagic.Config{}, fmt.Errorf("unrecognized key type: %s", keyType) } } keySource := certmagic.StandardKeyGenerator{ KeyType: supportedCertKeyTypes[keyType], } - storage := ap.storage if storage == nil { storage = tlsApp.ctx.Storage() } @@ -277,7 +304,7 @@ func (ap *AutomationPolicy) Provision(tlsApp *TLS) error { if noProtections { if !ap.hadExplicitManagers { // no managers, no explicitly-configured permission module, this is a config error - return fmt.Errorf("on-demand TLS cannot be enabled without a permission module to prevent abuse; please refer to documentation for details") + return certmagic.Config{}, fmt.Errorf("on-demand TLS cannot be enabled without a permission module to prevent abuse; please refer to documentation for details") } // allow on-demand to be enabled but only for the purpose of the Managers; issuance won't be allowed from Issuers tlsApp.logger.Warn("on-demand TLS can only get certificates from the configured external manager(s) because no ask endpoint / permission module is specified") @@ -334,7 +361,7 @@ func (ap *AutomationPolicy) Provision(tlsApp *TLS) error { } } - template := certmagic.Config{ + cfg := certmagic.Config{ MustStaple: ap.MustStaple, RenewalWindowRatio: ap.RenewalWindowRatio, KeySource: keySource, @@ -349,8 +376,31 @@ func (ap *AutomationPolicy) Provision(tlsApp *TLS) error { Issuers: issuers, Logger: tlsApp.logger, } + + return cfg, nil +} + +// IsProvisioned reports whether the automation policy has been +// provisioned. A provisioned policy has an initialized CertMagic +// instance (i.e. ap.magic != nil). +func (ap *AutomationPolicy) IsProvisioned() bool { return ap.magic != nil } + +// RebuildCertMagic rebuilds the policy's CertMagic configuration from the +// policy's already-populated fields (Issuers, Managers, storage, etc.) and +// replaces the internal CertMagic instance. This is a lightweight +// alternative to calling Provision because it does not re-provision +// modules or re-run module Provision; instead, it constructs a new +// certmagic.Config and calls SetConfig on issuers so they receive updated +// templates (for example, alternate HTTP/TLS ports supplied by the HTTP +// app). RebuildCertMagic should only be called when the policy's required +// fields are already populated. +func (ap *AutomationPolicy) RebuildCertMagic(tlsApp *TLS) error { + cfg, err := ap.makeCertMagicConfig(tlsApp, ap.Issuers, ap.storage) + if err != nil { + return err + } certCacheMu.RLock() - ap.magic = certmagic.New(certCache, template) + ap.magic = certmagic.New(certCache, cfg) certCacheMu.RUnlock() // sometimes issuers may need the parent certmagic.Config in diff --git a/modules/caddytls/capools.go b/modules/caddytls/capools.go index c73bc4832..97ce6af2b 100644 --- a/modules/caddytls/capools.go +++ b/modules/caddytls/capools.go @@ -257,7 +257,7 @@ func (PKIIntermediateCAPool) CaddyModule() caddy.ModuleInfo { } } -// Loads the PKI app and load the intermediate certificates into the certificate pool +// Loads the PKI app and loads the intermediate certificates into the certificate pool func (p *PKIIntermediateCAPool) Provision(ctx caddy.Context) error { pkiApp, err := ctx.AppIfConfigured("pki") if err != nil { @@ -274,7 +274,9 @@ func (p *PKIIntermediateCAPool) Provision(ctx caddy.Context) error { caPool := x509.NewCertPool() for _, ca := range p.ca { - caPool.AddCert(ca.IntermediateCertificate()) + for _, c := range ca.IntermediateCertificateChain() { + caPool.AddCert(c) + } } p.pool = caPool return nil @@ -500,8 +502,8 @@ func (t *TLSConfig) unmarshalCaddyfile(d *caddyfile.Dispenser) error { // If there is no custom TLS configuration, a nil config may be returned. // copied from with minor modifications: modules/caddyhttp/reverseproxy/httptransport.go func (t *TLSConfig) makeTLSClientConfig(ctx caddy.Context) (*tls.Config, error) { - repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer) - if repl == nil { + repl, ok := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer) + if !ok || repl == nil { repl = caddy.NewReplacer() } cfg := new(tls.Config) @@ -586,7 +588,7 @@ func (hcp *HTTPCertPool) Provision(ctx caddy.Context) error { if err != nil { return err } - res, err := httpClient.Do(req) + res, err := httpClient.Do(req) //nolint:gosec // SSRF false positive... uri comes from config if err != nil { return err } diff --git a/modules/caddytls/certmanagers.go b/modules/caddytls/certmanagers.go index 0a9d459df..68014635e 100644 --- a/modules/caddytls/certmanagers.go +++ b/modules/caddytls/certmanagers.go @@ -155,7 +155,7 @@ func (hcg HTTPCertGetter) GetCertificate(ctx context.Context, hello *tls.ClientH return nil, err } - resp, err := http.DefaultClient.Do(req) + resp, err := http.DefaultClient.Do(req) //nolint:gosec // SSRF false positive... request URI comes from config if err != nil { return nil, err } diff --git a/modules/caddytls/connpolicy.go b/modules/caddytls/connpolicy.go index 724271a8e..c9258da48 100644 --- a/modules/caddytls/connpolicy.go +++ b/modules/caddytls/connpolicy.go @@ -168,21 +168,11 @@ func (cp ConnectionPolicies) TLSConfig(ctx caddy.Context) *tls.Config { tlsApp.RegisterServerNames(echNames) } - // TODO: Ideally, ECH keys should be rotated. However, as of Go 1.24, the std lib implementation - // does not support safely modifying the tls.Config's EncryptedClientHelloKeys field. - // So, we implement static ECH keys temporarily. See https://github.com/golang/go/issues/71920. - // Revisit this after Go 1.25 is released and implement key rotation. - var stdECHKeys []tls.EncryptedClientHelloKey - for _, echConfigs := range tlsApp.EncryptedClientHello.configs { - for _, c := range echConfigs { - stdECHKeys = append(stdECHKeys, tls.EncryptedClientHelloKey{ - Config: c.configBin, - PrivateKey: c.privKeyBin, - SendAsRetry: c.sendAsRetry, - }) - } + tlsCfg.GetEncryptedClientHelloKeys = func(chi *tls.ClientHelloInfo) ([]tls.EncryptedClientHelloKey, error) { + tlsApp.EncryptedClientHello.configsMu.RLock() + defer tlsApp.EncryptedClientHello.configsMu.RUnlock() + return tlsApp.EncryptedClientHello.stdlibReady, nil } - tlsCfg.EncryptedClientHelloKeys = stdECHKeys } } @@ -794,7 +784,7 @@ func (clientauth *ClientAuthentication) provision(ctx caddy.Context) error { for _, fpath := range clientauth.TrustedCACertPEMFiles { ders, err := convertPEMFilesToDER(fpath) if err != nil { - return nil + return err } clientauth.TrustedCACerts = append(clientauth.TrustedCACerts, ders...) } @@ -807,7 +797,7 @@ func (clientauth *ClientAuthentication) provision(ctx caddy.Context) error { } err := caPool.Provision(ctx) if err != nil { - return nil + return err } clientauth.ca = caPool } @@ -895,24 +885,29 @@ func (clientauth *ClientAuthentication) ConfigureTLSConfig(cfg *tls.Config) erro // if a custom verification function already exists, wrap it clientauth.existingVerifyPeerCert = cfg.VerifyPeerCertificate - cfg.VerifyPeerCertificate = clientauth.verifyPeerCertificate + cfg.VerifyConnection = clientauth.verifyConnection return nil } -// verifyPeerCertificate is for use as a tls.Config.VerifyPeerCertificate -// callback to do custom client certificate verification. It is intended -// for installation only by clientauth.ConfigureTLSConfig(). -func (clientauth *ClientAuthentication) verifyPeerCertificate(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { +// verifyConnection is for use as a tls.Config.VerifyConnection callback +// to do custom client certificate verification. It is intended for +// installation only by clientauth.ConfigureTLSConfig(). +// +// Unlike VerifyPeerCertificate, VerifyConnection is called on every +// connection including resumed sessions, preventing session-resumption bypass. +func (clientauth *ClientAuthentication) verifyConnection(cs tls.ConnectionState) error { // first use any pre-existing custom verification function if clientauth.existingVerifyPeerCert != nil { - err := clientauth.existingVerifyPeerCert(rawCerts, verifiedChains) - if err != nil { + rawCerts := make([][]byte, len(cs.PeerCertificates)) + for i, cert := range cs.PeerCertificates { + rawCerts[i] = cert.Raw + } + if err := clientauth.existingVerifyPeerCert(rawCerts, cs.VerifiedChains); err != nil { return err } } for _, verifier := range clientauth.verifiers { - err := verifier.VerifyClientCertificate(rawCerts, verifiedChains) - if err != nil { + if err := verifier.VerifyClientCertificate(nil, cs.VerifiedChains); err != nil { return err } } diff --git a/modules/caddytls/ech.go b/modules/caddytls/ech.go index 1b3bacbd2..b915fcfbe 100644 --- a/modules/caddytls/ech.go +++ b/modules/caddytls/ech.go @@ -2,6 +2,7 @@ package caddytls import ( "context" + "crypto/tls" "encoding/base64" "encoding/json" "errors" @@ -11,6 +12,7 @@ import ( "path" "strconv" "strings" + "sync" "time" "github.com/caddyserver/certmagic" @@ -48,12 +50,6 @@ func init() { // applied will automatically upgrade the minimum TLS version to 1.3, even if // configured to a lower version. // -// Note that, as of Caddy 2.10.0 (~March 2025), ECH keys are not automatically -// rotated due to a limitation in the Go standard library (see -// https://github.com/golang/go/issues/71920). This should be resolved when -// Go 1.25 is released (~Aug. 2025), and Caddy will be updated to automatically -// rotate ECH keys/configs at that point. -// // EXPERIMENTAL: Subject to change. type ECH struct { // The list of ECH configurations for which to automatically generate @@ -73,14 +69,17 @@ type ECH struct { // DNS RRs. (This also typically requires that they use DoH or DoT.) Publication []*ECHPublication `json:"publication,omitempty"` - // map of public_name to list of configs - configs map[string][]echConfig + configsMu *sync.RWMutex // protects both configs and the list of configs/keys the standard library uses + configs map[string][]echConfig // map of public_name to list of configs + stdlibReady []tls.EncryptedClientHelloKey // ECH configs+keys in a format the standard library can use } // Provision loads or creates ECH configs and returns outer names (for certificate // management), but does not publish any ECH configs. The DNS module is used as // a default for later publishing if needed. func (ech *ECH) Provision(ctx caddy.Context) ([]string, error) { + ech.configsMu = new(sync.RWMutex) + logger := ctx.Logger().Named("ech") // set up publication modules before we need to obtain a lock in storage, @@ -98,17 +97,60 @@ func (ech *ECH) Provision(ctx caddy.Context) ([]string, error) { // the rest of provisioning needs an exclusive lock so that instances aren't // stepping on each other when setting up ECH configs storage := ctx.Storage() - const echLockName = "ech_provision" - if err := storage.Lock(ctx, echLockName); err != nil { + if err := storage.Lock(ctx, echStorageLockName); err != nil { return nil, err } defer func() { - if err := storage.Unlock(ctx, echLockName); err != nil { + if err := storage.Unlock(ctx, echStorageLockName); err != nil { logger.Error("unable to unlock ECH provisioning in storage", zap.Error(err)) } }() - var outerNames []string //nolint:prealloc // (FALSE POSITIVE - see https://github.com/alexkohler/prealloc/issues/30) + ech.configsMu.Lock() + defer ech.configsMu.Unlock() + + outerNames, err := ech.setConfigsFromStorage(ctx, logger) + if err != nil { + return nil, fmt.Errorf("loading configs from storage: %w", err) + } + + // see if we need to make any new ones based on the input configuration + for _, cfg := range ech.Configs { + publicName := strings.ToLower(strings.TrimSpace(cfg.PublicName)) + + if list, ok := ech.configs[publicName]; !ok || len(list) == 0 { + // no config with this public name was loaded, so create one + echCfg, err := generateAndStoreECHConfig(ctx, publicName) + if err != nil { + return nil, err + } + logger.Debug("generated new ECH config", + zap.String("public_name", echCfg.RawPublicName), + zap.Uint8("id", echCfg.ConfigID)) + ech.configs[publicName] = append(ech.configs[publicName], echCfg) + outerNames = append(outerNames, publicName) + } + } + + // convert the configs into a structure ready for the std lib to use + ech.updateKeyList() + + // ensure any old keys are rotated out + if err = ech.rotateECHKeys(ctx, logger, true); err != nil { + return nil, fmt.Errorf("rotating ECH configs: %w", err) + } + + return outerNames, nil +} + +// setConfigsFromStorage sets the ECH configs in memory to those in storage. +// It must be called in a write lock on ech.configsMu. +func (ech *ECH) setConfigsFromStorage(ctx caddy.Context, logger *zap.Logger) ([]string, error) { + storage := ctx.Storage() + + ech.configs = make(map[string][]echConfig) + + var outerNames []string // start by loading all the existing configs (even the older ones on the way out, // since some clients may still be using them if they haven't yet picked up on the @@ -131,48 +173,145 @@ func (ech *ECH) Provision(ctx caddy.Context) ([]string, error) { logger.Debug("loaded ECH config", zap.String("public_name", cfg.RawPublicName), zap.Uint8("id", cfg.ConfigID)) - ech.configs[cfg.RawPublicName] = append(ech.configs[cfg.RawPublicName], cfg) - outerNames = append(outerNames, cfg.RawPublicName) - } - - // all existing configs are now loaded; see if we need to make any new ones - // based on the input configuration, and also mark the most recent one(s) as - // current/active, so they can be used for ECH retries - for _, cfg := range ech.Configs { - publicName := strings.ToLower(strings.TrimSpace(cfg.PublicName)) - - if list, ok := ech.configs[publicName]; ok && len(list) > 0 { - // at least one config with this public name was loaded, so find the - // most recent one and mark it as active to be used with retries - var mostRecentDate time.Time - var mostRecentIdx int - for i, c := range list { - if mostRecentDate.IsZero() || c.meta.Created.After(mostRecentDate) { - mostRecentDate = c.meta.Created - mostRecentIdx = i - } - } - list[mostRecentIdx].sendAsRetry = true - } else { - // no config with this public name was loaded, so create one - echCfg, err := generateAndStoreECHConfig(ctx, publicName) - if err != nil { - return nil, err - } - logger.Debug("generated new ECH config", - zap.String("public_name", echCfg.RawPublicName), - zap.Uint8("id", echCfg.ConfigID)) - ech.configs[publicName] = append(ech.configs[publicName], echCfg) - outerNames = append(outerNames, publicName) + if _, seen := ech.configs[cfg.RawPublicName]; !seen { + outerNames = append(outerNames, cfg.RawPublicName) } + ech.configs[cfg.RawPublicName] = append(ech.configs[cfg.RawPublicName], cfg) } return outerNames, nil } -func (t *TLS) publishECHConfigs() error { - logger := t.logger.Named("ech") +// rotateECHKeys updates the ECH keys/configs that are outdated if rotation is needed. +// It should be called in a write lock on ech.configsMu. If a lock is already obtained +// in storage, then pass true for storageSynced. +// +// This function sets/updates the stdlib-ready key list only if a rotation occurs. +func (ech *ECH) rotateECHKeys(ctx caddy.Context, logger *zap.Logger, storageSynced bool) error { + storage := ctx.Storage() + // all existing configs are now loaded; rotate keys "regularly" as recommended by the spec + // (also: "Rotating too frequently limits the client anonymity set." - but the more server + // names, the more frequently rotation can be done safely) + const ( + rotationInterval = 24 * time.Hour * 30 + deleteAfter = 24 * time.Hour * 90 + ) + + if !ech.rotationNeeded(rotationInterval, deleteAfter) { + return nil + } + + // sync this operation across cluster if not already + if !storageSynced { + if err := storage.Lock(ctx, echStorageLockName); err != nil { + return err + } + defer func() { + if err := storage.Unlock(ctx, echStorageLockName); err != nil { + logger.Error("unable to unlock ECH rotation in storage", zap.Error(err)) + } + }() + } + + // update what storage has, in case another instance already updated things + if _, err := ech.setConfigsFromStorage(ctx, logger); err != nil { + return fmt.Errorf("updating ECH keys from storage: %v", err) + } + + // iterate the updated list and do any updates as needed + for publicName := range ech.configs { + for i := 0; i < len(ech.configs[publicName]); i++ { + cfg := ech.configs[publicName][i] + if time.Since(cfg.meta.Created) >= rotationInterval && cfg.meta.Replaced.IsZero() { + // key is due for rotation and it hasn't been replaced yet; do that now + logger.Debug("ECH config is due for rotation", + zap.String("public_name", cfg.RawPublicName), + zap.Uint8("id", cfg.ConfigID), + zap.Time("created", cfg.meta.Created), + zap.Duration("age", time.Since(cfg.meta.Created)), + zap.Duration("rotation_interval", rotationInterval)) + + // start by generating and storing the replacement ECH config + newCfg, err := generateAndStoreECHConfig(ctx, publicName) + if err != nil { + return fmt.Errorf("generating and storing new replacement ECH config: %w", err) + } + + // mark the key as replaced so we don't rotate it again, and instead delete it later + ech.configs[publicName][i].meta.Replaced = time.Now() + + // persist the updated metadata + metaBytes, err := json.Marshal(ech.configs[publicName][i].meta) + if err != nil { + return fmt.Errorf("marshaling updated ECH config metadata: %v", err) + } + if err := storage.Store(ctx, echMetaKey(cfg.ConfigID), metaBytes); err != nil { + return fmt.Errorf("storing updated ECH config metadata: %v", err) + } + + ech.configs[publicName] = append(ech.configs[publicName], newCfg) + + logger.Debug("rotated ECH key", + zap.String("public_name", cfg.RawPublicName), + zap.Uint8("old_id", cfg.ConfigID), + zap.Uint8("new_id", newCfg.ConfigID)) + } else if time.Since(cfg.meta.Created) >= deleteAfter && !cfg.meta.Replaced.IsZero() { + // key has expired and is no longer supported; delete it from storage and memory + cfgIDKey := path.Join(echConfigsKey, strconv.Itoa(int(cfg.ConfigID))) + if err := storage.Delete(ctx, cfgIDKey); err != nil { + return fmt.Errorf("deleting expired ECH config: %v", err) + } + + ech.configs[publicName] = append(ech.configs[publicName][:i], ech.configs[publicName][i+1:]...) + i-- + + logger.Debug("deleted expired ECH key", + zap.String("public_name", cfg.RawPublicName), + zap.Uint8("id", cfg.ConfigID), + zap.Duration("age", time.Since(cfg.meta.Created))) + } + } + } + + ech.updateKeyList() + + return nil +} + +// rotationNeeded returns true if any ECH key needs to be replaced, or deleted. +// It must be called inside a read or write lock of ech.configsMu (probably a +// write lock, so that the rotation can occur correctly in the same lock).) +func (ech *ECH) rotationNeeded(rotationInterval, deleteAfter time.Duration) bool { + for publicName := range ech.configs { + for i := 0; i < len(ech.configs[publicName]); i++ { + cfg := ech.configs[publicName][i] + if (time.Since(cfg.meta.Created) >= rotationInterval && cfg.meta.Replaced.IsZero()) || + (time.Since(cfg.meta.Created) >= deleteAfter && !cfg.meta.Replaced.IsZero()) { + return true + } + } + } + return false +} + +// updateKeyList updates the list of ECH keys the std lib uses to serve ECH. +// It must be called inside a write lock on ech.configsMu. +func (ech *ECH) updateKeyList() { + ech.stdlibReady = []tls.EncryptedClientHelloKey{} + for _, cfgs := range ech.configs { + for _, cfg := range cfgs { + ech.stdlibReady = append(ech.stdlibReady, tls.EncryptedClientHelloKey{ + Config: cfg.configBin, + PrivateKey: cfg.privKeyBin, + SendAsRetry: cfg.meta.Replaced.IsZero(), // only send during retries if key has not been rotated out + }) + } + } +} + +// publishECHConfigs publishes any configs that are configured for publication and which haven't been published already. +func (t *TLS) publishECHConfigs(logger *zap.Logger) error { // make publication exclusive, since we don't need to repeat this unnecessarily storage := t.ctx.Storage() const echLockName = "ech_publish" @@ -197,7 +336,7 @@ func (t *TLS) publishECHConfigs() error { publishers: []ECHPublisher{ &ECHDNSPublisher{ provider: dnsProv, - logger: t.logger, + logger: logger, }, }, }, @@ -209,6 +348,7 @@ func (t *TLS) publishECHConfigs() error { // publish with it, and figure out which inner names to publish // to/for, then publish for _, publication := range publicationList { + t.EncryptedClientHello.configsMu.RLock() // this publication is either configured for specific ECH configs, // or we just use an implied default of all ECH configs var echCfgList echConfigList @@ -231,6 +371,7 @@ func (t *TLS) publishECHConfigs() error { } } } + t.EncryptedClientHello.configsMu.RUnlock() // marshal the ECH config list as binary for publication echCfgListBin, err := echCfgList.MarshalBinary() @@ -250,6 +391,10 @@ func (t *TLS) publishECHConfigs() error { if publication.Domains == nil { serverNamesSet = make(map[string]struct{}, len(t.serverNames)) for name := range t.serverNames { + // skip Tailscale names, a special case we also handle differently in our auto-HTTPS + if strings.HasSuffix(name, ".ts.net") { + continue + } serverNamesSet[name] = struct{}{} } } else { @@ -304,7 +449,7 @@ func (t *TLS) publishECHConfigs() error { // at least a partial failure, maybe a complete failure, but we can // log each error by domain for innerName, domainErr := range publishErrs { - t.logger.Error("failed to publish ECH configuration list", + logger.Error("failed to publish ECH configuration list", zap.String("publisher", publisherKey), zap.String("domain", innerName), zap.Uint8s("config_ids", configIDs), @@ -312,7 +457,7 @@ func (t *TLS) publishECHConfigs() error { } } else if err != nil { // generic error; assume the entire thing failed, I guess - t.logger.Error("failed publishing ECH configuration list", + logger.Error("failed publishing ECH configuration list", zap.String("publisher", publisherKey), zap.Strings("domains", dnsNamesToPublish), zap.Uint8s("config_ids", configIDs), @@ -334,7 +479,7 @@ func (t *TLS) publishECHConfigs() error { successNames = append(successNames, name) } } - t.logger.Info("successfully published ECH configuration list for "+someAll+" domains", + logger.Info("successfully published ECH configuration list for "+someAll+" domains", zap.String("publisher", publisherKey), zap.Strings("domains", successNames), zap.Uint8s("config_ids", configIDs)) @@ -353,13 +498,12 @@ func (t *TLS) publishECHConfigs() error { if err != nil { return fmt.Errorf("marshaling ECH config metadata: %v", err) } - metaKey := path.Join(echConfigsKey, strconv.Itoa(int(cfg.ConfigID)), "meta.json") - if err := t.ctx.Storage().Store(t.ctx, metaKey, metaBytes); err != nil { + if err := t.ctx.Storage().Store(t.ctx, echMetaKey(cfg.ConfigID), metaBytes); err != nil { return fmt.Errorf("storing updated ECH config metadata: %v", err) } } } else { - t.logger.Error("all domains failed to publish ECH configuration list (see earlier errors)", + logger.Error("all domains failed to publish ECH configuration list (see earlier errors)", zap.String("publisher", publisherKey), zap.Strings("domains", dnsNamesToPublish), zap.Uint8s("config_ids", configIDs)) @@ -489,7 +633,7 @@ func generateAndStoreECHConfig(ctx caddy.Context, publicName string) (echConfig, echCfg := echConfig{ PublicKey: publicKey, - Version: draftTLSESNI22, + Version: draftTLSESNI25, ConfigID: configID, RawPublicName: publicName, KEMID: kemChoice, @@ -507,7 +651,6 @@ func generateAndStoreECHConfig(ctx caddy.Context, publicName string) (echConfig, AEADID: hpke.AEAD_ChaCha20Poly1305, }, }, - sendAsRetry: true, } meta := echConfigMeta{ Created: time.Now(), @@ -786,10 +929,9 @@ type echConfig struct { // these fields are not part of the spec, but are here for // our use when setting up TLS servers or maintenance - configBin []byte - privKeyBin []byte - meta echConfigMeta - sendAsRetry bool + configBin []byte + privKeyBin []byte + meta echConfigMeta } func (echCfg echConfig) MarshalBinary() ([]byte, error) { @@ -811,8 +953,8 @@ func (echCfg *echConfig) UnmarshalBinary(data []byte) error { if !b.ReadUint16(&echCfg.Version) { return errInvalidLen } - if echCfg.Version != draftTLSESNI22 { - return fmt.Errorf("supported version must be %d: got %d", draftTLSESNI22, echCfg.Version) + if echCfg.Version != draftTLSESNI25 { + return fmt.Errorf("supported version must be %d: got %d", draftTLSESNI25, echCfg.Version) } if !b.ReadUint16LengthPrefixed(&content) || !b.Empty() { @@ -1022,19 +1164,27 @@ func (p PublishECHConfigListErrors) Error() string { type echConfigMeta struct { Created time.Time `json:"created"` + Replaced time.Time `json:"replaced,omitzero"` Publications publicationHistory `json:"publications"` } +func echMetaKey(configID uint8) string { + return path.Join(echConfigsKey, strconv.Itoa(int(configID)), "meta.json") +} + // publicationHistory is a map of publisher key to // map of inner name to timestamp type publicationHistory map[string]map[string]time.Time +// echStorageLockName is the name of the storage lock to sync ECH updates. +const echStorageLockName = "ech_rotation" + // The key prefix when putting ECH configs in storage. After this // comes the config ID. const echConfigsKey = "ech/configs" -// https://www.ietf.org/archive/id/draft-ietf-tls-esni-22.html -const draftTLSESNI22 = 0xfe0d +// https://www.ietf.org/archive/id/draft-ietf-tls-esni-25.html +const draftTLSESNI25 = 0xfe0d // Interface guard var _ ECHPublisher = (*ECHDNSPublisher)(nil) diff --git a/modules/caddytls/folderloader.go b/modules/caddytls/folderloader.go index 2df6f4cee..b86d3b6ae 100644 --- a/modules/caddytls/folderloader.go +++ b/modules/caddytls/folderloader.go @@ -19,6 +19,7 @@ import ( "crypto/tls" "encoding/pem" "fmt" + "io/fs" "os" "path/filepath" "strings" @@ -62,18 +63,27 @@ func (fl FolderLoader) Provision(ctx caddy.Context) error { func (fl FolderLoader) LoadCertificates() ([]Certificate, error) { var certs []Certificate for _, dir := range fl { - err := filepath.Walk(dir, func(fpath string, info os.FileInfo, err error) error { + root, err := os.OpenRoot(dir) + if err != nil { + return nil, fmt.Errorf("unable to open root directory %s: %w", dir, err) + } + err = filepath.WalkDir(dir, func(fpath string, d fs.DirEntry, err error) error { if err != nil { return fmt.Errorf("unable to traverse into path: %s", fpath) } - if info.IsDir() { + if d.IsDir() { return nil } - if !strings.HasSuffix(strings.ToLower(info.Name()), ".pem") { + if !strings.HasSuffix(strings.ToLower(d.Name()), ".pem") { return nil } - bundle, err := os.ReadFile(fpath) + rel, err := filepath.Rel(dir, fpath) + if err != nil { + return fmt.Errorf("unable to get relative path for %s: %w", fpath, err) + } + + bundle, err := root.ReadFile(rel) if err != nil { return err } @@ -83,11 +93,11 @@ func (fl FolderLoader) LoadCertificates() ([]Certificate, error) { } certs = append(certs, Certificate{Certificate: cert}) - return nil }) + _ = root.Close() if err != nil { - return nil, err + return nil, fmt.Errorf("walking certificates directory %s: %w", dir, err) } } return certs, nil diff --git a/modules/caddytls/internalissuer.go b/modules/caddytls/internalissuer.go index be779757a..cad19f063 100644 --- a/modules/caddytls/internalissuer.go +++ b/modules/caddytls/internalissuer.go @@ -115,7 +115,8 @@ func (iss InternalIssuer) Issue(ctx context.Context, csr *x509.CertificateReques if iss.SignWithRoot { issuerCert = iss.ca.RootCertificate() } else { - issuerCert = iss.ca.IntermediateCertificate() + chain := iss.ca.IntermediateCertificateChain() + issuerCert = chain[0] } // ensure issued certificate does not expire later than its issuer diff --git a/modules/caddytls/internalissuer_test.go b/modules/caddytls/internalissuer_test.go new file mode 100644 index 000000000..d39d8373b --- /dev/null +++ b/modules/caddytls/internalissuer_test.go @@ -0,0 +1,262 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package caddytls + +import ( + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/modules/caddypki" + "go.uber.org/zap" + + "go.step.sm/crypto/keyutil" + "go.step.sm/crypto/pemutil" +) + +func TestInternalIssuer_Issue(t *testing.T) { + rootSigner, err := keyutil.GenerateDefaultSigner() + if err != nil { + t.Fatalf("Creating root signer failed: %v", err) + } + + tmpl := &x509.Certificate{ + Subject: pkix.Name{CommonName: "test-root"}, + IsCA: true, + MaxPathLen: 3, + NotAfter: time.Now().Add(7 * 24 * time.Hour), + NotBefore: time.Now().Add(-7 * 24 * time.Hour), + } + rootBytes, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, rootSigner.Public(), rootSigner) + if err != nil { + t.Fatalf("Creating root certificate failed: %v", err) + } + + root, err := x509.ParseCertificate(rootBytes) + if err != nil { + t.Fatalf("Parsing root certificate failed: %v", err) + } + + firstIntermediateSigner, err := keyutil.GenerateDefaultSigner() + if err != nil { + t.Fatalf("Creating intermedaite signer failed: %v", err) + } + + firstIntermediateBytes, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + Subject: pkix.Name{CommonName: "test-first-intermediate"}, + IsCA: true, + MaxPathLen: 2, + NotAfter: time.Now().Add(24 * time.Hour), + NotBefore: time.Now().Add(-24 * time.Hour), + }, root, firstIntermediateSigner.Public(), rootSigner) + if err != nil { + t.Fatalf("Creating intermediate certificate failed: %v", err) + } + + firstIntermediate, err := x509.ParseCertificate(firstIntermediateBytes) + if err != nil { + t.Fatalf("Parsing intermediate certificate failed: %v", err) + } + + secondIntermediateSigner, err := keyutil.GenerateDefaultSigner() + if err != nil { + t.Fatalf("Creating second intermedaite signer failed: %v", err) + } + + secondIntermediateBytes, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{ + Subject: pkix.Name{CommonName: "test-second-intermediate"}, + IsCA: true, + MaxPathLen: 2, + NotAfter: time.Now().Add(24 * time.Hour), + NotBefore: time.Now().Add(-24 * time.Hour), + }, firstIntermediate, secondIntermediateSigner.Public(), firstIntermediateSigner) + if err != nil { + t.Fatalf("Creating second intermediate certificate failed: %v", err) + } + + secondIntermediate, err := x509.ParseCertificate(secondIntermediateBytes) + if err != nil { + t.Fatalf("Parsing second intermediate certificate failed: %v", err) + } + + dir := t.TempDir() + storageDir := filepath.Join(dir, "certmagic") + rootCertFile := filepath.Join(dir, "root.pem") + if _, err = pemutil.Serialize(root, pemutil.WithFilename(rootCertFile)); err != nil { + t.Fatalf("Failed serializing root certificate: %v", err) + } + intermediateCertFile := filepath.Join(dir, "intermediate.pem") + if _, err = pemutil.Serialize(firstIntermediate, pemutil.WithFilename(intermediateCertFile)); err != nil { + t.Fatalf("Failed serializing intermediate certificate: %v", err) + } + intermediateKeyFile := filepath.Join(dir, "intermediate.key") + if _, err = pemutil.Serialize(firstIntermediateSigner, pemutil.WithFilename(intermediateKeyFile)); err != nil { + t.Fatalf("Failed serializing intermediate key: %v", err) + } + + var intermediateChainContents []byte + intermediateChain := []*x509.Certificate{secondIntermediate, firstIntermediate} + for _, cert := range intermediateChain { + b, err := pemutil.Serialize(cert) + if err != nil { + t.Fatalf("Failed serializing intermediate certificate: %v", err) + } + intermediateChainContents = append(intermediateChainContents, pem.EncodeToMemory(b)...) + } + intermediateChainFile := filepath.Join(dir, "intermediates.pem") + if err := os.WriteFile(intermediateChainFile, intermediateChainContents, 0644); err != nil { + t.Fatalf("Failed writing intermediate chain: %v", err) + } + intermediateChainKeyFile := filepath.Join(dir, "intermediates.key") + if _, err = pemutil.Serialize(secondIntermediateSigner, pemutil.WithFilename(intermediateChainKeyFile)); err != nil { + t.Fatalf("Failed serializing intermediate key: %v", err) + } + + signer, err := keyutil.GenerateDefaultSigner() + if err != nil { + t.Fatalf("Failed creating signer: %v", err) + } + + csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + Subject: pkix.Name{CommonName: "test"}, + }, signer) + if err != nil { + t.Fatalf("Failed creating CSR: %v", err) + } + + csr, err := x509.ParseCertificateRequest(csrBytes) + if err != nil { + t.Fatalf("Failed parsing CSR: %v", err) + } + + t.Run("generated-with-defaults", func(t *testing.T) { + caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: t.Context()}) + t.Cleanup(cancel) + logger := zap.NewNop() + + ca := &caddypki.CA{ + StorageRaw: []byte(fmt.Sprintf(`{"module": "file_system", "root": %q}`, storageDir)), + } + if err := ca.Provision(caddyCtx, "local-test-generated", logger); err != nil { + t.Fatalf("Failed provisioning CA: %v", err) + } + + iss := InternalIssuer{ + SignWithRoot: false, + ca: ca, + logger: logger, + } + + c, err := iss.Issue(t.Context(), csr) + if err != nil { + t.Fatalf("Failed issuing certificate: %v", err) + } + + chain, err := pemutil.ParseCertificateBundle(c.Certificate) + if err != nil { + t.Errorf("Failed issuing certificate: %v", err) + } + if len(chain) != 2 { + t.Errorf("Expected 2 certificates in chain; got %d", len(chain)) + } + }) + + t.Run("single-intermediate-from-disk", func(t *testing.T) { + caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: t.Context()}) + t.Cleanup(cancel) + logger := zap.NewNop() + + ca := &caddypki.CA{ + Root: &caddypki.KeyPair{ + Certificate: rootCertFile, + }, + Intermediate: &caddypki.KeyPair{ + Certificate: intermediateCertFile, + PrivateKey: intermediateKeyFile, + }, + StorageRaw: []byte(fmt.Sprintf(`{"module": "file_system", "root": %q}`, storageDir)), + } + + if err := ca.Provision(caddyCtx, "local-test-single-intermediate", logger); err != nil { + t.Fatalf("Failed provisioning CA: %v", err) + } + + iss := InternalIssuer{ + ca: ca, + SignWithRoot: false, + logger: logger, + } + + c, err := iss.Issue(t.Context(), csr) + if err != nil { + t.Fatalf("Failed issuing certificate: %v", err) + } + + chain, err := pemutil.ParseCertificateBundle(c.Certificate) + if err != nil { + t.Errorf("Failed issuing certificate: %v", err) + } + if len(chain) != 2 { + t.Errorf("Expected 2 certificates in chain; got %d", len(chain)) + } + }) + + t.Run("multiple-intermediates-from-disk", func(t *testing.T) { + caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: t.Context()}) + t.Cleanup(cancel) + logger := zap.NewNop() + + ca := &caddypki.CA{ + Root: &caddypki.KeyPair{ + Certificate: rootCertFile, + }, + Intermediate: &caddypki.KeyPair{ + Certificate: intermediateChainFile, + PrivateKey: intermediateChainKeyFile, + }, + StorageRaw: []byte(fmt.Sprintf(`{"module": "file_system", "root": %q}`, storageDir)), + } + + if err := ca.Provision(caddyCtx, "local-test", zap.NewNop()); err != nil { + t.Fatalf("Failed provisioning CA: %v", err) + } + + iss := InternalIssuer{ + ca: ca, + SignWithRoot: false, + logger: logger, + } + + c, err := iss.Issue(t.Context(), csr) + if err != nil { + t.Fatalf("Failed issuing certificate: %v", err) + } + + chain, err := pemutil.ParseCertificateBundle(c.Certificate) + if err != nil { + t.Errorf("Failed issuing certificate: %v", err) + } + if len(chain) != 3 { + t.Errorf("Expected 3 certificates in chain; got %d", len(chain)) + } + }) +} diff --git a/modules/caddytls/leaffolderloader.go b/modules/caddytls/leaffolderloader.go index 20f5aa82c..fe5e9e244 100644 --- a/modules/caddytls/leaffolderloader.go +++ b/modules/caddytls/leaffolderloader.go @@ -29,9 +29,9 @@ func init() { caddy.RegisterModule(LeafFolderLoader{}) } -// LeafFolderLoader loads certificates and their associated keys from disk +// LeafFolderLoader loads certificates from disk // by recursively walking the specified directories, looking for PEM -// files which contain both a certificate and a key. +// files which contain a certificate. type LeafFolderLoader struct { Folders []string `json:"folders,omitempty"` } diff --git a/modules/caddytls/tls.go b/modules/caddytls/tls.go index 7b49c0208..34ffbf62d 100644 --- a/modules/caddytls/tls.go +++ b/modules/caddytls/tls.go @@ -123,8 +123,15 @@ type TLS struct { // // EXPERIMENTAL: Subject to change. DNSRaw json.RawMessage `json:"dns,omitempty" caddy:"namespace=dns.providers inline_key=name"` - dns any // technically, it should be any/all of the libdns interfaces (RecordSetter, RecordAppender, etc.) + // The default DNS resolvers to use for TLS-related DNS operations, specifically + // for ACME DNS challenges and ACME server DNS validations. + // If not specified, the system default resolvers will be used. + // + // EXPERIMENTAL: Subject to change. + Resolvers []string `json:"resolvers,omitempty"` + + dns any // technically, it should be any/all of the libdns interfaces (RecordSetter, RecordAppender, etc.) certificateLoaders []CertificateLoader automateNames map[string]struct{} ctx caddy.Context @@ -335,7 +342,6 @@ func (t *TLS) Provision(ctx caddy.Context) error { // ECH (Encrypted ClientHello) initialization if t.EncryptedClientHello != nil { - t.EncryptedClientHello.configs = make(map[string][]echConfig) outerNames, err := t.EncryptedClientHello.Provision(ctx) if err != nil { return fmt.Errorf("provisioning Encrypted ClientHello components: %v", err) @@ -411,12 +417,37 @@ func (t *TLS) Start() error { return fmt.Errorf("automate: managing %v: %v", t.automateNames, err) } - // publish ECH configs in the background; does not need to block - // server startup, as it could take a while if t.EncryptedClientHello != nil { + echLogger := t.logger.Named("ech") + + // publish ECH configs in the background; does not need to block + // server startup, as it could take a while; then keep keys rotated go func() { - if err := t.publishECHConfigs(); err != nil { - t.logger.Named("ech").Error("publication(s) failed", zap.Error(err)) + // publish immediately first + if err := t.publishECHConfigs(echLogger); err != nil { + echLogger.Error("publication(s) failed", zap.Error(err)) + } + + // then every so often, rotate and publish if needed + // (both of these functions only do something if needed) + for { + select { + case <-time.After(1 * time.Hour): + // ensure old keys are rotated out + t.EncryptedClientHello.configsMu.Lock() + err = t.EncryptedClientHello.rotateECHKeys(t.ctx, echLogger, false) + t.EncryptedClientHello.configsMu.Unlock() + if err != nil { + echLogger.Error("rotating ECH configs failed", zap.Error(err)) + return + } + err := t.publishECHConfigs(echLogger) + if err != nil { + echLogger.Error("publication(s) failed", zap.Error(err)) + } + case <-t.ctx.Done(): + return + } } }() } diff --git a/modules/caddytls/zerosslissuer.go b/modules/caddytls/zerosslissuer.go index b8727ab66..3421e816a 100644 --- a/modules/caddytls/zerosslissuer.go +++ b/modules/caddytls/zerosslissuer.go @@ -40,7 +40,7 @@ func init() { type ZeroSSLIssuer struct { // The API key (or "access key") for using the ZeroSSL API. // REQUIRED. - APIKey string `json:"api_key,omitempty"` + APIKey string `json:"api_key,omitempty"` //nolint:gosec // false positive... yes this is exported, for JSON interop // How many days the certificate should be valid for. // Only certain values are accepted; see ZeroSSL docs. diff --git a/modules/logging/filewriter.go b/modules/logging/filewriter.go index c3df562cb..0445ef06f 100644 --- a/modules/logging/filewriter.go +++ b/modules/logging/filewriter.go @@ -63,7 +63,7 @@ func (m *fileMode) UnmarshalJSON(b []byte) error { // MarshalJSON satisfies json.Marshaler. func (m *fileMode) MarshalJSON() ([]byte, error) { - return []byte(fmt.Sprintf("\"%04o\"", *m)), nil + return fmt.Appendf(nil, "\"%04o\"", *m), nil } // parseFileMode parses a file mode string, @@ -90,6 +90,15 @@ type FileWriter struct { // 0600 by default. Mode fileMode `json:"mode,omitempty"` + // DirMode controls permissions for any directories created to reach Filename. + // Default: 0700 (current behavior). + // + // Special values: + // - "inherit" → copy the nearest existing parent directory's perms (with r→x normalization) + // - "from_file" → derive from the file Mode (with r→x), e.g. 0644 → 0755, 0600 → 0700 + // Numeric octal strings (e.g. "0755") are also accepted. Subject to process umask. + DirMode string `json:"dir_mode,omitempty"` + // Roll toggles log rolling or rotation, which is // enabled by default. Roll *bool `json:"roll,omitempty"` @@ -113,9 +122,16 @@ type FileWriter struct { // See https://github.com/DeRuina/timberjack#%EF%B8%8F-rotation-notes--warnings for caveats RollAt []string `json:"roll_at,omitempty"` - // Whether to compress rolled files. Default: true + // Whether to compress rolled files. + // Default: true. + // Deprecated: Use RollCompression instead, setting it to "none". RollCompress *bool `json:"roll_gzip,omitempty"` + // RollCompression selects the compression algorithm for rolled files. + // Accepted values: "none", "gzip", "zstd". + // Default: gzip + RollCompression string `json:"roll_compression,omitempty"` + // Whether to use local timestamps in rolled filenames. // Default: false RollLocalTime bool `json:"roll_local_time,omitempty"` @@ -177,11 +193,33 @@ func (fw FileWriter) OpenWriter() (io.WriteCloser, error) { // roll log files as a sensible default to avoid disk space exhaustion roll := fw.Roll == nil || *fw.Roll - // create the file if it does not exist; create with the configured mode, or default - // to restrictive if not set. (timberjack will reuse the file mode across log rotation) - if err := os.MkdirAll(filepath.Dir(fw.Filename), 0o700); err != nil { - return nil, err + // Ensure directory exists before opening the file. + dirPath := filepath.Dir(fw.Filename) + switch strings.ToLower(strings.TrimSpace(fw.DirMode)) { + case "", "0": + // Preserve current behavior: locked-down directories by default. + if err := os.MkdirAll(dirPath, 0o700); err != nil { + return nil, err + } + case "inherit": + if err := mkdirAllInherit(dirPath); err != nil { + return nil, err + } + case "from_file": + if err := mkdirAllFromFile(dirPath, os.FileMode(fw.Mode)); err != nil { + return nil, err + } + default: + dm, err := parseFileMode(fw.DirMode) + if err != nil { + return nil, fmt.Errorf("dir_mode: %w", err) + } + if err := os.MkdirAll(dirPath, dm); err != nil { + return nil, err + } } + + // create/open the file file, err := os.OpenFile(fw.Filename, os.O_WRONLY|os.O_APPEND|os.O_CREATE, modeIfCreating) if err != nil { return nil, err @@ -223,27 +261,104 @@ func (fw FileWriter) OpenWriter() (io.WriteCloser, error) { if fw.RollKeepDays == 0 { fw.RollKeepDays = 90 } + + // Determine compression algorithm to use. Priority: + // 1) explicit RollCompression (none|gzip|zstd) + // 2) if RollCompress is unset or true -> gzip + // 3) if RollCompress is false -> none + var compression string + if fw.RollCompression != "" { + compression = strings.ToLower(strings.TrimSpace(fw.RollCompression)) + if compression != "none" && compression != "gzip" && compression != "zstd" { + return nil, fmt.Errorf("invalid roll_compression: %s", fw.RollCompression) + } + } else { + if fw.RollCompress == nil || *fw.RollCompress { + compression = "gzip" + } else { + compression = "none" + } + } + return &timberjack.Logger{ Filename: fw.Filename, MaxSize: fw.RollSizeMB, MaxAge: fw.RollKeepDays, MaxBackups: fw.RollKeep, LocalTime: fw.RollLocalTime, - Compress: *fw.RollCompress, + Compression: compression, RotationInterval: fw.RollInterval, RotateAtMinutes: fw.RollAtMinutes, RotateAt: fw.RollAt, BackupTimeFormat: fw.BackupTimeFormat, + FileMode: os.FileMode(fw.Mode), }, nil } +// normalizeDirPerm ensures that read bits also have execute bits set. +func normalizeDirPerm(p os.FileMode) os.FileMode { + if p&0o400 != 0 { + p |= 0o100 + } + if p&0o040 != 0 { + p |= 0o010 + } + if p&0o004 != 0 { + p |= 0o001 + } + return p +} + +// mkdirAllInherit creates missing dirs using the nearest existing parent's +// permissions, normalized with r→x. +func mkdirAllInherit(dir string) error { + if fi, err := os.Stat(dir); err == nil && fi.IsDir() { + return nil + } + cur := dir + var parent string + for { + next := filepath.Dir(cur) + if next == cur { + parent = next + break + } + if fi, err := os.Stat(next); err == nil { + if !fi.IsDir() { + return fmt.Errorf("path component %s exists and is not a directory", next) + } + parent = next + break + } + cur = next + } + perm := os.FileMode(0o700) + if fi, err := os.Stat(parent); err == nil && fi.IsDir() { + perm = fi.Mode().Perm() + } + perm = normalizeDirPerm(perm) + return os.MkdirAll(dir, perm) +} + +// mkdirAllFromFile creates missing dirs using the file's mode (with r→x) so +// 0644 → 0755, 0600 → 0700, etc. +func mkdirAllFromFile(dir string, fileMode os.FileMode) error { + if fi, err := os.Stat(dir); err == nil && fi.IsDir() { + return nil + } + perm := normalizeDirPerm(fileMode.Perm()) | 0o200 // ensure owner write on dir so files can be created + return os.MkdirAll(dir, perm) +} + // UnmarshalCaddyfile sets up the module from Caddyfile tokens. Syntax: // // file { // mode +// dir_mode // roll_disabled // roll_size // roll_uncompressed +// roll_compression // roll_local_time // roll_keep // roll_keep_for @@ -284,6 +399,22 @@ func (fw *FileWriter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { } fw.Mode = fileMode(mode) + case "dir_mode": + var val string + if !d.AllArgs(&val) { + return d.ArgErr() + } + val = strings.TrimSpace(val) + switch strings.ToLower(val) { + case "inherit", "from_file": + fw.DirMode = val + default: + if _, err := parseFileMode(val); err != nil { + return d.Errf("parsing dir_mode: %v", err) + } + fw.DirMode = val + } + case "roll_disabled": var f bool fw.Roll = &f @@ -309,6 +440,19 @@ func (fw *FileWriter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return d.ArgErr() } + case "roll_compression": + var comp string + if !d.AllArgs(&comp) { + return d.ArgErr() + } + comp = strings.ToLower(strings.TrimSpace(comp)) + switch comp { + case "none", "gzip", "zstd": + fw.RollCompression = comp + default: + return d.Errf("parsing roll_compression: must be 'none', 'gzip' or 'zstd'") + } + case "roll_local_time": fw.RollLocalTime = true if d.NextArg() { @@ -352,31 +496,48 @@ func (fw *FileWriter) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { fw.RollInterval = duration case "roll_minutes": - var minutesArrayStr string - if !d.AllArgs(&minutesArrayStr) { + // Accept either a single comma-separated argument or + // multiple space-separated arguments. Collect all + // remaining args on the line and split on commas. + args := d.RemainingArgs() + if len(args) == 0 { return d.ArgErr() } - minutesStr := strings.Split(minutesArrayStr, ",") - minutes := make([]int, len(minutesStr)) - for i := range minutesStr { - ms := strings.Trim(minutesStr[i], " ") - m, err := strconv.Atoi(ms) - if err != nil { - return d.Errf("parsing roll_minutes number: %v", err) + var minutes []int + for _, arg := range args { + parts := strings.SplitSeq(arg, ",") + for p := range parts { + ms := strings.TrimSpace(p) + if ms == "" { + return d.Errf("parsing roll_minutes: empty value") + } + m, err := strconv.Atoi(ms) + if err != nil { + return d.Errf("parsing roll_minutes number: %v", err) + } + minutes = append(minutes, m) } - minutes[i] = m } fw.RollAtMinutes = minutes case "roll_at": - var timeArrayStr string - if !d.AllArgs(&timeArrayStr) { + // Accept either a single comma-separated argument or + // multiple space-separated arguments. Collect all + // remaining args on the line and split on commas. + args := d.RemainingArgs() + if len(args) == 0 { return d.ArgErr() } - timeStr := strings.Split(timeArrayStr, ",") - times := make([]string, len(timeStr)) - for i := range timeStr { - times[i] = strings.Trim(timeStr[i], " ") + var times []string + for _, arg := range args { + parts := strings.SplitSeq(arg, ",") + for p := range parts { + ts := strings.TrimSpace(p) + if ts == "" { + return d.Errf("parsing roll_at: empty value") + } + times = append(times, ts) + } } fw.RollAt = times diff --git a/modules/logging/filewriter_test.go b/modules/logging/filewriter_test.go index 2a246156c..915784b53 100644 --- a/modules/logging/filewriter_test.go +++ b/modules/logging/filewriter_test.go @@ -385,3 +385,225 @@ func TestFileModeModification(t *testing.T) { t.Errorf("file mode is %v, want %v", st.Mode(), want) } } + +func TestDirMode_Inherit(t *testing.T) { + m := syscall.Umask(0) + defer syscall.Umask(m) + + parent := t.TempDir() + if err := os.Chmod(parent, 0o755); err != nil { + t.Fatal(err) + } + + targetDir := filepath.Join(parent, "a", "b") + fw := &FileWriter{ + Filename: filepath.Join(targetDir, "test.log"), + DirMode: "inherit", + Mode: 0o640, + Roll: func() *bool { f := false; return &f }(), + } + w, err := fw.OpenWriter() + if err != nil { + t.Fatal(err) + } + _ = w.Close() + + st, err := os.Stat(targetDir) + if err != nil { + t.Fatal(err) + } + if got := st.Mode().Perm(); got != 0o755 { + t.Fatalf("dir perm = %o, want 0755", got) + } +} + +func TestDirMode_FromFile(t *testing.T) { + m := syscall.Umask(0) + defer syscall.Umask(m) + + base := t.TempDir() + + dir1 := filepath.Join(base, "logs1") + fw1 := &FileWriter{ + Filename: filepath.Join(dir1, "app.log"), + DirMode: "from_file", + Mode: 0o644, // => dir 0755 + Roll: func() *bool { f := false; return &f }(), + } + w1, err := fw1.OpenWriter() + if err != nil { + t.Fatal(err) + } + _ = w1.Close() + + st1, err := os.Stat(dir1) + if err != nil { + t.Fatal(err) + } + if got := st1.Mode().Perm(); got != 0o755 { + t.Fatalf("dir perm = %o, want 0755", got) + } + + dir2 := filepath.Join(base, "logs2") + fw2 := &FileWriter{ + Filename: filepath.Join(dir2, "app.log"), + DirMode: "from_file", + Mode: 0o600, // => dir 0700 + Roll: func() *bool { f := false; return &f }(), + } + w2, err := fw2.OpenWriter() + if err != nil { + t.Fatal(err) + } + _ = w2.Close() + + st2, err := os.Stat(dir2) + if err != nil { + t.Fatal(err) + } + if got := st2.Mode().Perm(); got != 0o700 { + t.Fatalf("dir perm = %o, want 0700", got) + } +} + +func TestDirMode_ExplicitOctal(t *testing.T) { + m := syscall.Umask(0) + defer syscall.Umask(m) + + base := t.TempDir() + dest := filepath.Join(base, "logs3") + fw := &FileWriter{ + Filename: filepath.Join(dest, "app.log"), + DirMode: "0750", + Mode: 0o640, + Roll: func() *bool { f := false; return &f }(), + } + w, err := fw.OpenWriter() + if err != nil { + t.Fatal(err) + } + _ = w.Close() + + st, err := os.Stat(dest) + if err != nil { + t.Fatal(err) + } + if got := st.Mode().Perm(); got != 0o750 { + t.Fatalf("dir perm = %o, want 0750", got) + } +} + +func TestDirMode_Default0700(t *testing.T) { + m := syscall.Umask(0) + defer syscall.Umask(m) + + base := t.TempDir() + dest := filepath.Join(base, "logs4") + fw := &FileWriter{ + Filename: filepath.Join(dest, "app.log"), + Mode: 0o640, + Roll: func() *bool { f := false; return &f }(), + } + w, err := fw.OpenWriter() + if err != nil { + t.Fatal(err) + } + _ = w.Close() + + st, err := os.Stat(dest) + if err != nil { + t.Fatal(err) + } + if got := st.Mode().Perm(); got != 0o700 { + t.Fatalf("dir perm = %o, want 0700", got) + } +} + +func TestDirMode_UmaskInteraction(t *testing.T) { + _ = syscall.Umask(0o022) // typical umask; restore after + defer syscall.Umask(0) + + base := t.TempDir() + dest := filepath.Join(base, "logs5") + fw := &FileWriter{ + Filename: filepath.Join(dest, "app.log"), + DirMode: "0755", + Mode: 0o644, + Roll: func() *bool { f := false; return &f }(), + } + w, err := fw.OpenWriter() + if err != nil { + t.Fatal(err) + } + _ = w.Close() + + st, err := os.Stat(dest) + if err != nil { + t.Fatal(err) + } + // 0755 &^ 0022 still 0755 for dirs; this just sanity-checks we didn't get stricter unexpectedly + if got := st.Mode().Perm(); got != 0o755 { + t.Fatalf("dir perm = %o, want 0755 (considering umask)", got) + } +} + +func TestCaddyfile_DirMode_Inherit(t *testing.T) { + d := caddyfile.NewTestDispenser(` +file /var/log/app.log { + dir_mode inherit + mode 0640 +}`) + var fw FileWriter + if err := fw.UnmarshalCaddyfile(d); err != nil { + t.Fatal(err) + } + if fw.DirMode != "inherit" { + t.Fatalf("got %q", fw.DirMode) + } + if fw.Mode != 0o640 { + t.Fatalf("mode = %o", fw.Mode) + } +} + +func TestCaddyfile_DirMode_FromFile(t *testing.T) { + d := caddyfile.NewTestDispenser(` +file /var/log/app.log { + dir_mode from_file + mode 0600 +}`) + var fw FileWriter + if err := fw.UnmarshalCaddyfile(d); err != nil { + t.Fatal(err) + } + if fw.DirMode != "from_file" { + t.Fatalf("got %q", fw.DirMode) + } + if fw.Mode != 0o600 { + t.Fatalf("mode = %o", fw.Mode) + } +} + +func TestCaddyfile_DirMode_Octal(t *testing.T) { + d := caddyfile.NewTestDispenser(` +file /var/log/app.log { + dir_mode 0755 +}`) + var fw FileWriter + if err := fw.UnmarshalCaddyfile(d); err != nil { + t.Fatal(err) + } + if fw.DirMode != "0755" { + t.Fatalf("got %q", fw.DirMode) + } +} + +func TestCaddyfile_DirMode_Invalid(t *testing.T) { + d := caddyfile.NewTestDispenser(` +file /var/log/app.log { + dir_mode nope +}`) + var fw FileWriter + if err := fw.UnmarshalCaddyfile(d); err == nil { + t.Fatal("expected error for invalid dir_mode") + } +} diff --git a/modules/logging/filewriter_test_windows.go b/modules/logging/filewriter_test_windows.go index d32a8d2c0..254d5c30e 100644 --- a/modules/logging/filewriter_test_windows.go +++ b/modules/logging/filewriter_test_windows.go @@ -23,9 +23,9 @@ import ( ) // Windows relies on ACLs instead of unix permissions model. -// Go allows to open files with a particular mode put it is limited to read or write. +// Go allows to open files with a particular mode but it is limited to read or write. // See https://cs.opensource.google/go/go/+/refs/tags/go1.22.3:src/syscall/syscall_windows.go;l=708. -// This is pretty restrictive and has few interest for log files and thus we just test that log files are +// This is pretty restrictive and has little interest for log files and thus we just test that log files are // opened with R/W permissions by default on Windows too. func TestFileCreationMode(t *testing.T) { dir, err := os.MkdirTemp("", "caddytest") @@ -53,3 +53,41 @@ func TestFileCreationMode(t *testing.T) { t.Fatalf("file mode is %v, want rw for user", st.Mode().Perm()) } } + +func TestDirMode_Windows_CreateSucceeds(t *testing.T) { + dir, err := os.MkdirTemp("", "caddytest") + if err != nil { + t.Fatalf("failed to create tempdir: %v", err) + } + defer os.RemoveAll(dir) + + tests := []struct { + name string + dirMode string + }{ + {"inherit", "inherit"}, + {"from_file", "from_file"}, + {"octal", "0755"}, + {"default", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + subdir := path.Join(dir, "logs-"+tt.name) + fw := &FileWriter{ + Filename: path.Join(subdir, "test.log"), + DirMode: tt.dirMode, + Mode: 0o600, + } + w, err := fw.OpenWriter() + if err != nil { + t.Fatalf("failed to open writer: %v", err) + } + defer w.Close() + + if _, err := os.Stat(fw.Filename); err != nil { + t.Fatalf("expected file to exist: %v", err) + } + }) + } +} diff --git a/modules/logging/filters.go b/modules/logging/filters.go index a2ce6502f..4574b7ca0 100644 --- a/modules/logging/filters.go +++ b/modules/logging/filters.go @@ -255,7 +255,7 @@ func (m IPMaskFilter) Filter(in zapcore.Field) zapcore.Field { } func (m IPMaskFilter) mask(s string) string { - output := "" + parts := make([]string, 0) for value := range strings.SplitSeq(s, ",") { value = strings.TrimSpace(value) host, port, err := net.SplitHostPort(value) @@ -264,7 +264,7 @@ func (m IPMaskFilter) mask(s string) string { } ipAddr := net.ParseIP(host) if ipAddr == nil { - output += value + ", " + parts = append(parts, value) continue } mask := m.v4Mask @@ -273,13 +273,13 @@ func (m IPMaskFilter) mask(s string) string { } masked := ipAddr.Mask(mask) if port == "" { - output += masked.String() + ", " + parts = append(parts, masked.String()) continue } - output += net.JoinHostPort(masked.String(), port) + ", " + parts = append(parts, net.JoinHostPort(masked.String(), port)) } - return strings.TrimSuffix(output, ", ") + return strings.Join(parts, ", ") } type filterAction string diff --git a/notify/notify_windows.go b/notify/notify_windows.go index 5666a4c22..33f947565 100644 --- a/notify/notify_windows.go +++ b/notify/notify_windows.go @@ -14,16 +14,26 @@ package notify -import "golang.org/x/sys/windows/svc" +import ( + "log" + "strings" + + "golang.org/x/sys/windows/svc" +) // globalStatus store windows service status, it can be // use to notify caddy status. var globalStatus chan<- svc.Status +// SetGlobalStatus assigns the channel through which status updates +// will be sent to the SCM. This is typically provided by the service +// handler when the service starts. func SetGlobalStatus(status chan<- svc.Status) { globalStatus = status } +// Ready notifies the SCM that the service is fully running and ready +// to accept stop or shutdown control requests. func Ready() error { if globalStatus != nil { globalStatus <- svc.Status{ @@ -34,6 +44,8 @@ func Ready() error { return nil } +// Reloading notifies the SCM that the service is entering a transitional +// state. func Reloading() error { if globalStatus != nil { globalStatus <- svc.Status{State: svc.StartPending} @@ -41,6 +53,8 @@ func Reloading() error { return nil } +// Stopping notifies the SCM that the service is in the process of stopping. +// This allows Windows to track the shutdown transition properly. func Stopping() error { if globalStatus != nil { globalStatus <- svc.Status{State: svc.StopPending} @@ -48,8 +62,53 @@ func Stopping() error { return nil } -// TODO: not implemented -func Status(_ string) error { return nil } +// Status sends an arbitrary service state to the SCM based on a string +// identifier of [svc.State]. +// The unknown states will be logged. +func Status(name string) error { + if globalStatus == nil { + return nil + } -// TODO: not implemented -func Error(_ error, _ int) error { return nil } + var state svc.State + var accepts svc.Accepted + accepts = 0 + + switch strings.ToLower(name) { + case "stopped": + state = svc.Stopped + case "start_pending": + state = svc.StartPending + case "stop_pending": + state = svc.StopPending + case "running": + state = svc.Running + accepts = svc.AcceptStop | svc.AcceptShutdown + case "continue_pending": + state = svc.ContinuePending + case "pause_pending": + state = svc.PausePending + case "paused": + state = svc.Paused + accepts = svc.AcceptStop | svc.AcceptShutdown | svc.AcceptPauseAndContinue + default: + log.Printf("unknown state: %s", name) + return nil + } + + globalStatus <- svc.Status{State: state, Accepts: accepts} + return nil +} + +// Error notifies the SCM that the service is stopping due to a failure, +// including a service-specific exit code. +func Error(err error, code int) error { + if globalStatus != nil { + globalStatus <- svc.Status{ + State: svc.StopPending, + ServiceSpecificExitCode: uint32(code), + } + } + + return nil +}