mirror of
https://github.com/caddyserver/caddy.git
synced 2026-05-25 16:22:36 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5b7c74c88 |
@@ -1,31 +0,0 @@
|
||||
name: Issue
|
||||
description: An actionable development item, like a bug report or feature request
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for opening an issue! This is for actionable development items like bug reports and feature requests.
|
||||
If you have a question about using Caddy, please [post on our forums](https://caddy.community) instead.
|
||||
- type: textarea
|
||||
id: content
|
||||
attributes:
|
||||
label: Issue Details
|
||||
placeholder: Describe the issue here. Be specific by providing complete logs and minimal instructions to reproduce, or a thoughtful proposal, etc.
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: assistance-disclosure
|
||||
attributes:
|
||||
label: Assistance Disclosure
|
||||
description: "Our project allows assistance by AI/LLM tools as long as it is disclosed and described so we can better respond. Please certify whether you have used any such tooling related to this issue:"
|
||||
options:
|
||||
-
|
||||
- AI used
|
||||
- AI not used
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: assistance-description
|
||||
attributes:
|
||||
label: If AI was used, describe the extent to which it was used.
|
||||
description: 'Examples: "ChatGPT translated from my native language" or "Claude proposed this change/feature"'
|
||||
@@ -1,5 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Caddy forum
|
||||
url: https://caddy.community
|
||||
about: If you have questions (or answers!) about using Caddy, please use our forum
|
||||
+7
-12
@@ -1,14 +1,15 @@
|
||||
# Security Policy
|
||||
|
||||
The Caddy project would like to make sure that it stays on top of all relevant and practically-exploitable vulnerabilities.
|
||||
The Caddy project would like to make sure that it stays on top of all practically-exploitable vulnerabilities.
|
||||
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ----------- | ----------|
|
||||
| 2.latest | ✔️ |
|
||||
| < 2.latest | :x: |
|
||||
| Version | Supported |
|
||||
| -------- | ----------|
|
||||
| 2.latest | ✔️ |
|
||||
| 1.x | :x: |
|
||||
| < 1.x | :x: |
|
||||
|
||||
|
||||
## Acceptable Scope
|
||||
@@ -17,7 +18,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. 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -25,10 +26,6 @@ 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.
|
||||
|
||||
Many reports are not security bugs and can be addressed by updating the documentation.
|
||||
|
||||
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
|
||||
|
||||
@@ -36,8 +33,6 @@ 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!)
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
|
||||
|
||||
|
||||
## Assistance Disclosure
|
||||
<!--
|
||||
Thank you for contributing! Please note:
|
||||
|
||||
The use of AI/LLM tools is allowed so long as it is disclosed, so
|
||||
that we can provide better code review and maintain project quality.
|
||||
|
||||
If you used AI/LLM tooling in any way related to this PR, please
|
||||
let us know to what extent it was utilized.
|
||||
|
||||
Examples:
|
||||
|
||||
"No AI was used."
|
||||
"I wrote the code, but Claude generated the tests."
|
||||
"I consulted ChatGPT for a solution, but I authored/coded it myself."
|
||||
"Cody generated the code, and I verified it is correct."
|
||||
"Copilot provided tab completion for code and comments."
|
||||
|
||||
We expect that you have vetted your contributions for correctness.
|
||||
Additionally, signing our CLA certifies that you have the rights to
|
||||
contribute this change.
|
||||
|
||||
Replace the text below with your disclosure:
|
||||
-->
|
||||
|
||||
_This PR is missing an assistance disclosure._
|
||||
@@ -1,30 +0,0 @@
|
||||
name: AI Moderator
|
||||
permissions: read-all
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
jobs:
|
||||
spam-detection:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
models: read
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
- uses: github/ai-moderator@81159c370785e295c97461ade67d7c33576e9319
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
spam-label: 'spam'
|
||||
ai-label: 'ai-generated'
|
||||
minimize-detected-comments: true
|
||||
# Built-in prompt configuration (all enabled by default)
|
||||
enable-spam-detection: true
|
||||
enable-link-spam-detection: true
|
||||
enable-ai-detection: true
|
||||
# custom-prompt-path: '.github/prompts/my-custom.prompt.yml' # Optional
|
||||
@@ -1,221 +0,0 @@
|
||||
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');
|
||||
|
||||
+18
-19
@@ -13,7 +13,6 @@ on:
|
||||
- 2.*
|
||||
|
||||
env:
|
||||
GOFLAGS: '-tags=nobadger,nomysql,nopgx'
|
||||
# https://github.com/actions/setup-go/issues/491
|
||||
GOTOOLCHAIN: local
|
||||
|
||||
@@ -31,13 +30,13 @@ jobs:
|
||||
- mac
|
||||
- windows
|
||||
go:
|
||||
- '1.26'
|
||||
- '1.24'
|
||||
|
||||
include:
|
||||
# Set the minimum Go patch version for the given Go minor
|
||||
# Usable via ${{ matrix.GO_SEMVER }}
|
||||
- go: '1.26'
|
||||
GO_SEMVER: '~1.26.0'
|
||||
- go: '1.24'
|
||||
GO_SEMVER: '~1.24.1'
|
||||
|
||||
# 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 +64,15 @@ jobs:
|
||||
actions: write # to allow uploading artifacts and cache
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: ${{ matrix.GO_SEMVER }}
|
||||
check-latest: true
|
||||
@@ -111,7 +110,7 @@ jobs:
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
go build -trimpath -ldflags="-w -s" -v
|
||||
go build -tags nobadger,nomysql,nopgx -trimpath -ldflags="-w -s" -v
|
||||
|
||||
- name: Smoke test Caddy
|
||||
working-directory: ./cmd/caddy
|
||||
@@ -120,7 +119,7 @@ jobs:
|
||||
./caddy stop
|
||||
|
||||
- name: Publish Build Artifact
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: caddy_${{ runner.os }}_go${{ matrix.go }}_${{ steps.vars.outputs.short_sha }}
|
||||
path: ${{ matrix.CADDY_BIN_PATH }}
|
||||
@@ -134,7 +133,7 @@ jobs:
|
||||
# continue-on-error: true
|
||||
run: |
|
||||
# (go test -v -coverprofile=cover-profile.out -race ./... 2>&1) > test-results/test-result.out
|
||||
go test -v -coverprofile="cover-profile.out" -short -race ./...
|
||||
go test -tags nobadger,nomysql,nopgx -v -coverprofile="cover-profile.out" -short -race ./...
|
||||
# echo "status=$?" >> $GITHUB_OUTPUT
|
||||
|
||||
# Relevant step if we reinvestigate publishing test/coverage reports
|
||||
@@ -162,13 +161,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@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
allowed-endpoints: ci-s390x.caddyserver.com:22
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Run Tests
|
||||
run: |
|
||||
set +e
|
||||
@@ -191,7 +190,7 @@ jobs:
|
||||
retries=3
|
||||
exit_code=0
|
||||
while ((retries > 0)); do
|
||||
CGO_ENABLED=0 go test -p 1 -v ./...
|
||||
CGO_ENABLED=0 go test -p 1 -tags nobadger,nomysql,nopgx -v ./...
|
||||
exit_code=$?
|
||||
if ((exit_code == 0)); then
|
||||
break
|
||||
@@ -221,27 +220,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@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
|
||||
- uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0
|
||||
with:
|
||||
version: latest
|
||||
args: check
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: "~1.26"
|
||||
go-version: "~1.24"
|
||||
check-latest: true
|
||||
- name: Install xcaddy
|
||||
run: |
|
||||
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
|
||||
xcaddy version
|
||||
- uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
|
||||
- uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0
|
||||
with:
|
||||
version: latest
|
||||
args: build --single-target --snapshot
|
||||
|
||||
@@ -11,8 +11,6 @@ on:
|
||||
- 2.*
|
||||
|
||||
env:
|
||||
GOFLAGS: '-tags=nobadger,nomysql,nopgx'
|
||||
CGO_ENABLED: '0'
|
||||
# https://github.com/actions/setup-go/issues/491
|
||||
GOTOOLCHAIN: local
|
||||
|
||||
@@ -36,13 +34,13 @@ jobs:
|
||||
- 'darwin'
|
||||
- 'netbsd'
|
||||
go:
|
||||
- '1.26'
|
||||
- '1.24'
|
||||
|
||||
include:
|
||||
# Set the minimum Go patch version for the given Go minor
|
||||
# Usable via ${{ matrix.GO_SEMVER }}
|
||||
- go: '1.26'
|
||||
GO_SEMVER: '~1.26.0'
|
||||
- go: '1.24'
|
||||
GO_SEMVER: '~1.24.1'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -51,15 +49,15 @@ jobs:
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: ${{ matrix.GO_SEMVER }}
|
||||
check-latest: true
|
||||
@@ -76,9 +74,11 @@ jobs:
|
||||
|
||||
- name: Run Build
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goos == 'aix' && 'ppc64' || 'amd64' }}
|
||||
shell: bash
|
||||
continue-on-error: true
|
||||
working-directory: ./cmd/caddy
|
||||
run: go build -trimpath -o caddy-"$GOOS"-$GOARCH 2> /dev/null
|
||||
run: |
|
||||
GOOS=$GOOS GOARCH=$GOARCH go build -tags=nobadger,nomysql,nopgx -trimpath -o caddy-"$GOOS"-$GOARCH 2> /dev/null
|
||||
|
||||
+10
-10
@@ -45,18 +45,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: '~1.26'
|
||||
go-version: '~1.24'
|
||||
check-latest: true
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
|
||||
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.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@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: govulncheck
|
||||
uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4
|
||||
with:
|
||||
go-version-input: '~1.26.0'
|
||||
go-version-input: '~1.24.1'
|
||||
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@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3
|
||||
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
|
||||
with:
|
||||
comment-summary-in-pr: on-failure
|
||||
# https://github.com/actions/dependency-review-action/issues/430#issuecomment-1468975566
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
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<<EOF" >> $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
|
||||
+20
-395
@@ -13,334 +13,20 @@ 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.26'
|
||||
- '1.24'
|
||||
|
||||
include:
|
||||
# Set the minimum Go patch version for the given Go minor
|
||||
# Usable via ${{ matrix.GO_SEMVER }}
|
||||
- go: '1.26'
|
||||
GO_SEMVER: '~1.26.0'
|
||||
- go: '1.24'
|
||||
GO_SEMVER: '~1.24.1'
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
# https://github.com/sigstore/cosign/issues/1258#issuecomment-1002251233
|
||||
@@ -350,28 +36,26 @@ 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@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
|
||||
with:
|
||||
go-version: ${{ matrix.GO_SEMVER }}
|
||||
check-latest: true
|
||||
|
||||
# Force fetch upstream tags -- because 65 minutes
|
||||
# tl;dr: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4.2.2 runs this line:
|
||||
# tl;dr: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 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
|
||||
@@ -414,12 +98,22 @@ 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
|
||||
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # main
|
||||
- name: Cosign version
|
||||
run: cosign version
|
||||
- name: Install Syft
|
||||
uses: anchore/sbom-action/download-syft@17ae1740179002c89186b61233e0f892c3118b11 # main
|
||||
uses: anchore/sbom-action/download-syft@7b36ad622f042cab6f59a75c2ac24ccb256e9b45 # main
|
||||
- name: Syft version
|
||||
run: syft version
|
||||
- name: Install xcaddy
|
||||
@@ -428,7 +122,7 @@ jobs:
|
||||
xcaddy version
|
||||
# GoReleaser will take care of publishing those artifacts into the release
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0
|
||||
uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0
|
||||
with:
|
||||
version: latest
|
||||
args: release --clean --timeout 60m
|
||||
@@ -494,72 +188,3 @@ 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Trigger event on caddyserver/dist
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
|
||||
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@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
|
||||
with:
|
||||
token: ${{ secrets.REPO_DISPATCH_TOKEN }}
|
||||
repository: caddyserver/caddy-docker
|
||||
|
||||
@@ -37,17 +37,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@a90bcbc6539c36a85cdfeb73f7e2f433735f215b # v2.15.0
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
@@ -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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
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@89a39a4e59826350b863aa6b6252a07ad50cf83e # v3.29.5
|
||||
uses: github/codeql-action/upload-sarif@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -2,10 +2,6 @@ version: "2"
|
||||
run:
|
||||
issues-exit-code: 1
|
||||
tests: false
|
||||
build-tags:
|
||||
- nobadger
|
||||
- nomysql
|
||||
- nopgx
|
||||
output:
|
||||
formats:
|
||||
text:
|
||||
@@ -32,7 +28,6 @@ linters:
|
||||
- importas
|
||||
- ineffassign
|
||||
- misspell
|
||||
- modernize
|
||||
- prealloc
|
||||
- promlinter
|
||||
- sloglint
|
||||
|
||||
+1
-3
@@ -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 arm64; do XCADDY_SKIP_BUILD=1 GOOS=windows GOARCH=$a xcaddy build {{.Env.TAG}}; done'
|
||||
- /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 '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,8 +67,6 @@ builds:
|
||||
goarch: s390x
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: freebsd
|
||||
goarch: ppc64le
|
||||
- goos: freebsd
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
# Caddy Project Guidelines
|
||||
|
||||
## Mission
|
||||
|
||||
**Every site on HTTPS.** Caddy is a security-first, modular, extensible server platform.
|
||||
|
||||
## Code Style
|
||||
|
||||
### Go Idioms
|
||||
|
||||
Follow [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments):
|
||||
|
||||
- **Error flow**: Early return, indent error handling—not else blocks
|
||||
```go
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// normal code
|
||||
```
|
||||
- **Naming**: initialisms (`URL`, `HTTP`, `ID`—not `Url`, `Http`, `Id`)
|
||||
- **Receiver names**: 1–2 letters reflecting type (`c` for `Client`, `h` for `Handler`)
|
||||
- **Error strings**: Lowercase, no trailing punctuation (`"something failed"` not `"Something failed."`)
|
||||
- **Doc comments**: Full sentences starting with the name being documented
|
||||
```go
|
||||
// Handler serves HTTP requests for the file server.
|
||||
type Handler struct { ... }
|
||||
```
|
||||
- **Empty slices**: `var t []string` (nil slice), not `t := []string{}` (non-nil zero-length)
|
||||
- **Don't panic**: Use error returns for normal error handling
|
||||
|
||||
### Caddy Patterns
|
||||
|
||||
**Module registration**:
|
||||
```go
|
||||
func init() {
|
||||
caddy.RegisterModule(MyModule{})
|
||||
}
|
||||
|
||||
func (MyModule) CaddyModule() caddy.ModuleInfo {
|
||||
return caddy.ModuleInfo{
|
||||
ID: "namespace.category.name",
|
||||
New: func() caddy.Module { return new(MyModule) },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Module lifecycle**: `New()` → JSON unmarshal → `Provision()` → `Validate()` → use → `Cleanup()`
|
||||
|
||||
**Interface guards** — compile-time verification that modules implement required interfaces:
|
||||
```go
|
||||
var (
|
||||
_ caddy.Provisioner = (*MyModule)(nil)
|
||||
_ caddy.Validator = (*MyModule)(nil)
|
||||
_ caddyfile.Unmarshaler = (*MyModule)(nil)
|
||||
)
|
||||
```
|
||||
|
||||
**Structured logging** — use the module-scoped logger from context:
|
||||
```go
|
||||
func (m *MyModule) Provision(ctx caddy.Context) error {
|
||||
m.logger = ctx.Logger()
|
||||
m.logger.Debug("provisioning", zap.String("field", m.Field))
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Caddyfile support** — implement `UnmarshalCaddyfile(*caddyfile.Dispenser)` using the `Dispenser` API:
|
||||
```go
|
||||
// UnmarshalCaddyfile sets up the module from Caddyfile tokens. Syntax:
|
||||
//
|
||||
// directive [arg1] [arg2] {
|
||||
// subdir value
|
||||
// }
|
||||
func (m *MyModule) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
|
||||
d.Next() // consume directive name
|
||||
for d.NextArg() {
|
||||
// handle inline arguments
|
||||
}
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
switch d.Val() {
|
||||
case "subdir":
|
||||
if !d.NextArg() {
|
||||
return d.ArgErr()
|
||||
}
|
||||
m.Field = d.Val()
|
||||
default:
|
||||
return d.Errf("unrecognized subdirective: %s", d.Val())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Admin API**: Implement `caddy.AdminRouter` for custom endpoints.
|
||||
|
||||
**Context**: Use `caddy.Context` for accessing other apps/modules and logging—don't store contexts in structs.
|
||||
|
||||
## Architecture
|
||||
|
||||
Caddy is built around a **module system** where everything is a module registered via `caddy.RegisterModule()`:
|
||||
|
||||
- **Apps** (`caddy.App`): Top-level modules like `http`, `tls`, `pki` that Caddy loads and runs
|
||||
- **Modules** (`caddy.Module`): Extensible components with namespaced IDs (e.g., `http.handlers.file_server`)
|
||||
- **Configuration**: Native JSON with adapters (Caddyfile → JSON via `caddyconfig/httpcaddyfile`)
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `modules/` | All standard modules (HTTP, TLS, PKI, etc.) |
|
||||
| `modules/standard/imports.go` | Standard module registry |
|
||||
| `caddyconfig/httpcaddyfile/` | Caddyfile → JSON adapter for HTTP |
|
||||
| `caddytest/` | Test utilities and integration tests |
|
||||
| `cmd/caddy/` | CLI entry point with module imports |
|
||||
|
||||
### Critical Packages
|
||||
|
||||
`caddyhttp` and `caddytls` require **extra scrutiny** in code review—these are security-critical.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
|
||||
**All required before PR is merge-ready:**
|
||||
|
||||
| Gate | Command | Notes |
|
||||
|------|---------|-------|
|
||||
| Tests pass | `go test -race -short ./...` | Race detection enabled |
|
||||
| Lint clean | `golangci-lint run --timeout 10m` | No warnings in changed files |
|
||||
| Builds | `go build ./...` | Must compile |
|
||||
| Benchmarks | `go test -bench=. -benchmem` | Required for optimizations |
|
||||
|
||||
CI runs tests on **Linux, macOS, and Windows**—ensure cross-platform compatibility.
|
||||
|
||||
### Build & Test
|
||||
|
||||
```bash
|
||||
# Build
|
||||
cd cmd/caddy && go build
|
||||
|
||||
# Tests with race detection (matches CI)
|
||||
go test -race -short ./...
|
||||
|
||||
# Integration tests
|
||||
go test ./caddytest/integration/...
|
||||
|
||||
# Lint (matches CI)
|
||||
golangci-lint run --timeout 10m
|
||||
```
|
||||
|
||||
## Testing Conventions
|
||||
|
||||
**Table-driven tests** (preferred pattern):
|
||||
```go
|
||||
func TestFeature(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
input string
|
||||
expected string
|
||||
wantErr bool
|
||||
}{
|
||||
{input: "valid", expected: "result", wantErr: false},
|
||||
{input: "invalid", expected: "", wantErr: true},
|
||||
} {
|
||||
actual, err := Function(tc.input)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("Test %d: expected error but got none", i)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("Test %d: unexpected error: %v", i, err)
|
||||
}
|
||||
if actual != tc.expected {
|
||||
t.Errorf("Test %d: expected %q, got %q", i, tc.expected, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Integration tests** use `caddytest.Tester`:
|
||||
```go
|
||||
func TestHTTPFeature(t *testing.T) {
|
||||
tester := caddytest.NewTester(t)
|
||||
tester.InitServer(`
|
||||
{
|
||||
admin localhost:2999
|
||||
http_port 9080
|
||||
}
|
||||
localhost:9080 {
|
||||
respond "hello"
|
||||
}`, "caddyfile")
|
||||
|
||||
tester.AssertGetResponse("http://localhost:9080/", 200, "hello")
|
||||
}
|
||||
```
|
||||
|
||||
Use non-standard ports (9080, 9443, 2999) to avoid conflicts with running servers.
|
||||
|
||||
## AI Contribution Policy
|
||||
|
||||
Per [CONTRIBUTING.md](.github/CONTRIBUTING.md), AI-assisted code **MUST** be:
|
||||
|
||||
1. **Disclosed** — Tell reviewers when code was AI-generated or AI-assisted, mentioning which agent/model is used
|
||||
2. **Fully comprehended** — You must be able to explain every line
|
||||
3. **Tested** — Automated tests when feasible, thorough manual tests otherwise
|
||||
4. **Licensed** — Verify AI output doesn't include plagiarized or incompatibly-licensed code
|
||||
5. **Contributor License Agreement (CLA)** — The CLA must be signed by the human user
|
||||
|
||||
**Do NOT submit code you cannot fully explain.** Contributors are responsible for their submissions.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Avoid new dependencies** — Justify any additions; tiny deps can be inlined
|
||||
- **No exported dependency types** — Caddy must not export types defined by external packages
|
||||
- Use Go modules; check with `go mod tidy`
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [CONTRIBUTING.md](.github/CONTRIBUTING.md) — Full PR process and expectations
|
||||
- [Extending Caddy](https://caddyserver.com/docs/extending-caddy) — Module development guide
|
||||
- [JSON Config](https://caddyserver.com/docs/json/) — Native configuration reference
|
||||
- [Caddyfile](https://caddyserver.com/docs/caddyfile/concepts) — Caddyfile syntax guide
|
||||
@@ -12,52 +12,24 @@
|
||||
<hr>
|
||||
<h3 align="center">Every site on HTTPS</h3>
|
||||
<p align="center">Caddy is an extensible server platform that uses TLS by default.</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/caddyserver/caddy/actions/workflows/ci.yml"><img src="https://github.com/caddyserver/caddy/actions/workflows/ci.yml/badge.svg"></a>
|
||||
<a href="https://www.bestpractices.dev/projects/7141"><img src="https://www.bestpractices.dev/projects/7141/badge"></a>
|
||||
<a href="https://pkg.go.dev/github.com/caddyserver/caddy/v2"><img src="https://img.shields.io/badge/godoc-reference-%23007d9c.svg"></a>
|
||||
<br>
|
||||
<a href="https://x.com/caddyserver" title="@caddyserver on Twitter"><img src="https://img.shields.io/twitter/follow/caddyserver" alt="@caddyserver on Twitter"></a>
|
||||
<a href="https://caddy.community" title="Caddy Forum"><img src="https://img.shields.io/badge/community-forum-ff69b4.svg" alt="Caddy Forum"></a>
|
||||
<br>
|
||||
<a href="https://sourcegraph.com/github.com/caddyserver/caddy?badge" title="Caddy on Sourcegraph"><img src="https://sourcegraph.com/github.com/caddyserver/caddy/-/badge.svg" alt="Caddy on Sourcegraph"></a>
|
||||
<a href="https://cloudsmith.io/~caddy/repos/"><img src="https://img.shields.io/badge/OSS%20hosting%20by-cloudsmith-blue?logo=cloudsmith" alt="Cloudsmith"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/caddyserver/caddy/releases">Releases</a> ·
|
||||
<a href="https://caddyserver.com/docs/">Documentation</a> ·
|
||||
<a href="https://caddy.community">Get Help</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/caddyserver/caddy/actions/workflows/ci.yml"><img src="https://github.com/caddyserver/caddy/actions/workflows/ci.yml/badge.svg"></a>
|
||||
|
||||
<a href="https://www.bestpractices.dev/projects/7141"><img src="https://www.bestpractices.dev/projects/7141/badge"></a>
|
||||
|
||||
<a href="https://pkg.go.dev/github.com/caddyserver/caddy/v2"><img src="https://img.shields.io/badge/godoc-reference-%23007d9c.svg"></a>
|
||||
|
||||
<a href="https://x.com/caddyserver" title="@caddyserver on Twitter"><img src="https://img.shields.io/twitter/follow/caddyserver" alt="@caddyserver on Twitter"></a>
|
||||
|
||||
<a href="https://caddy.community" title="Caddy Forum"><img src="https://img.shields.io/badge/community-forum-ff69b4.svg" alt="Caddy Forum"></a>
|
||||
<br>
|
||||
<a href="https://sourcegraph.com/github.com/caddyserver/caddy?badge" title="Caddy on Sourcegraph"><img src="https://sourcegraph.com/github.com/caddyserver/caddy/-/badge.svg" alt="Caddy on Sourcegraph"></a>
|
||||
|
||||
<a href="https://cloudsmith.io/~caddy/repos/"><img src="https://img.shields.io/badge/OSS%20hosting%20by-cloudsmith-blue?logo=cloudsmith" alt="Cloudsmith"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<b>Powered by</b>
|
||||
<br>
|
||||
<a href="https://github.com/caddyserver/certmagic">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/55066419/206946718-740b6371-3df3-4d72-a822-47e4c48af999.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/1128849/49704830-49d37200-fbd5-11e8-8385-767e0cd033c3.png">
|
||||
<img src="https://user-images.githubusercontent.com/1128849/49704830-49d37200-fbd5-11e8-8385-767e0cd033c3.png" alt="CertMagic" width="250">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- Warp sponsorship requests this section -->
|
||||
<div align="center" markdown="1">
|
||||
<hr>
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<a href="https://go.warp.dev/caddy">
|
||||
<img alt="Warp sponsorship" width="400" src="https://github.com/user-attachments/assets/c8efffde-18c7-4af4-83ed-b1aba2dda394">
|
||||
</a>
|
||||
|
||||
### [Warp, built for coding with multiple AI agents](https://go.warp.dev/caddy)
|
||||
[Available for MacOS, Linux, & Windows](https://go.warp.dev/caddy)<br>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
### Menu
|
||||
|
||||
@@ -72,6 +44,18 @@
|
||||
- [Getting help](#getting-help)
|
||||
- [About](#about)
|
||||
|
||||
<p align="center">
|
||||
<b>Powered by</b>
|
||||
<br>
|
||||
<a href="https://github.com/caddyserver/certmagic">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/55066419/206946718-740b6371-3df3-4d72-a822-47e4c48af999.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/1128849/49704830-49d37200-fbd5-11e8-8385-767e0cd033c3.png">
|
||||
<img src="https://user-images.githubusercontent.com/1128849/49704830-49d37200-fbd5-11e8-8385-767e0cd033c3.png" alt="CertMagic" width="250">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
## [Features](https://caddyserver.com/features)
|
||||
|
||||
@@ -105,7 +89,7 @@ See [our online documentation](https://caddyserver.com/docs/install) for other i
|
||||
|
||||
Requirements:
|
||||
|
||||
- [Go 1.25.0 or newer](https://golang.org/dl/)
|
||||
- [Go 1.24.0 or newer](https://golang.org/dl/)
|
||||
|
||||
### For development
|
||||
|
||||
@@ -133,18 +117,11 @@ 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
|
||||
```
|
||||
|
||||
@@ -220,6 +197,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), an HID Global company.
|
||||
Caddy is a project of [ZeroSSL](https://zerossl.com), a Stack Holdings 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.
|
||||
|
||||
@@ -45,16 +45,8 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"github.com/caddyserver/caddy/v2/internal"
|
||||
)
|
||||
|
||||
// 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.
|
||||
@@ -120,6 +112,10 @@ type AdminConfig struct {
|
||||
//
|
||||
// EXPERIMENTAL: This feature is subject to change.
|
||||
Remote *RemoteAdmin `json:"remote,omitempty"`
|
||||
|
||||
// Holds onto the routers so that we can later provision them
|
||||
// if they require provisioning.
|
||||
routers []AdminRouter
|
||||
}
|
||||
|
||||
// ConfigSettings configures the management of configuration.
|
||||
@@ -208,8 +204,8 @@ type AdminAccess struct {
|
||||
// AdminPermissions specifies what kinds of requests are allowed
|
||||
// to be made to the admin endpoint.
|
||||
type AdminPermissions struct {
|
||||
// The API paths allowed. A request path must either equal an
|
||||
// allowed path or be a subpath with a path-segment boundary.
|
||||
// The API paths allowed. Paths are simple prefix matches.
|
||||
// Any subpath of the specified paths will be allowed.
|
||||
Paths []string `json:"paths,omitempty"`
|
||||
|
||||
// The HTTP methods allowed for the given paths.
|
||||
@@ -218,7 +214,7 @@ type AdminPermissions struct {
|
||||
|
||||
// newAdminHandler reads admin's config and returns an http.Handler suitable
|
||||
// for use in an admin endpoint server, which will be listening on listenAddr.
|
||||
func (admin *AdminConfig) newAdminHandler(addr NetworkAddress, remote bool, ctx Context) (adminHandler, error) {
|
||||
func (admin *AdminConfig) newAdminHandler(addr NetworkAddress, remote bool, _ Context) adminHandler {
|
||||
muxWrap := adminHandler{mux: http.NewServeMux()}
|
||||
|
||||
// secure the local or remote endpoint respectively
|
||||
@@ -275,21 +271,34 @@ func (admin *AdminConfig) newAdminHandler(addr NetworkAddress, remote bool, ctx
|
||||
// register third-party module endpoints
|
||||
for _, m := range GetModules("admin.api") {
|
||||
router := m.New().(AdminRouter)
|
||||
|
||||
// provision the router before registering its routes, so
|
||||
// handlers have access to all provisioned state
|
||||
if provisioner, ok := router.(Provisioner); ok {
|
||||
if err := provisioner.Provision(ctx); err != nil {
|
||||
return adminHandler{}, fmt.Errorf("provisioning admin router module %s: %v", m.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, route := range router.Routes() {
|
||||
addRoute(route.Pattern, handlerLabel, route.Handler)
|
||||
}
|
||||
admin.routers = append(admin.routers, router)
|
||||
}
|
||||
|
||||
return muxWrap, nil
|
||||
return muxWrap
|
||||
}
|
||||
|
||||
// provisionAdminRouters provisions all the router modules
|
||||
// in the admin.api namespace that need provisioning.
|
||||
func (admin *AdminConfig) provisionAdminRouters(ctx Context) error {
|
||||
for _, router := range admin.routers {
|
||||
provisioner, ok := router.(Provisioner)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
err := provisioner.Provision(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// We no longer need the routers once provisioned, allow for GC
|
||||
admin.routers = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// allowedOrigins returns a list of origins that are allowed.
|
||||
@@ -413,7 +422,11 @@ func replaceLocalAdminServer(cfg *Config, ctx Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
handler, err := cfg.Admin.newAdminHandler(addr, false, ctx)
|
||||
handler := cfg.Admin.newAdminHandler(addr, false, ctx)
|
||||
|
||||
// run the provisioners for loaded modules to make sure local
|
||||
// state is properly re-initialized in the new admin server
|
||||
err = cfg.Admin.provisionAdminRouters(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -537,7 +550,11 @@ func replaceRemoteAdminServer(ctx Context, cfg *Config) error {
|
||||
|
||||
// make the HTTP handler but disable Host/Origin enforcement
|
||||
// because we are using TLS authentication instead
|
||||
handler, err := cfg.Admin.newAdminHandler(addr, true, ctx)
|
||||
handler := cfg.Admin.newAdminHandler(addr, true, ctx)
|
||||
|
||||
// run the provisioners for loaded modules to make sure local
|
||||
// state is properly re-initialized in the new admin server
|
||||
err = cfg.Admin.provisionAdminRouters(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -616,19 +633,8 @@ 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: storage,
|
||||
Storage: DefaultStorage, // do not act as part of a cluster (this is for the server's local identity)
|
||||
Logger: logger,
|
||||
Issuers: ident.issuers,
|
||||
}
|
||||
@@ -693,7 +699,7 @@ func (remote RemoteAdmin) enforceAccessControls(r *http.Request) error {
|
||||
// verify path
|
||||
pathFound := accessPerm.Paths == nil
|
||||
for _, allowedPath := range accessPerm.Paths {
|
||||
if adminPathAllowed(r.URL.Path, allowedPath) {
|
||||
if strings.HasPrefix(r.URL.Path, allowedPath) {
|
||||
pathFound = true
|
||||
break
|
||||
}
|
||||
@@ -722,31 +728,14 @@ func (remote RemoteAdmin) enforceAccessControls(r *http.Request) error {
|
||||
}
|
||||
}
|
||||
|
||||
func adminPathAllowed(reqPath, allowedPath string) bool {
|
||||
if allowedPath == "" || allowedPath == "/" {
|
||||
return strings.HasPrefix(reqPath, allowedPath)
|
||||
}
|
||||
if reqPath == allowedPath {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(allowedPath, "/") {
|
||||
return strings.HasPrefix(reqPath, allowedPath)
|
||||
}
|
||||
return strings.HasPrefix(reqPath, allowedPath+"/")
|
||||
}
|
||||
|
||||
func stopAdminServer(srv *http.Server) error {
|
||||
if srv == nil {
|
||||
return fmt.Errorf("no admin server")
|
||||
}
|
||||
timeout := 10 * time.Second
|
||||
ctx, cancel := context.WithTimeoutCause(context.Background(), timeout, fmt.Errorf("stopping admin server: %ds timeout", int(timeout.Seconds())))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
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))
|
||||
@@ -790,7 +779,7 @@ func (h adminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
zap.String("uri", r.RequestURI),
|
||||
zap.String("remote_ip", ip),
|
||||
zap.String("remote_port", port),
|
||||
zap.Object("headers", internal.LoggableHTTPHeader{Header: r.Header}),
|
||||
zap.Reflect("headers", r.Header),
|
||||
)
|
||||
if r.TLS != nil {
|
||||
log = log.With(
|
||||
@@ -818,37 +807,11 @@ 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, 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.",
|
||||
})
|
||||
h.handleError(w, r, fmt.Errorf("websocket connections aren't allowed"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -861,9 +824,7 @@ func (h adminHandler) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
_, hasOriginHeader := r.Header["Origin"]
|
||||
_, hasSecHeader := r.Header["Sec-Fetch-Mode"]
|
||||
if h.enforceOrigin || hasOriginHeader || hasSecHeader {
|
||||
if h.enforceOrigin {
|
||||
// cross-site mitigation
|
||||
origin, err := h.checkOrigin(r)
|
||||
if err != nil {
|
||||
@@ -1051,9 +1012,6 @@ func handleConfig(w http.ResponseWriter, r *http.Request) error {
|
||||
buf.Reset()
|
||||
defer bufPool.Put(buf)
|
||||
|
||||
const maxConfigSize = 100 * 1024 * 1024 // 100 MB
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxConfigSize)
|
||||
|
||||
_, err := io.Copy(buf, r.Body)
|
||||
if err != nil {
|
||||
return APIError{
|
||||
@@ -1071,13 +1029,6 @@ func handleConfig(w http.ResponseWriter, r *http.Request) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// If this request changed the config, clear the last
|
||||
// config info we have stored, if it is different from
|
||||
// the original source.
|
||||
ClearLastConfigIfDifferent(
|
||||
r.Header.Get("Caddy-Config-Source-File"),
|
||||
r.Header.Get("Caddy-Config-Source-Adapter"))
|
||||
|
||||
default:
|
||||
return APIError{
|
||||
HTTPStatus: http.StatusMethodNotAllowed,
|
||||
@@ -1136,20 +1087,6 @@ func handleStop(w http.ResponseWriter, r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCanonicalArrayIndex(idx string) (int, error) {
|
||||
if idx == "" {
|
||||
return 0, fmt.Errorf("empty index")
|
||||
}
|
||||
i, err := strconv.Atoi(idx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if strconv.Itoa(i) != idx {
|
||||
return 0, fmt.Errorf("non-canonical array index")
|
||||
}
|
||||
return i, nil
|
||||
}
|
||||
|
||||
// unsyncedConfigAccess traverses into the current config and performs
|
||||
// the operation at path according to method, using body and out as
|
||||
// needed. This is a low-level, unsynchronized function; most callers
|
||||
@@ -1166,10 +1103,7 @@ func unsyncedConfigAccess(method, path string, body []byte, out io.Writer) error
|
||||
if len(body) > 0 {
|
||||
err = json.Unmarshal(body, &val)
|
||||
if err != nil {
|
||||
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)
|
||||
return fmt.Errorf("decoding request body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1211,12 +1145,11 @@ traverseLoop:
|
||||
var idx int
|
||||
if method != http.MethodPost {
|
||||
idxStr := parts[len(parts)-1]
|
||||
idx, err = parseCanonicalArrayIndex(idxStr)
|
||||
idx, err = strconv.Atoi(idxStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[%s] invalid array index '%s': %v",
|
||||
path, idxStr, err)
|
||||
}
|
||||
|
||||
if idx < 0 || (method != http.MethodPut && idx >= len(arr)) || idx > len(arr) {
|
||||
return fmt.Errorf("[%s] array index out of bounds: %s", path, idxStr)
|
||||
}
|
||||
@@ -1316,7 +1249,7 @@ traverseLoop:
|
||||
}
|
||||
|
||||
case []any:
|
||||
partInt, err := parseCanonicalArrayIndex(part)
|
||||
partInt, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[/%s] invalid array index '%s': %v",
|
||||
strings.Join(parts[:i+1], "/"), part, err)
|
||||
|
||||
+28
-246
@@ -15,28 +15,20 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/caddyserver/certmagic"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
var testCfg = []byte(`{
|
||||
@@ -57,13 +49,6 @@ var testCfg = []byte(`{
|
||||
}
|
||||
`)
|
||||
|
||||
type testAdminPublicKey string
|
||||
|
||||
func (k testAdminPublicKey) Equal(x crypto.PublicKey) bool {
|
||||
other, ok := x.(testAdminPublicKey)
|
||||
return ok && k == other
|
||||
}
|
||||
|
||||
func TestUnsyncedConfigAccess(t *testing.T) {
|
||||
// each test is performed in sequence, so
|
||||
// each change builds on the previous ones;
|
||||
@@ -164,9 +149,11 @@ func TestLoadConcurrent(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Go(func() {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
_ = Load(testCfg, true)
|
||||
})
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -220,7 +207,7 @@ func TestETags(t *testing.T) {
|
||||
}
|
||||
|
||||
func BenchmarkLoad(b *testing.B) {
|
||||
for b.Loop() {
|
||||
for i := 0; i < b.N; i++ {
|
||||
Load(testCfg, true)
|
||||
}
|
||||
}
|
||||
@@ -255,51 +242,6 @@ func TestAdminHandlerErrorHandling(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminHandlerServeHTTPRedactsSensitiveHeadersInLogs(t *testing.T) {
|
||||
core, logs := observer.New(zap.InfoLevel)
|
||||
|
||||
defaultLoggerMu.Lock()
|
||||
origLogger := defaultLogger.logger
|
||||
defaultLogger.logger = zap.New(core)
|
||||
defaultLoggerMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
defaultLoggerMu.Lock()
|
||||
defaultLogger.logger = origLogger
|
||||
defaultLoggerMu.Unlock()
|
||||
})
|
||||
|
||||
handler := adminHandler{
|
||||
mux: http.NewServeMux(),
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
req.Header.Set("Cookie", "session=secret")
|
||||
req.Header.Set("X-Test", "ok")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if logs.Len() == 0 {
|
||||
t.Fatal("expected request log entry")
|
||||
}
|
||||
|
||||
ctx := logs.All()[0].ContextMap()
|
||||
headers, ok := ctx["headers"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected headers field in log context, got %T", ctx["headers"])
|
||||
}
|
||||
|
||||
if got := headers["Authorization"]; !reflect.DeepEqual(got, []any{"REDACTED"}) {
|
||||
t.Fatalf("expected redacted Authorization header, got %#v", got)
|
||||
}
|
||||
if got := headers["Cookie"]; !reflect.DeepEqual(got, []any{"REDACTED"}) {
|
||||
t.Fatalf("expected redacted Cookie header, got %#v", got)
|
||||
}
|
||||
if got := headers["X-Test"]; !reflect.DeepEqual(got, []any{"ok"}) {
|
||||
t.Fatalf("expected X-Test header to remain visible, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func initAdminMetrics() {
|
||||
if adminMetrics.requestErrors != nil {
|
||||
prometheus.Unregister(adminMetrics.requestErrors)
|
||||
@@ -335,15 +277,13 @@ func TestAdminHandlerBuiltinRouteErrors(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
// Build the admin handler directly (no listener active)
|
||||
addr, err := ParseNetworkAddress("localhost:2019")
|
||||
err := replaceLocalAdminServer(cfg, Context{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse address: %v", err)
|
||||
}
|
||||
handler, err := cfg.Admin.newAdminHandler(addr, false, Context{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create admin handler: %v", err)
|
||||
t.Fatalf("setting up admin server: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
stopAdminServer(localAdminServer)
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -376,7 +316,7 @@ func TestAdminHandlerBuiltinRouteErrors(t *testing.T) {
|
||||
req := httptest.NewRequest(test.method, fmt.Sprintf("http://localhost:2019%s", test.path), nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
localAdminServer.Handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != test.expectedStatus {
|
||||
t.Errorf("expected status %d but got %d", test.expectedStatus, rr.Code)
|
||||
@@ -464,10 +404,7 @@ func TestNewAdminHandlerRouterRegistration(t *testing.T) {
|
||||
admin := &AdminConfig{
|
||||
EnforceOrigin: false,
|
||||
}
|
||||
handler, err := admin.newAdminHandler(addr, false, Context{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create admin handler: %v", err)
|
||||
}
|
||||
handler := admin.newAdminHandler(addr, false, Context{})
|
||||
|
||||
req := httptest.NewRequest("GET", "/mock", nil)
|
||||
req.Host = "localhost:2019"
|
||||
@@ -479,6 +416,10 @@ func TestNewAdminHandlerRouterRegistration(t *testing.T) {
|
||||
t.Errorf("Expected status code %d but got %d", http.StatusOK, rr.Code)
|
||||
t.Logf("Response body: %s", rr.Body.String())
|
||||
}
|
||||
|
||||
if len(admin.routers) != 1 {
|
||||
t.Errorf("Expected 1 router to be stored, got %d", len(admin.routers))
|
||||
}
|
||||
}
|
||||
|
||||
type mockProvisionableRouter struct {
|
||||
@@ -516,16 +457,19 @@ func TestAdminRouterProvisioning(t *testing.T) {
|
||||
name string
|
||||
provisionErr error
|
||||
wantErr bool
|
||||
routersAfter int // expected number of routers after provisioning
|
||||
}{
|
||||
{
|
||||
name: "successful provisioning",
|
||||
provisionErr: nil,
|
||||
wantErr: false,
|
||||
routersAfter: 0,
|
||||
},
|
||||
{
|
||||
name: "provisioning error",
|
||||
provisionErr: fmt.Errorf("provision failed"),
|
||||
wantErr: true,
|
||||
routersAfter: 1,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -561,7 +505,8 @@ func TestAdminRouterProvisioning(t *testing.T) {
|
||||
t.Fatalf("Failed to parse address: %v", err)
|
||||
}
|
||||
|
||||
_, err = admin.newAdminHandler(addr, false, Context{})
|
||||
_ = admin.newAdminHandler(addr, false, Context{})
|
||||
err = admin.provisionAdminRouters(Context{})
|
||||
|
||||
if test.wantErr {
|
||||
if err == nil {
|
||||
@@ -572,6 +517,10 @@ func TestAdminRouterProvisioning(t *testing.T) {
|
||||
t.Errorf("Expected no error but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(admin.routers) != test.routersAfter {
|
||||
t.Errorf("Expected %d routers after provisioning, got %d", test.routersAfter, len(admin.routers))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -656,99 +605,6 @@ func TestAllowedOriginsUnixSocket(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteAdminAccessControlPathSegmentMatching(t *testing.T) {
|
||||
const authorizedKey testAdminPublicKey = "authorized"
|
||||
peerCert := &x509.Certificate{PublicKey: authorizedKey}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
allowedPath string
|
||||
requestPath string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "exact path",
|
||||
allowedPath: "/pki/ca/prod",
|
||||
requestPath: "/pki/ca/prod",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "subpath",
|
||||
allowedPath: "/pki/ca/prod",
|
||||
requestPath: "/pki/ca/prod/certificates",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "trailing slash subpath",
|
||||
allowedPath: "/pki/ca/prod/",
|
||||
requestPath: "/pki/ca/prod/certificates",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "sibling with shared prefix",
|
||||
allowedPath: "/pki/ca/prod",
|
||||
requestPath: "/pki/ca/prod-backup",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "same segment plus digit",
|
||||
allowedPath: "/pki/ca/prod",
|
||||
requestPath: "/pki/ca/prod1",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
allowedPath: "/",
|
||||
requestPath: "/pki/ca/prod",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
remote := RemoteAdmin{
|
||||
AccessControl: []*AdminAccess{
|
||||
{
|
||||
Permissions: []AdminPermissions{
|
||||
{
|
||||
Methods: []string{http.MethodGet},
|
||||
Paths: []string{test.allowedPath},
|
||||
},
|
||||
},
|
||||
publicKeys: []crypto.PublicKey{authorizedKey},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://localhost:2021"+test.requestPath, nil)
|
||||
req.TLS = &tls.ConnectionState{
|
||||
VerifiedChains: [][]*x509.Certificate{{peerCert}},
|
||||
}
|
||||
|
||||
err := remote.enforceAccessControls(req)
|
||||
if test.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("test %d (%s): allowed path %q, request path %q: expected forbidden error, got nil", i, test.name, test.allowedPath, test.requestPath)
|
||||
return
|
||||
}
|
||||
var apiErr APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("test %d (%s): allowed path %q, request path %q: expected APIError with HTTP status %d, got %T: %v", i, test.name, test.allowedPath, test.requestPath, http.StatusForbidden, err, err)
|
||||
return
|
||||
}
|
||||
if apiErr.HTTPStatus != http.StatusForbidden {
|
||||
t.Errorf("test %d (%s): allowed path %q, request path %q: expected HTTP status %d, got %d", i, test.name, test.allowedPath, test.requestPath, http.StatusForbidden, apiErr.HTTPStatus)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("test %d (%s): allowed path %q, request path %q: expected no error, got %v", i, test.name, test.allowedPath, test.requestPath, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceRemoteAdminServer(t *testing.T) {
|
||||
const testCert = `MIIDCTCCAfGgAwIBAgIUXsqJ1mY8pKlHQtI3HJ23x2eZPqwwDQYJKoZIhvcNAQEL
|
||||
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIzMDEwMTAwMDAwMFoXDTI0MDEw
|
||||
@@ -945,24 +801,8 @@ MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRS0LmTwUT0iwP
|
||||
...
|
||||
-----END PRIVATE KEY-----`)
|
||||
|
||||
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)
|
||||
testStorage := certmagic.FileStorage{Path: t.TempDir()}
|
||||
err := testStorage.Store(context.Background(), "localhost/localhost.crt", certPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1024,7 +864,7 @@ MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRS0LmTwUT0iwP
|
||||
},
|
||||
},
|
||||
},
|
||||
storage: &testStorage,
|
||||
storage: &certmagic.FileStorage{Path: "testdata"},
|
||||
},
|
||||
checkState: func(t *testing.T, cfg *Config) {
|
||||
if len(cfg.Admin.Identity.issuers) != 1 {
|
||||
@@ -1062,13 +902,6 @@ 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(),
|
||||
@@ -1076,13 +909,6 @@ 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 {
|
||||
@@ -1101,47 +927,3 @@ MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRS0LmTwUT0iwP
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsyncedConfigAccessCanonicalArrayIndices(t *testing.T) {
|
||||
rawCfg = map[string]any{
|
||||
rawConfigKey: map[string]any{
|
||||
"list": []any{"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
wantOutput string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "allow zero", path: "/" + rawConfigKey + "/list/0", wantOutput: "\"zero\"\n"},
|
||||
{name: "allow one", path: "/" + rawConfigKey + "/list/1", wantOutput: "\"one\"\n"},
|
||||
{name: "allow ten", path: "/" + rawConfigKey + "/list/10", wantOutput: "\"ten\"\n"},
|
||||
{name: "reject leading zero", path: "/" + rawConfigKey + "/list/01", wantErr: true},
|
||||
{name: "reject multiple leading zeros", path: "/" + rawConfigKey + "/list/002", wantErr: true},
|
||||
{name: "reject plus sign", path: "/" + rawConfigKey + "/list/+1", wantErr: true},
|
||||
{name: "reject negative zero", path: "/" + rawConfigKey + "/list/-0", wantErr: true},
|
||||
}
|
||||
|
||||
for i, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var gotOutput bytes.Buffer
|
||||
err := unsyncedConfigAccess(http.MethodGet, tc.path, nil, &gotOutput)
|
||||
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("test %d (%s): input path %q: expected error, got nil with output %q", i, tc.name, tc.path, gotOutput.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("test %d (%s): input path %q: expected no error with output %q, got error %v with output %q", i, tc.name, tc.path, tc.wantOutput, err, gotOutput.String())
|
||||
}
|
||||
if gotOutput.String() != tc.wantOutput {
|
||||
t.Errorf("test %d (%s): input path %q: expected output %q, got %q", i, tc.name, tc.path, tc.wantOutput, gotOutput.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,377 +0,0 @@
|
||||
// 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 caddy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAPIError_Error_WithErr(t *testing.T) {
|
||||
underlyingErr := errors.New("underlying error")
|
||||
apiErr := APIError{
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
Err: underlyingErr,
|
||||
Message: "API error message",
|
||||
}
|
||||
|
||||
result := apiErr.Error()
|
||||
expected := "underlying error"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%s'", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIError_Error_WithoutErr(t *testing.T) {
|
||||
apiErr := APIError{
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
Err: nil,
|
||||
Message: "API error message",
|
||||
}
|
||||
|
||||
result := apiErr.Error()
|
||||
expected := "API error message"
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%s'", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIError_Error_BothNil(t *testing.T) {
|
||||
apiErr := APIError{
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
Err: nil,
|
||||
Message: "",
|
||||
}
|
||||
|
||||
result := apiErr.Error()
|
||||
expected := ""
|
||||
|
||||
if result != expected {
|
||||
t.Errorf("Expected empty string, got '%s'", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIError_JSON_Serialization(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
apiErr APIError
|
||||
}{
|
||||
{
|
||||
name: "with message only",
|
||||
apiErr: APIError{
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
Message: "validation failed",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with underlying error only",
|
||||
apiErr: APIError{
|
||||
HTTPStatus: http.StatusInternalServerError,
|
||||
Err: errors.New("internal error"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with both message and error",
|
||||
apiErr: APIError{
|
||||
HTTPStatus: http.StatusConflict,
|
||||
Err: errors.New("underlying"),
|
||||
Message: "conflict detected",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minimal error",
|
||||
apiErr: APIError{
|
||||
HTTPStatus: http.StatusNotFound,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
// Marshal to JSON
|
||||
jsonData, err := json.Marshal(test.apiErr)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal APIError: %v", err)
|
||||
}
|
||||
|
||||
// Unmarshal back
|
||||
var unmarshaled APIError
|
||||
err = json.Unmarshal(jsonData, &unmarshaled)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal APIError: %v", err)
|
||||
}
|
||||
|
||||
// Only Message field should survive JSON round-trip
|
||||
// HTTPStatus and Err are marked with json:"-"
|
||||
if unmarshaled.Message != test.apiErr.Message {
|
||||
t.Errorf("Message mismatch: expected '%s', got '%s'",
|
||||
test.apiErr.Message, unmarshaled.Message)
|
||||
}
|
||||
|
||||
// HTTPStatus and Err should be zero values after unmarshal
|
||||
if unmarshaled.HTTPStatus != 0 {
|
||||
t.Errorf("HTTPStatus should be 0 after unmarshal, got %d", unmarshaled.HTTPStatus)
|
||||
}
|
||||
if unmarshaled.Err != nil {
|
||||
t.Errorf("Err should be nil after unmarshal, got %v", unmarshaled.Err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIError_HTTPStatus_Values(t *testing.T) {
|
||||
// Test common HTTP status codes
|
||||
statusCodes := []int{
|
||||
http.StatusBadRequest,
|
||||
http.StatusUnauthorized,
|
||||
http.StatusForbidden,
|
||||
http.StatusNotFound,
|
||||
http.StatusMethodNotAllowed,
|
||||
http.StatusConflict,
|
||||
http.StatusPreconditionFailed,
|
||||
http.StatusInternalServerError,
|
||||
http.StatusNotImplemented,
|
||||
http.StatusServiceUnavailable,
|
||||
}
|
||||
|
||||
for _, status := range statusCodes {
|
||||
t.Run(fmt.Sprintf("status_%d", status), func(t *testing.T) {
|
||||
apiErr := APIError{
|
||||
HTTPStatus: status,
|
||||
Message: http.StatusText(status),
|
||||
}
|
||||
|
||||
if apiErr.HTTPStatus != status {
|
||||
t.Errorf("Expected status %d, got %d", status, apiErr.HTTPStatus)
|
||||
}
|
||||
|
||||
// Test that error message is reasonable
|
||||
if apiErr.Message == "" && status >= 400 {
|
||||
t.Errorf("Status %d should have a message", status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIError_ErrorInterface_Compliance(t *testing.T) {
|
||||
// Verify APIError properly implements error interface
|
||||
var err error = APIError{
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
Message: "test error",
|
||||
}
|
||||
|
||||
errorMsg := err.Error()
|
||||
if errorMsg != "test error" {
|
||||
t.Errorf("Expected 'test error', got '%s'", errorMsg)
|
||||
}
|
||||
|
||||
// Test with underlying error
|
||||
underlyingErr := errors.New("underlying")
|
||||
err2 := APIError{
|
||||
HTTPStatus: http.StatusInternalServerError,
|
||||
Err: underlyingErr,
|
||||
Message: "wrapper",
|
||||
}
|
||||
|
||||
if err2.Error() != "underlying" {
|
||||
t.Errorf("Expected 'underlying', got '%s'", err2.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIError_JSON_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "empty message",
|
||||
message: "",
|
||||
},
|
||||
{
|
||||
name: "unicode message",
|
||||
message: "Error: 🚨 Something went wrong! 你好",
|
||||
},
|
||||
{
|
||||
name: "json characters in message",
|
||||
message: `Error with "quotes" and {brackets}`,
|
||||
},
|
||||
{
|
||||
name: "newlines in message",
|
||||
message: "Line 1\nLine 2\r\nLine 3",
|
||||
},
|
||||
{
|
||||
name: "very long message",
|
||||
message: string(make([]byte, 10000)), // 10KB message
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
apiErr := APIError{
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
Message: test.message,
|
||||
}
|
||||
|
||||
// Should be JSON serializable
|
||||
jsonData, err := json.Marshal(apiErr)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to marshal APIError: %v", err)
|
||||
}
|
||||
|
||||
// Should be deserializable
|
||||
var unmarshaled APIError
|
||||
err = json.Unmarshal(jsonData, &unmarshaled)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal APIError: %v", err)
|
||||
}
|
||||
|
||||
if unmarshaled.Message != test.message {
|
||||
t.Errorf("Message corrupted during JSON round-trip")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIError_Chaining(t *testing.T) {
|
||||
// Test error chaining scenarios
|
||||
rootErr := errors.New("root cause")
|
||||
wrappedErr := fmt.Errorf("wrapped: %w", rootErr)
|
||||
|
||||
apiErr := APIError{
|
||||
HTTPStatus: http.StatusInternalServerError,
|
||||
Err: wrappedErr,
|
||||
Message: "API wrapper",
|
||||
}
|
||||
|
||||
// Error() should return the underlying error message
|
||||
if apiErr.Error() != wrappedErr.Error() {
|
||||
t.Errorf("Expected underlying error message, got '%s'", apiErr.Error())
|
||||
}
|
||||
|
||||
// Should be able to unwrap
|
||||
if !errors.Is(apiErr.Err, rootErr) {
|
||||
t.Error("Should be able to unwrap to root cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIError_StatusCode_Boundaries(t *testing.T) {
|
||||
// Test edge cases for HTTP status codes
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
name: "negative status",
|
||||
status: -1,
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "zero status",
|
||||
status: 0,
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "valid 1xx",
|
||||
status: http.StatusContinue,
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "valid 2xx",
|
||||
status: http.StatusOK,
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "valid 4xx",
|
||||
status: http.StatusBadRequest,
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "valid 5xx",
|
||||
status: http.StatusInternalServerError,
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "too large status",
|
||||
status: 9999,
|
||||
valid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := APIError{
|
||||
HTTPStatus: test.status,
|
||||
Message: "test",
|
||||
}
|
||||
|
||||
// The struct allows any int value, but we can test
|
||||
// if it's a valid HTTP status
|
||||
statusText := http.StatusText(test.status)
|
||||
isValidStatus := statusText != ""
|
||||
|
||||
if isValidStatus != test.valid {
|
||||
t.Errorf("Status %d validity: expected %v, got %v",
|
||||
test.status, test.valid, isValidStatus)
|
||||
}
|
||||
|
||||
// Verify the struct holds the status
|
||||
if err.HTTPStatus != test.status {
|
||||
t.Errorf("Status not preserved: expected %d, got %d", test.status, err.HTTPStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAPIError_Error(b *testing.B) {
|
||||
apiErr := APIError{
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
Err: errors.New("benchmark error"),
|
||||
Message: "benchmark message",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
apiErr.Error()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAPIError_JSON_Marshal(b *testing.B) {
|
||||
apiErr := APIError{
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
Err: errors.New("benchmark error"),
|
||||
Message: "benchmark message",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
json.Marshal(apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAPIError_JSON_Unmarshal(b *testing.B) {
|
||||
jsonData := []byte(`{"error": "benchmark message"}`)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var result APIError
|
||||
_ = json.Unmarshal(jsonData, &result)
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ type Config struct {
|
||||
storage certmagic.Storage
|
||||
eventEmitter eventEmitter
|
||||
|
||||
cancelFunc context.CancelCauseFunc
|
||||
cancelFunc context.CancelFunc
|
||||
|
||||
// fileSystems is a dict of fileSystems that will later be loaded from and added to.
|
||||
fileSystems FileSystems
|
||||
@@ -127,9 +127,10 @@ func Load(cfgJSON []byte, forceReload bool) error {
|
||||
zap.Error(notifyErr),
|
||||
zap.String("reload_err", err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
if notifyErr := notify.Ready(); notifyErr != nil {
|
||||
Log().Error("unable to notify to service manager of ready state", zap.Error(notifyErr))
|
||||
if err := notify.Ready(); err != nil {
|
||||
Log().Error("unable to notify to service manager of ready state", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -146,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 is returned. This function
|
||||
// is safe for concurrent use.
|
||||
// forcefully reloaded, then errConfigUnchanged This function is safe for
|
||||
// concurrent use.
|
||||
// The ifMatchHeader can optionally be given a string of the format:
|
||||
//
|
||||
// "<path> <hash>"
|
||||
@@ -226,18 +227,8 @@ 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.StatusBadRequest,
|
||||
HTTPStatus: http.StatusInternalServerError,
|
||||
Err: fmt.Errorf("indexing config: %v", err),
|
||||
}
|
||||
}
|
||||
@@ -257,8 +248,6 @@ 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)
|
||||
@@ -292,19 +281,14 @@ 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:
|
||||
idStr = idVal
|
||||
index[idVal] = configPath
|
||||
case float64: // all JSON numbers decode as float64
|
||||
idStr = fmt.Sprintf("%v", idVal)
|
||||
index[fmt.Sprintf("%v", idVal)] = configPath
|
||||
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
|
||||
@@ -432,7 +416,7 @@ func run(newCfg *Config, start bool) (Context, error) {
|
||||
// partially copied from provisionContext
|
||||
if err != nil {
|
||||
globalMetrics.configSuccess.Set(0)
|
||||
ctx.cfg.cancelFunc(fmt.Errorf("configuration start error: %w", err))
|
||||
ctx.cfg.cancelFunc()
|
||||
|
||||
if currentCtx.cfg != nil {
|
||||
certmagic.Default.Storage = currentCtx.cfg.storage
|
||||
@@ -440,6 +424,13 @@ func run(newCfg *Config, start bool) (Context, error) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Provision any admin routers which may need to access
|
||||
// some of the other apps at runtime
|
||||
err = ctx.cfg.Admin.provisionAdminRouters(ctx)
|
||||
if err != nil {
|
||||
return ctx, err
|
||||
}
|
||||
|
||||
// Start
|
||||
err = func() error {
|
||||
started := make([]string, 0, len(ctx.cfg.apps))
|
||||
@@ -501,7 +492,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, cancelCause := NewContextWithCause(Context{Context: context.Background(), cfg: newCfg})
|
||||
ctx, cancel := NewContext(Context{Context: context.Background(), cfg: newCfg})
|
||||
defer func() {
|
||||
if err != nil {
|
||||
globalMetrics.configSuccess.Set(0)
|
||||
@@ -510,7 +501,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
|
||||
cancelCause(fmt.Errorf("configuration error: %w", err))
|
||||
cancel()
|
||||
|
||||
// also undo any other state changes we made
|
||||
if currentCtx.cfg != nil {
|
||||
@@ -518,7 +509,7 @@ func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error)
|
||||
}
|
||||
}
|
||||
}()
|
||||
newCfg.cancelFunc = cancelCause // clean up later
|
||||
newCfg.cancelFunc = cancel // clean up later
|
||||
|
||||
// set up logging before anything bad happens
|
||||
if newCfg.Logging == nil {
|
||||
@@ -738,7 +729,7 @@ func unsyncedStop(ctx Context) {
|
||||
}
|
||||
|
||||
// clean up all modules
|
||||
ctx.cfg.cancelFunc(fmt.Errorf("stopping apps"))
|
||||
ctx.cfg.cancelFunc()
|
||||
}
|
||||
|
||||
// Validate loads, provisions, and validates
|
||||
@@ -746,7 +737,7 @@ func unsyncedStop(ctx Context) {
|
||||
func Validate(cfg *Config) error {
|
||||
_, err := run(cfg, false)
|
||||
if err == nil {
|
||||
cfg.cancelFunc(fmt.Errorf("validation complete")) // call Cleanup on all modules
|
||||
cfg.cancelFunc() // call Cleanup on all modules
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -759,7 +750,7 @@ func Validate(cfg *Config) error {
|
||||
// code is emitted.
|
||||
func exitProcess(ctx context.Context, logger *zap.Logger) {
|
||||
// let the rest of the program know we're quitting; only do it once
|
||||
if !exiting.CompareAndSwap(false, true) {
|
||||
if !atomic.CompareAndSwapInt32(exiting, 0, 1) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -838,11 +829,11 @@ func exitProcess(ctx context.Context, logger *zap.Logger) {
|
||||
}()
|
||||
}
|
||||
|
||||
var exiting atomic.Bool
|
||||
var exiting = new(int32) // accessed atomically
|
||||
|
||||
// Exiting returns true if the process is exiting.
|
||||
// EXPERIMENTAL API: subject to change or removal.
|
||||
func Exiting() bool { return exiting.Load() }
|
||||
func Exiting() bool { return atomic.LoadInt32(exiting) == 1 }
|
||||
|
||||
// OnExit registers a callback to invoke during process exit.
|
||||
// This registration is PROCESS-GLOBAL, meaning that each
|
||||
@@ -954,34 +945,6 @@ 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
|
||||
@@ -1012,11 +975,11 @@ func Version() (simple, full string) {
|
||||
if CustomVersion != "" {
|
||||
full = CustomVersion
|
||||
simple = CustomVersion
|
||||
return simple, full
|
||||
return
|
||||
}
|
||||
full = "unknown"
|
||||
simple = "unknown"
|
||||
return simple, full
|
||||
return
|
||||
}
|
||||
// find the Caddy module in the dependency list
|
||||
for _, dep := range bi.Deps {
|
||||
@@ -1096,7 +1059,7 @@ func Version() (simple, full string) {
|
||||
}
|
||||
}
|
||||
|
||||
return simple, full
|
||||
return
|
||||
}
|
||||
|
||||
// Event represents something that has happened or is happening.
|
||||
@@ -1129,7 +1092,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 instead.
|
||||
// event, call Emit() on the current instance of the caddyevents app insteaad.
|
||||
//
|
||||
// EXPERIMENTAL: Subject to change.
|
||||
func NewEvent(ctx Context, name string, data map[string]any) (Event, error) {
|
||||
@@ -1234,91 +1197,6 @@ var (
|
||||
rawCfgMu sync.RWMutex
|
||||
)
|
||||
|
||||
// lastConfigFile and lastConfigAdapter remember the source config
|
||||
// file and adapter used when Caddy was started via the CLI "run" command.
|
||||
// These are consulted by the SIGUSR1 handler to attempt reloading from
|
||||
// the same source. They are intentionally not set for other entrypoints
|
||||
// such as "caddy start" or subcommands like file-server.
|
||||
var (
|
||||
lastConfigMu sync.RWMutex
|
||||
lastConfigFile string
|
||||
lastConfigAdapter string
|
||||
)
|
||||
|
||||
// reloadFromSourceFunc is the type of stored callback
|
||||
// which is called when we receive a SIGUSR1 signal.
|
||||
type reloadFromSourceFunc func(file, adapter string) error
|
||||
|
||||
// reloadFromSourceCallback is the stored callback
|
||||
// which is called when we receive a SIGUSR1 signal.
|
||||
var reloadFromSourceCallback reloadFromSourceFunc
|
||||
|
||||
// errReloadFromSourceUnavailable is returned when no reload-from-source callback is set.
|
||||
var errReloadFromSourceUnavailable = errors.New("reload from source unavailable in this process") //nolint:unused
|
||||
|
||||
// SetLastConfig records the given source file and adapter as the
|
||||
// last-known external configuration source. Intended to be called
|
||||
// only when starting via "caddy run --config <file> --adapter <adapter>".
|
||||
func SetLastConfig(file, adapter string, fn reloadFromSourceFunc) {
|
||||
lastConfigMu.Lock()
|
||||
lastConfigFile = file
|
||||
lastConfigAdapter = adapter
|
||||
reloadFromSourceCallback = fn
|
||||
lastConfigMu.Unlock()
|
||||
}
|
||||
|
||||
// ClearLastConfigIfDifferent clears the recorded last-config if the provided
|
||||
// source file/adapter do not match the recorded last-config. If both srcFile
|
||||
// and srcAdapter are empty, the last-config is cleared.
|
||||
func ClearLastConfigIfDifferent(srcFile, srcAdapter string) {
|
||||
if (srcFile != "" || srcAdapter != "") && lastConfigMatches(srcFile, srcAdapter) {
|
||||
return
|
||||
}
|
||||
SetLastConfig("", "", nil)
|
||||
}
|
||||
|
||||
// getLastConfig returns the last-known config file and adapter.
|
||||
func getLastConfig() (file, adapter string, fn reloadFromSourceFunc) {
|
||||
lastConfigMu.RLock()
|
||||
f, a, cb := lastConfigFile, lastConfigAdapter, reloadFromSourceCallback
|
||||
lastConfigMu.RUnlock()
|
||||
return f, a, cb
|
||||
}
|
||||
|
||||
// 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.
|
||||
func lastConfigMatches(srcFile, srcAdapter string) bool {
|
||||
lf, la, _ := getLastConfig()
|
||||
|
||||
// If adapter is provided, it must match.
|
||||
if srcAdapter != "" && srcAdapter != la {
|
||||
return false
|
||||
}
|
||||
|
||||
// Quick equality check.
|
||||
if srcFile == lf {
|
||||
return true
|
||||
}
|
||||
|
||||
// Try absolute path comparison.
|
||||
sAbs, sErr := filepath.Abs(srcFile)
|
||||
lAbs, lErr := filepath.Abs(lf)
|
||||
if sErr == nil && lErr == nil && sAbs == lAbs {
|
||||
return true
|
||||
}
|
||||
|
||||
// Final fallback: basename equality.
|
||||
if filepath.Base(srcFile) == filepath.Base(lf) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// errSameConfig is returned if the new config is the same
|
||||
// as the old one. This isn't usually an actual, actionable
|
||||
// error; it's mostly a sentinel value.
|
||||
|
||||
@@ -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 := range targets {
|
||||
for i := 0; i < len(targets); i++ {
|
||||
if !d.NextArg() {
|
||||
return false
|
||||
}
|
||||
@@ -308,9 +308,9 @@ func (d *Dispenser) CountRemainingArgs() int {
|
||||
}
|
||||
|
||||
// RemainingArgs loads any more arguments (tokens on the same line)
|
||||
// into a slice of strings and returns them. Open curly brace tokens
|
||||
// also indicate the end of arguments, and the curly brace is not
|
||||
// included in the return value nor is it loaded.
|
||||
// into a slice and returns them. Open curly brace tokens also indicate
|
||||
// the end of arguments, and the curly brace is not included in
|
||||
// the return value nor is it loaded.
|
||||
func (d *Dispenser) RemainingArgs() []string {
|
||||
var args []string
|
||||
for d.NextArg() {
|
||||
@@ -320,9 +320,9 @@ func (d *Dispenser) RemainingArgs() []string {
|
||||
}
|
||||
|
||||
// RemainingArgsRaw loads any more arguments (tokens on the same line,
|
||||
// retaining quotes) into a slice of strings and returns them.
|
||||
// Open curly brace tokens also indicate the end of arguments,
|
||||
// and the curly brace is not included in the return value nor is it loaded.
|
||||
// retaining quotes) into a slice and returns them. Open curly brace
|
||||
// tokens also indicate the end of arguments, and the curly brace is
|
||||
// not included in the return value nor is it loaded.
|
||||
func (d *Dispenser) RemainingArgsRaw() []string {
|
||||
var args []string
|
||||
for d.NextArg() {
|
||||
@@ -331,18 +331,6 @@ func (d *Dispenser) RemainingArgsRaw() []string {
|
||||
return args
|
||||
}
|
||||
|
||||
// RemainingArgsAsTokens loads any more arguments (tokens on the same line)
|
||||
// into a slice of Token-structs and returns them. Open curly brace tokens
|
||||
// also indicate the end of arguments, and the curly brace is not included
|
||||
// in the return value nor is it loaded.
|
||||
func (d *Dispenser) RemainingArgsAsTokens() []Token {
|
||||
var args []Token
|
||||
for d.NextArg() {
|
||||
args = append(args, d.Token())
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// NewFromNextSegment returns a new dispenser with a copy of
|
||||
// the tokens from the current token until the end of the
|
||||
// "directive" whether that be to the end of the line or
|
||||
|
||||
@@ -274,66 +274,6 @@ func TestDispenser_RemainingArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispenser_RemainingArgsAsTokens(t *testing.T) {
|
||||
input := `dir1 arg1 arg2 arg3
|
||||
dir2 arg4 arg5
|
||||
dir3 arg6 { arg7
|
||||
dir4`
|
||||
d := NewTestDispenser(input)
|
||||
|
||||
d.Next() // dir1
|
||||
|
||||
args := d.RemainingArgsAsTokens()
|
||||
|
||||
tokenTexts := make([]string, 0, len(args))
|
||||
for _, arg := range args {
|
||||
tokenTexts = append(tokenTexts, arg.Text)
|
||||
}
|
||||
|
||||
if expected := []string{"arg1", "arg2", "arg3"}; !reflect.DeepEqual(tokenTexts, expected) {
|
||||
t.Errorf("RemainingArgsAsTokens(): Expected %v, got %v", expected, tokenTexts)
|
||||
}
|
||||
|
||||
d.Next() // dir2
|
||||
|
||||
args = d.RemainingArgsAsTokens()
|
||||
|
||||
tokenTexts = tokenTexts[:0]
|
||||
for _, arg := range args {
|
||||
tokenTexts = append(tokenTexts, arg.Text)
|
||||
}
|
||||
|
||||
if expected := []string{"arg4", "arg5"}; !reflect.DeepEqual(tokenTexts, expected) {
|
||||
t.Errorf("RemainingArgsAsTokens(): Expected %v, got %v", expected, tokenTexts)
|
||||
}
|
||||
|
||||
d.Next() // dir3
|
||||
|
||||
args = d.RemainingArgsAsTokens()
|
||||
tokenTexts = tokenTexts[:0]
|
||||
for _, arg := range args {
|
||||
tokenTexts = append(tokenTexts, arg.Text)
|
||||
}
|
||||
|
||||
if expected := []string{"arg6"}; !reflect.DeepEqual(tokenTexts, expected) {
|
||||
t.Errorf("RemainingArgsAsTokens(): Expected %v, got %v", expected, tokenTexts)
|
||||
}
|
||||
|
||||
d.Next() // {
|
||||
d.Next() // arg7
|
||||
d.Next() // dir4
|
||||
|
||||
args = d.RemainingArgsAsTokens()
|
||||
tokenTexts = tokenTexts[:0]
|
||||
for _, arg := range args {
|
||||
tokenTexts = append(tokenTexts, arg.Text)
|
||||
}
|
||||
|
||||
if len(args) != 0 {
|
||||
t.Errorf("RemainingArgsAsTokens(): Expected %v, got %v", []string{}, tokenTexts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispenser_ArgErr_Err(t *testing.T) {
|
||||
input := `dir1 {
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"bytes"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
@@ -53,43 +52,19 @@ func Format(input []byte) []byte {
|
||||
|
||||
newLines int // count of newlines consumed
|
||||
|
||||
comment bool // whether we're in a comment
|
||||
quotes string // encountered quotes ('', '`', '"', '"`', '`"')
|
||||
escaped bool // whether current char is escaped
|
||||
comment bool // whether we're in a comment
|
||||
quoted bool // whether we're in a quoted segment
|
||||
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
|
||||
|
||||
currentToken strings.Builder
|
||||
currentLineFirstToken string
|
||||
previousLineWasTopLevelImport bool
|
||||
openBraceOwnLine bool
|
||||
nesting int // indentation level
|
||||
withinBackquote bool
|
||||
)
|
||||
|
||||
finishToken := func() {
|
||||
if currentToken.Len() == 0 {
|
||||
return
|
||||
}
|
||||
if currentLineFirstToken == "" {
|
||||
currentLineFirstToken = currentToken.String()
|
||||
}
|
||||
currentToken.Reset()
|
||||
}
|
||||
|
||||
finishLine := func() {
|
||||
finishToken()
|
||||
if currentLineFirstToken != "" {
|
||||
previousLineWasTopLevelImport = nesting == 0 && currentLineFirstToken == "import"
|
||||
} else if !openBrace || !openBraceOwnLine || openBraceWritten {
|
||||
previousLineWasTopLevelImport = false
|
||||
}
|
||||
currentLineFirstToken = ""
|
||||
}
|
||||
|
||||
write := func(ch rune) {
|
||||
out.WriteRune(ch)
|
||||
last = ch
|
||||
@@ -114,8 +89,12 @@ func Format(input []byte) []byte {
|
||||
}
|
||||
panic(err)
|
||||
}
|
||||
if ch == '`' {
|
||||
withinBackquote = !withinBackquote
|
||||
}
|
||||
|
||||
// detect whether we have the start of a heredoc
|
||||
if quotes == "" && (heredoc == heredocClosed && !heredocEscaped) &&
|
||||
if !quoted && (heredoc == heredocClosed && !heredocEscaped) &&
|
||||
space && last == '<' && ch == '<' {
|
||||
write(ch)
|
||||
heredoc = heredocOpening
|
||||
@@ -201,55 +180,22 @@ func Format(input []byte) []byte {
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == '`' {
|
||||
switch quotes {
|
||||
case "\"`":
|
||||
quotes = "\""
|
||||
case "`":
|
||||
quotes = ""
|
||||
case "\"":
|
||||
quotes = "\"`"
|
||||
default:
|
||||
quotes = "`"
|
||||
}
|
||||
}
|
||||
|
||||
if quotes == "\"" {
|
||||
if quoted {
|
||||
if ch == '"' {
|
||||
quotes = ""
|
||||
quoted = false
|
||||
}
|
||||
write(ch)
|
||||
continue
|
||||
}
|
||||
|
||||
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 space && ch == '"' {
|
||||
quoted = true
|
||||
}
|
||||
|
||||
if unicode.IsSpace(ch) {
|
||||
finishToken()
|
||||
space = true
|
||||
heredocEscaped = false
|
||||
if ch == '\n' {
|
||||
finishLine()
|
||||
newLines++
|
||||
}
|
||||
continue
|
||||
@@ -276,19 +222,13 @@ func Format(input []byte) []byte {
|
||||
}
|
||||
|
||||
openBrace = false
|
||||
if openBraceOwnLine && previousLineWasTopLevelImport {
|
||||
if last != '\n' {
|
||||
nextLine()
|
||||
}
|
||||
if beginningOfLine {
|
||||
indent()
|
||||
} else if beginningOfLine {
|
||||
indent()
|
||||
} else if !openBraceSpace || !unicode.IsSpace(last) {
|
||||
} else if !openBraceSpace {
|
||||
write(' ')
|
||||
}
|
||||
write('{')
|
||||
openBraceWritten = true
|
||||
openBraceOwnLine = false
|
||||
nextLine()
|
||||
newLines = 0
|
||||
// prevent infinite nesting from ridiculous inputs (issue #4169)
|
||||
@@ -299,25 +239,21 @@ func Format(input []byte) []byte {
|
||||
|
||||
switch {
|
||||
case ch == '{':
|
||||
finishToken()
|
||||
openBrace = true
|
||||
openBraceSpace = spacePrior && !beginningOfLine
|
||||
openBraceOwnLine = newLines > 0
|
||||
if openBraceSpace && newLines == 0 {
|
||||
if openBraceSpace {
|
||||
write(' ')
|
||||
}
|
||||
openBraceWritten = false
|
||||
if quotes == "`" {
|
||||
if withinBackquote {
|
||||
write('{')
|
||||
openBraceWritten = true
|
||||
openBraceOwnLine = false
|
||||
continue
|
||||
}
|
||||
continue
|
||||
|
||||
case ch == '}' && (spacePrior || !openBrace):
|
||||
finishToken()
|
||||
if quotes == "`" {
|
||||
if withinBackquote {
|
||||
write('}')
|
||||
continue
|
||||
}
|
||||
@@ -361,7 +297,6 @@ func Format(input []byte) []byte {
|
||||
space = true
|
||||
}
|
||||
|
||||
currentToken.WriteRune(ch)
|
||||
write(ch)
|
||||
|
||||
beginningOfLine = false
|
||||
|
||||
@@ -444,52 +444,6 @@ 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: `{
|
||||
a
|
||||
|
||||
{$ENV_VAR}
|
||||
}
|
||||
`,
|
||||
expect: `{
|
||||
a
|
||||
|
||||
{$ENV_VAR}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
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}",
|
||||
},
|
||||
{
|
||||
description: "imports before global options block keep standalone brace",
|
||||
input: `import ./conf.d/matcher_my_subnet.caddy
|
||||
import ./conf.d/matcher_not_my_subnet.caddy
|
||||
{
|
||||
order crowdsec first
|
||||
order appsec after crowdsec
|
||||
}`,
|
||||
expect: `import ./conf.d/matcher_my_subnet.caddy
|
||||
import ./conf.d/matcher_not_my_subnet.caddy
|
||||
{
|
||||
order crowdsec first
|
||||
order appsec after crowdsec
|
||||
}`,
|
||||
},
|
||||
} {
|
||||
// the formatter should output a trailing newline,
|
||||
// even if the tests aren't written to expect that
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
package caddyfile
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestImportGraphAddNode(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
|
||||
g.addNode("a")
|
||||
if !g.exists("a") {
|
||||
t.Error("expected node 'a' to exist after addNode")
|
||||
}
|
||||
|
||||
// Adding again should not error
|
||||
g.addNode("a")
|
||||
if !g.exists("a") {
|
||||
t.Error("expected node 'a' to still exist after duplicate addNode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphAddNodes(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
|
||||
g.addNodes([]string{"a", "b", "c"})
|
||||
for _, name := range []string{"a", "b", "c"} {
|
||||
if !g.exists(name) {
|
||||
t.Errorf("expected node %q to exist", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphRemoveNode(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
|
||||
g.addNode("a")
|
||||
g.addNode("b")
|
||||
g.removeNode("a")
|
||||
|
||||
if g.exists("a") {
|
||||
t.Error("expected node 'a' to not exist after removeNode")
|
||||
}
|
||||
if !g.exists("b") {
|
||||
t.Error("expected node 'b' to still exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphRemoveNodes(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
|
||||
g.addNodes([]string{"a", "b", "c", "d"})
|
||||
g.removeNodes([]string{"a", "c"})
|
||||
|
||||
if g.exists("a") {
|
||||
t.Error("expected node 'a' to be removed")
|
||||
}
|
||||
if g.exists("c") {
|
||||
t.Error("expected node 'c' to be removed")
|
||||
}
|
||||
if !g.exists("b") {
|
||||
t.Error("expected node 'b' to still exist")
|
||||
}
|
||||
if !g.exists("d") {
|
||||
t.Error("expected node 'd' to still exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphAddEdge(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
g.addNodes([]string{"a", "b"})
|
||||
|
||||
err := g.addEdge("a", "b")
|
||||
if err != nil {
|
||||
t.Fatalf("addEdge() error = %v", err)
|
||||
}
|
||||
|
||||
if !g.areConnected("a", "b") {
|
||||
t.Error("expected 'a' -> 'b' edge to exist")
|
||||
}
|
||||
if g.areConnected("b", "a") {
|
||||
t.Error("expected no 'b' -> 'a' edge (directed)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphAddEdgeNonExistentNode(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
g.addNode("a")
|
||||
|
||||
err := g.addEdge("a", "nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error when adding edge to nonexistent node")
|
||||
}
|
||||
|
||||
err = g.addEdge("nonexistent", "a")
|
||||
if err == nil {
|
||||
t.Error("expected error when adding edge from nonexistent node")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphAddEdgeDuplicate(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
g.addNodes([]string{"a", "b"})
|
||||
|
||||
_ = g.addEdge("a", "b")
|
||||
err := g.addEdge("a", "b")
|
||||
if err != nil {
|
||||
t.Errorf("duplicate addEdge() should not error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphCycleDetectionDirect(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
g.addNodes([]string{"a", "b"})
|
||||
|
||||
_ = g.addEdge("a", "b")
|
||||
|
||||
// Adding b -> a should create a cycle
|
||||
err := g.addEdge("b", "a")
|
||||
if err == nil {
|
||||
t.Error("expected error for cycle: a -> b -> a")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphCycleDetectionIndirect(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
g.addNodes([]string{"a", "b", "c"})
|
||||
|
||||
_ = g.addEdge("a", "b")
|
||||
_ = g.addEdge("b", "c")
|
||||
|
||||
// Adding c -> a should create a cycle: a -> b -> c -> a
|
||||
err := g.addEdge("c", "a")
|
||||
if err == nil {
|
||||
t.Error("expected error for indirect cycle: a -> b -> c -> a")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphCycleDetectionLongChain(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
nodes := []string{"a", "b", "c", "d", "e"}
|
||||
g.addNodes(nodes)
|
||||
|
||||
_ = g.addEdge("a", "b")
|
||||
_ = g.addEdge("b", "c")
|
||||
_ = g.addEdge("c", "d")
|
||||
_ = g.addEdge("d", "e")
|
||||
|
||||
// Adding e -> a should create a cycle
|
||||
err := g.addEdge("e", "a")
|
||||
if err == nil {
|
||||
t.Error("expected error for long cycle: a -> b -> c -> d -> e -> a")
|
||||
}
|
||||
|
||||
// Adding e -> c should also create a cycle
|
||||
err = g.addEdge("e", "c")
|
||||
if err == nil {
|
||||
t.Error("expected error for cycle: c -> d -> e -> c")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphNoCycleDAG(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
g.addNodes([]string{"a", "b", "c", "d"})
|
||||
|
||||
// Create a diamond DAG: a -> b, a -> c, b -> d, c -> d
|
||||
_ = g.addEdge("a", "b")
|
||||
_ = g.addEdge("a", "c")
|
||||
_ = g.addEdge("b", "d")
|
||||
|
||||
err := g.addEdge("c", "d")
|
||||
if err != nil {
|
||||
t.Errorf("expected no cycle in DAG, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphSelfLoop(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
g.addNode("a")
|
||||
|
||||
// BUG: Self-loops are not detected by willCycle(). The function checks if
|
||||
// adding edge from→to would create a cycle by traversing edges from "to"
|
||||
// to see if "from" is reachable. But for a self-loop (from==to), the edge
|
||||
// doesn't exist yet, so the DFS finds nothing and returns false.
|
||||
// A self-importing file would NOT be caught by this cycle detection.
|
||||
err := g.addEdge("a", "a")
|
||||
if err != nil {
|
||||
t.Log("Self-loop was correctly detected (bug may have been fixed)")
|
||||
} else {
|
||||
t.Log("BUG CONFIRMED: addEdge('a', 'a') did not detect self-loop cycle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphExistsNonExistent(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
if g.exists("nonexistent") {
|
||||
t.Error("expected false for nonexistent node on empty graph")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphAreConnectedEmpty(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
if g.areConnected("a", "b") {
|
||||
t.Error("expected false for areConnected on empty graph")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphAddEdges(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
g.addNodes([]string{"a", "b", "c", "d"})
|
||||
|
||||
err := g.addEdges("a", []string{"b", "c", "d"})
|
||||
if err != nil {
|
||||
t.Fatalf("addEdges() error = %v", err)
|
||||
}
|
||||
|
||||
if !g.areConnected("a", "b") || !g.areConnected("a", "c") || !g.areConnected("a", "d") {
|
||||
t.Error("expected all edges from 'a' to exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphAddEdgesWithCycle(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
g.addNodes([]string{"a", "b", "c"})
|
||||
|
||||
_ = g.addEdge("b", "c")
|
||||
_ = g.addEdge("c", "a")
|
||||
|
||||
// This should fail because a -> b -> c -> a creates a cycle
|
||||
err := g.addEdges("a", []string{"b"})
|
||||
if err == nil {
|
||||
t.Error("expected error when addEdges creates a cycle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphRemoveNodeEdgeLeakBug(t *testing.T) {
|
||||
// This test documents a known bug: removeNode doesn't clean up edges.
|
||||
// Edges FROM the removed node remain in the adjacency list.
|
||||
g := &importGraph{}
|
||||
g.addNodes([]string{"a", "b", "c"})
|
||||
_ = g.addEdge("a", "b")
|
||||
_ = g.addEdge("b", "c")
|
||||
|
||||
g.removeNode("b")
|
||||
|
||||
// Bug: "b" is removed from nodes, but edges from "b" are still in the adjacency list.
|
||||
// This means the graph is now inconsistent.
|
||||
// The node doesn't exist...
|
||||
if g.exists("b") {
|
||||
t.Error("node 'b' should not exist after removeNode")
|
||||
}
|
||||
|
||||
// ...but edges from "b" may still be present in the edges map (this is a bug).
|
||||
// We test this to document the behavior.
|
||||
if g.edges != nil {
|
||||
if targets, ok := g.edges["b"]; ok && len(targets) > 0 {
|
||||
t.Log("BUG CONFIRMED: removeNode does not clean up outgoing edges. " +
|
||||
"Edges from removed node 'b' still exist in adjacency list.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportGraphWillCycleEmptyGraph(t *testing.T) {
|
||||
g := &importGraph{}
|
||||
// willCycle on empty graph should return false
|
||||
if g.willCycle("a", "b") {
|
||||
t.Error("expected no cycle on empty graph")
|
||||
}
|
||||
}
|
||||
@@ -379,23 +379,28 @@ func (p *parser) doImport(nesting int) error {
|
||||
if len(blockTokens) > 0 {
|
||||
// use such tokens to create a new dispenser, and then use it to parse each block
|
||||
bd := NewDispenser(blockTokens)
|
||||
|
||||
// one iteration processes one sub-block inside the import
|
||||
for bd.Next() {
|
||||
currentMappingKey := bd.Val()
|
||||
|
||||
if currentMappingKey == "{" {
|
||||
// see if we can grab a key
|
||||
var currentMappingKey string
|
||||
if bd.Val() == "{" {
|
||||
return p.Err("anonymous blocks are not supported")
|
||||
}
|
||||
|
||||
// load up all arguments (if there even are any)
|
||||
currentMappingTokens := bd.RemainingArgsAsTokens()
|
||||
|
||||
// load up the entire block
|
||||
for mappingNesting := bd.Nesting(); bd.NextBlock(mappingNesting); {
|
||||
currentMappingKey = bd.Val()
|
||||
currentMappingTokens := []Token{}
|
||||
// read all args until end of line / {
|
||||
if bd.NextArg() {
|
||||
currentMappingTokens = append(currentMappingTokens, bd.Token())
|
||||
for bd.NextArg() {
|
||||
currentMappingTokens = append(currentMappingTokens, bd.Token())
|
||||
}
|
||||
// TODO(elee1766): we don't enter another mapping here because it's annoying to extract the { and } properly.
|
||||
// maybe someone can do that in the future
|
||||
} else {
|
||||
// attempt to enter a block and add tokens to the currentMappingTokens
|
||||
for mappingNesting := bd.Nesting(); bd.NextBlock(mappingNesting); {
|
||||
currentMappingTokens = append(currentMappingTokens, bd.Token())
|
||||
}
|
||||
}
|
||||
|
||||
blockMapping[currentMappingKey] = currentMappingTokens
|
||||
}
|
||||
}
|
||||
@@ -507,7 +512,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[len(tokensCopy)-1], token) {
|
||||
if i == 0 || isNextOnNewLine(tokensCopy[i-1], token) {
|
||||
index = 0
|
||||
} else {
|
||||
index++
|
||||
@@ -533,24 +538,25 @@ func (p *parser) doImport(nesting int) error {
|
||||
}
|
||||
// if it is {block}, we substitute with all tokens in the block
|
||||
// if it is {blocks.*}, we substitute with the tokens in the mapping for the *
|
||||
var skip bool
|
||||
var tokensToAdd []Token
|
||||
foundBlockDirective := false
|
||||
switch {
|
||||
case token.Text == "{block}":
|
||||
foundBlockDirective = true
|
||||
tokensToAdd = blockTokens
|
||||
case strings.HasPrefix(token.Text, "{blocks.") && strings.HasSuffix(token.Text, "}"):
|
||||
foundBlockDirective = true
|
||||
// {blocks.foo.bar} will be extracted to key `foo.bar`
|
||||
blockKey := strings.TrimPrefix(strings.TrimSuffix(token.Text, "}"), "{blocks.")
|
||||
val, ok := blockMapping[blockKey]
|
||||
if ok {
|
||||
tokensToAdd = val
|
||||
}
|
||||
default:
|
||||
skip = true
|
||||
}
|
||||
|
||||
if foundBlockDirective {
|
||||
if maybeSnippet {
|
||||
if !skip {
|
||||
if len(tokensToAdd) == 0 {
|
||||
// if there is no content in the snippet block, don't do any replacement
|
||||
// this allows snippets which contained {block}/{block.*} before this change to continue functioning as normal
|
||||
tokensCopy = append(tokensCopy, token)
|
||||
} else {
|
||||
tokensCopy = append(tokensCopy, tokensToAdd...)
|
||||
@@ -620,7 +626,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 := range importedTokens {
|
||||
for i := 0; i < len(importedTokens); i++ {
|
||||
importedTokens[i].File = filename
|
||||
}
|
||||
|
||||
@@ -686,28 +692,11 @@ func (p *parser) directive() error {
|
||||
// a opening curly brace. It does NOT advance the token.
|
||||
func (p *parser) openCurlyBrace() error {
|
||||
if p.Val() != "{" {
|
||||
if p.valLooksLikeGlobalOptionsAfterImportedSnippets() {
|
||||
return p.Err("global options block must appear before import directives; move the global options block to the top of the Caddyfile")
|
||||
}
|
||||
return p.SyntaxErr("{")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *parser) valLooksLikeGlobalOptionsAfterImportedSnippets() bool {
|
||||
if p.Val() != "import" || len(p.block.Keys) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, key := range p.block.Keys {
|
||||
if !strings.HasPrefix(key.Text, "(") || !strings.HasSuffix(key.Text, ")") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// closeCurlyBrace expects the current token to be
|
||||
// a closing curly brace. This acts like an assertion
|
||||
// because it returns an error if the token is not
|
||||
@@ -782,7 +771,7 @@ type ServerBlock struct {
|
||||
}
|
||||
|
||||
func (sb ServerBlock) GetKeysText() []string {
|
||||
res := make([]string, 0, len(sb.Keys))
|
||||
res := []string{}
|
||||
for _, k := range sb.Keys {
|
||||
res = append(res, k.Text)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -885,152 +884,6 @@ func TestRejectsGlobalMatcher(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectAnonymousImportBlock(t *testing.T) {
|
||||
p := testParser(`
|
||||
(site) {
|
||||
http://{args[0]} https://{args[0]} {
|
||||
{block}
|
||||
}
|
||||
}
|
||||
|
||||
import site test.domain {
|
||||
{
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
`)
|
||||
_, err := p.parseAll()
|
||||
if err == nil {
|
||||
t.Fatal("Expected an error, but got nil")
|
||||
}
|
||||
expected := "anonymous blocks are not supported"
|
||||
if !strings.HasPrefix(err.Error(), "anonymous blocks are not supported") {
|
||||
t.Errorf("Expected error to start with '%s' but got '%v'", expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptSiteImportWithBraces(t *testing.T) {
|
||||
p := testParser(`
|
||||
(site) {
|
||||
http://{args[0]} https://{args[0]} {
|
||||
{block}
|
||||
}
|
||||
}
|
||||
|
||||
import site test.domain {
|
||||
reverse_proxy http://192.168.1.1:8080 {
|
||||
header_up Host {host}
|
||||
}
|
||||
}
|
||||
`)
|
||||
_, err := p.parseAll()
|
||||
if err != nil {
|
||||
t.Errorf("Expected error to be nil but got '%v'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalOptionsAfterImportedSnippetsGivesHelpfulError(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
importFile1 := filepath.Join(tempDir, "matcher_snippet_1.caddy")
|
||||
importFile2 := filepath.Join(tempDir, "matcher_snippet_2.caddy")
|
||||
|
||||
err := os.WriteFile(importFile1, []byte(`(matcher1)`), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("writing first import file: %v", err)
|
||||
}
|
||||
|
||||
err = os.WriteFile(importFile2, []byte(`(matcher2)`), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("writing second import file: %v", err)
|
||||
}
|
||||
|
||||
_, err = Parse("Testfile", []byte(`import `+importFile1+`
|
||||
import `+importFile2+`
|
||||
{
|
||||
debug
|
||||
}`))
|
||||
if err == nil {
|
||||
t.Fatal("Expected an error, but got nil")
|
||||
}
|
||||
|
||||
expected := "global options block must appear before import directives; move the global options block to the top of the Caddyfile"
|
||||
if !strings.HasPrefix(err.Error(), expected) {
|
||||
t.Errorf("Expected error to start with '%s' but got '%v'", expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportedSnippetDefinitionRetainsBlockPlaceholder(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
importFile := filepath.Join(tempDir, "snippets.caddy")
|
||||
|
||||
err := os.WriteFile(importFile, []byte(`
|
||||
(site) {
|
||||
http://{args[0]} {
|
||||
respond "before"
|
||||
{block}
|
||||
respond "after"
|
||||
}
|
||||
}
|
||||
`), 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("writing imported snippet file: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
input string
|
||||
expectedDirectives []string
|
||||
}{
|
||||
{
|
||||
name: "with nested block",
|
||||
input: `
|
||||
import ` + importFile + `
|
||||
|
||||
import site example.com {
|
||||
redir https://example.net
|
||||
}
|
||||
`,
|
||||
expectedDirectives: []string{"respond", "redir", "respond"},
|
||||
},
|
||||
{
|
||||
name: "without nested block",
|
||||
input: `
|
||||
import ` + importFile + `
|
||||
|
||||
import site example.com
|
||||
`,
|
||||
expectedDirectives: []string{"respond", "respond"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := testParser(tc.input)
|
||||
blocks, err := p.parseAll()
|
||||
if err != nil {
|
||||
t.Fatalf("parseAll: %v", err)
|
||||
}
|
||||
|
||||
if len(blocks) != 1 {
|
||||
t.Fatalf("expected exactly one server block, got %d", len(blocks))
|
||||
}
|
||||
|
||||
if actual := blocks[0].GetKeysText(); len(actual) != 1 || actual[0] != "http://example.com" {
|
||||
t.Fatalf("expected server block key http://example.com, got %v", actual)
|
||||
}
|
||||
|
||||
if len(blocks[0].Segments) != len(tc.expectedDirectives) {
|
||||
t.Fatalf("expected %d segments, got %d", len(tc.expectedDirectives), len(blocks[0].Segments))
|
||||
}
|
||||
|
||||
for i, directive := range tc.expectedDirectives {
|
||||
if actual := blocks[0].Segments[i].Directive(); actual != directive {
|
||||
t.Fatalf("segment %d: expected directive %q, got %q", i, directive, actual)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testParser(input string) parser {
|
||||
return parser{Dispenser: NewTestDispenser(input)}
|
||||
}
|
||||
|
||||
@@ -81,11 +81,7 @@ func JSONModuleObject(val any, fieldName, fieldVal string, warnings *[]Warning)
|
||||
err = json.Unmarshal(enc, &tmp)
|
||||
if err != nil {
|
||||
if warnings != nil {
|
||||
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})
|
||||
*warnings = append(*warnings, Warning{Message: err.Error()})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
package caddyconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
val any
|
||||
wantNil bool
|
||||
wantWarnings int
|
||||
nilWarnings bool // pass nil warnings pointer
|
||||
}{
|
||||
{
|
||||
name: "simple string",
|
||||
val: "hello",
|
||||
wantNil: false,
|
||||
wantWarnings: 0,
|
||||
},
|
||||
{
|
||||
name: "struct",
|
||||
val: struct{ Name string }{"test"},
|
||||
wantNil: false,
|
||||
wantWarnings: 0,
|
||||
},
|
||||
{
|
||||
name: "nil value",
|
||||
val: nil,
|
||||
wantNil: false, // json.Marshal(nil) returns "null"
|
||||
wantWarnings: 0,
|
||||
},
|
||||
{
|
||||
name: "map",
|
||||
val: map[string]string{"key": "val"},
|
||||
wantNil: false,
|
||||
wantWarnings: 0,
|
||||
},
|
||||
{
|
||||
name: "unmarshalable value produces warning",
|
||||
val: make(chan int),
|
||||
wantNil: true,
|
||||
wantWarnings: 1,
|
||||
},
|
||||
{
|
||||
name: "unmarshalable value with nil warnings pointer",
|
||||
val: make(chan int),
|
||||
wantNil: true,
|
||||
nilWarnings: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var warnings *[]Warning
|
||||
if !tt.nilWarnings {
|
||||
w := []Warning{}
|
||||
warnings = &w
|
||||
}
|
||||
|
||||
result := JSON(tt.val, warnings)
|
||||
|
||||
if tt.wantNil && result != nil {
|
||||
t.Errorf("JSON() = %v, want nil", string(result))
|
||||
}
|
||||
if !tt.wantNil && result == nil {
|
||||
t.Error("JSON() = nil, want non-nil")
|
||||
}
|
||||
if warnings != nil && len(*warnings) != tt.wantWarnings {
|
||||
t.Errorf("JSON() produced %d warnings, want %d", len(*warnings), tt.wantWarnings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONModuleObject(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
val any
|
||||
fieldName string
|
||||
fieldVal string
|
||||
wantNil bool
|
||||
wantField bool
|
||||
wantWarnings int
|
||||
}{
|
||||
{
|
||||
name: "simple struct",
|
||||
val: struct{ Name string }{"test"},
|
||||
fieldName: "handler",
|
||||
fieldVal: "file_server",
|
||||
wantNil: false,
|
||||
wantField: true,
|
||||
wantWarnings: 0,
|
||||
},
|
||||
{
|
||||
name: "map value",
|
||||
val: map[string]any{"key": "val"},
|
||||
fieldName: "module",
|
||||
fieldVal: "my_module",
|
||||
wantNil: false,
|
||||
wantField: true,
|
||||
wantWarnings: 0,
|
||||
},
|
||||
{
|
||||
name: "non-object type (string) produces warning",
|
||||
val: "not-an-object",
|
||||
fieldName: "handler",
|
||||
fieldVal: "test",
|
||||
wantNil: true,
|
||||
wantField: false,
|
||||
wantWarnings: 1,
|
||||
},
|
||||
{
|
||||
name: "unmarshalable value produces warning",
|
||||
val: make(chan int),
|
||||
fieldName: "handler",
|
||||
fieldVal: "test",
|
||||
wantNil: true,
|
||||
wantField: false,
|
||||
wantWarnings: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
warnings := []Warning{}
|
||||
result := JSONModuleObject(tt.val, tt.fieldName, tt.fieldVal, &warnings)
|
||||
|
||||
if tt.wantNil && result != nil {
|
||||
t.Errorf("JSONModuleObject() = %v, want nil", string(result))
|
||||
}
|
||||
if !tt.wantNil && result == nil {
|
||||
t.Error("JSONModuleObject() = nil, want non-nil")
|
||||
}
|
||||
if len(warnings) != tt.wantWarnings {
|
||||
t.Errorf("JSONModuleObject() produced %d warnings, want %d", len(warnings), tt.wantWarnings)
|
||||
}
|
||||
if tt.wantField && result != nil {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(result, &m); err != nil {
|
||||
t.Fatalf("failed to unmarshal result: %v", err)
|
||||
}
|
||||
if v, ok := m[tt.fieldName]; !ok {
|
||||
t.Errorf("expected field %q in result", tt.fieldName)
|
||||
} else if v != tt.fieldVal {
|
||||
t.Errorf("field %q = %v, want %v", tt.fieldName, v, tt.fieldVal)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONModuleObjectPreservesExistingFields(t *testing.T) {
|
||||
val := struct {
|
||||
Name string `json:"name"`
|
||||
Port int `json:"port"`
|
||||
}{"example", 8080}
|
||||
|
||||
warnings := []Warning{}
|
||||
result := JSONModuleObject(val, "handler", "static", &warnings)
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(result, &m); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if m["name"] != "example" {
|
||||
t.Errorf("name = %v, want 'example'", m["name"])
|
||||
}
|
||||
if m["port"] != float64(8080) {
|
||||
t.Errorf("port = %v, want 8080", m["port"])
|
||||
}
|
||||
if m["handler"] != "static" {
|
||||
t.Errorf("handler = %v, want 'static'", m["handler"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAdapterNil(t *testing.T) {
|
||||
adapter := GetAdapter("nonexistent_adapter_xyz")
|
||||
if adapter != nil {
|
||||
t.Error("expected nil for unregistered adapter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
warning Warning
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "all fields",
|
||||
warning: Warning{File: "Caddyfile", Line: 10, Directive: "reverse_proxy", Message: "upstream not found"},
|
||||
want: "Caddyfile:10 (reverse_proxy): upstream not found",
|
||||
},
|
||||
{
|
||||
name: "no directive",
|
||||
warning: Warning{File: "Caddyfile", Line: 5, Message: "something off"},
|
||||
want: "Caddyfile:5: something off",
|
||||
},
|
||||
{
|
||||
name: "zero line",
|
||||
warning: Warning{File: "config.json", Line: 0, Message: "invalid"},
|
||||
want: "config.json:0: invalid",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.warning.String()
|
||||
if got != tt.want {
|
||||
t.Errorf("Warning.String() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func parseBind(h Helper) ([]ConfigValue, error) {
|
||||
// curves <curves...>
|
||||
// client_auth {
|
||||
// mode [request|require|verify_if_given|require_and_verify]
|
||||
// trust_pool <module_name> [...]
|
||||
// trust_pool <module_name> [...]
|
||||
// trusted_leaf_cert <base64_der>
|
||||
// trusted_leaf_cert_file <filename>
|
||||
// }
|
||||
@@ -113,7 +113,6 @@ func parseBind(h Helper) ([]ConfigValue, error) {
|
||||
// issuer <module_name> [...]
|
||||
// get_certificate <module_name> [...]
|
||||
// insecure_secrets_log <log_file>
|
||||
// renewal_window_ratio <ratio>
|
||||
// }
|
||||
func parseTLS(h Helper) ([]ConfigValue, error) {
|
||||
h.Next() // consume directive name
|
||||
@@ -130,7 +129,6 @@ 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
|
||||
@@ -475,20 +473,6 @@ 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())
|
||||
}
|
||||
@@ -497,7 +481,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
|
||||
// Validate DNS challenge config: any DNS challenge option except "dns" requires a DNS provider
|
||||
if acmeIssuer != nil && acmeIssuer.Challenges != nil && acmeIssuer.Challenges.DNS != nil {
|
||||
dnsCfg := acmeIssuer.Challenges.DNS
|
||||
providerSet := dnsCfg.ProviderRaw != nil || h.Option("dns") != nil || h.Option("acme_dns") != nil
|
||||
providerSet := dnsCfg.ProviderRaw != nil || h.Option("dns") != nil
|
||||
if len(dnsOptionsSet) > 0 && !providerSet {
|
||||
return nil, h.Errf(
|
||||
"setting DNS challenge options [%s] requires a DNS provider (set with the 'dns' subdirective or 'acme_dns' global option)",
|
||||
@@ -550,11 +534,26 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
|
||||
}
|
||||
|
||||
case acmeIssuer != nil:
|
||||
// implicit ACME issuers (from various subdirectives) should inherit from
|
||||
// any globally-configured ACME issuer templates, then apply the local
|
||||
// shortcut settings as overrides.
|
||||
defaultIssuers := implicitACMEIssuers(h, acmeIssuer)
|
||||
// implicit ACME issuers (from various subdirectives) - use defaults; there might be more than one
|
||||
defaultIssuers := caddytls.DefaultIssuers(acmeIssuer.Email)
|
||||
|
||||
// if an ACME CA endpoint was set, the user expects to use that specific one,
|
||||
// not any others that may be defaults, so replace all defaults with that ACME CA
|
||||
if acmeIssuer.CA != "" {
|
||||
defaultIssuers = []certmagic.Issuer{acmeIssuer}
|
||||
}
|
||||
|
||||
for _, issuer := range defaultIssuers {
|
||||
// apply settings from the implicitly-configured ACMEIssuer to any
|
||||
// default ACMEIssuers, but preserve each default issuer's CA endpoint,
|
||||
// because, for example, if you configure the DNS challenge, it should
|
||||
// apply to any of the default ACMEIssuers, but you don't want to trample
|
||||
// out their unique CA endpoints
|
||||
if iss, ok := issuer.(*caddytls.ACMEIssuer); ok && iss != nil {
|
||||
acmeCopy := *acmeIssuer
|
||||
acmeCopy.CA = iss.CA
|
||||
issuer = &acmeCopy
|
||||
}
|
||||
configVals = append(configVals, ConfigValue{
|
||||
Class: "tls.cert_issuer",
|
||||
Value: issuer,
|
||||
@@ -598,14 +597,6 @@ 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 {
|
||||
@@ -653,8 +644,6 @@ func parseRoot(h Helper) ([]ConfigValue, error) {
|
||||
if !h.NextArg() {
|
||||
return nil, h.ArgErr()
|
||||
}
|
||||
// store the unmatched root in block state so sibling directives can access it
|
||||
h.BlockState["root"] = h.Val()
|
||||
return h.NewRoute(nil, caddyhttp.VarsMiddleware{"root": h.Val()}), nil
|
||||
}
|
||||
|
||||
@@ -669,10 +658,6 @@ func parseRoot(h Helper) ([]ConfigValue, error) {
|
||||
if !h.NextArg() {
|
||||
return nil, h.ArgErr()
|
||||
}
|
||||
// store the unmatched root in state so sibling/child directives can access it
|
||||
if userMatcherSet == nil {
|
||||
h.BlockState["root"] = h.Val()
|
||||
}
|
||||
// make the route with the matcher
|
||||
return h.NewRoute(userMatcherSet, caddyhttp.VarsMiddleware{"root": h.Val()}), nil
|
||||
}
|
||||
@@ -945,7 +930,6 @@ 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
|
||||
@@ -1053,7 +1037,7 @@ func parseLogHelper(h Helper, globalLogNames map[string]struct{}) ([]ConfigValue
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
interval, err := caddy.ParseDuration(d.Val())
|
||||
interval, err := time.ParseDuration(d.Val() + "ns")
|
||||
if err != nil {
|
||||
return nil, d.Errf("failed to parse interval: %v", err)
|
||||
}
|
||||
|
||||
@@ -66,14 +66,14 @@ func TestLogDirectiveSyntax(t *testing.T) {
|
||||
input: `:8080 {
|
||||
log {
|
||||
sampling {
|
||||
interval 2s
|
||||
interval 2
|
||||
first 3
|
||||
thereafter 4
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
output: `{"logging":{"logs":{"default":{"exclude":["http.log.access.log0"]},"log0":{"sampling":{"interval":2000000000,"first":3,"thereafter":4},"include":["http.log.access.log0"]}}},"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"logs":{"default_logger_name":"log0"}}}}}}`,
|
||||
output: `{"logging":{"logs":{"default":{"exclude":["http.log.access.log0"]},"log0":{"sampling":{"interval":2,"first":3,"thereafter":4},"include":["http.log.access.log0"]}}},"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"logs":{"default_logger_name":"log0"}}}}}}`,
|
||||
expectError: false,
|
||||
},
|
||||
} {
|
||||
|
||||
@@ -202,10 +202,7 @@ func RegisterGlobalOption(opt string, setupFunc UnmarshalGlobalFunc) {
|
||||
type Helper struct {
|
||||
*caddyfile.Dispenser
|
||||
// State stores intermediate variables during caddyfile adaptation.
|
||||
State map[string]any
|
||||
// BlockState stores intermediate variables scoped to the current block.
|
||||
// It propagates down, but unlike state not back up from child to parent.
|
||||
BlockState map[string]any
|
||||
State map[string]any
|
||||
options map[string]any
|
||||
warnings *[]caddyconfig.Warning
|
||||
matcherDefs map[string]caddy.ModuleMap
|
||||
@@ -388,11 +385,6 @@ func parseSegmentAsConfig(h Helper) ([]ConfigValue, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// clone BlockState once for the entire block so sibling directives
|
||||
// can share state, but changes don't leak to the parent scope
|
||||
subBlockState := make(map[string]any, len(h.BlockState))
|
||||
maps.Copy(subBlockState, h.BlockState)
|
||||
|
||||
// with matchers ready to go, evaluate each directive's segment
|
||||
for _, seg := range segments {
|
||||
dir := seg.Directive()
|
||||
@@ -404,7 +396,6 @@ func parseSegmentAsConfig(h Helper) ([]ConfigValue, error) {
|
||||
subHelper := h
|
||||
subHelper.Dispenser = caddyfile.NewDispenser(seg)
|
||||
subHelper.matcherDefs = matcherDefs
|
||||
subHelper.BlockState = subBlockState
|
||||
|
||||
results, err := dirFunc(subHelper)
|
||||
if err != nil {
|
||||
|
||||
@@ -143,7 +143,6 @@ func (st ServerType) Setup(
|
||||
parentBlock: sb.block,
|
||||
groupCounter: gc,
|
||||
State: state,
|
||||
BlockState: state,
|
||||
}
|
||||
|
||||
results, err := dirFunc(h)
|
||||
@@ -505,7 +504,6 @@ func (ServerType) extractNamedRoutes(
|
||||
parentBlock: sb.block,
|
||||
groupCounter: gc,
|
||||
State: state,
|
||||
BlockState: state,
|
||||
}
|
||||
|
||||
handler, err := ParseSegmentAsSubroute(h)
|
||||
@@ -824,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
|
||||
@@ -853,20 +851,6 @@ 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)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
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) {
|
||||
@@ -211,53 +209,3 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,9 +64,7 @@ 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 }
|
||||
@@ -307,15 +305,6 @@ 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
|
||||
|
||||
@@ -468,8 +457,9 @@ 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', or 'ignore_loaded_certs'")
|
||||
return "", d.Errf("auto_https must be one of 'off', 'disable_redirects', 'disable_certs', 'ignore_loaded_certs', or 'prefer_wildcard'")
|
||||
}
|
||||
}
|
||||
return val, nil
|
||||
@@ -482,10 +472,6 @@ func unmarshalCaddyfileMetricsOptions(d *caddyfile.Dispenser) (any, error) {
|
||||
switch d.Val() {
|
||||
case "per_host":
|
||||
metrics.PerHost = true
|
||||
case "observe_catchall_hosts":
|
||||
metrics.ObserveCatchallHosts = true
|
||||
case "otlp":
|
||||
metrics.OTLP = true
|
||||
default:
|
||||
return nil, d.Errf("unrecognized servers option '%s'", d.Val())
|
||||
}
|
||||
@@ -637,22 +623,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
package httpcaddyfile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddytls"
|
||||
_ "github.com/caddyserver/caddy/v2/modules/logging"
|
||||
)
|
||||
|
||||
@@ -66,228 +62,3 @@ 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])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalCertIssuerAppliesToImplicitACMEIssuer(t *testing.T) {
|
||||
adapter := caddyfile.Adapter{
|
||||
ServerType: ServerType{},
|
||||
}
|
||||
|
||||
input := `{
|
||||
cert_issuer acme {
|
||||
disable_tlsalpn_challenge
|
||||
}
|
||||
}
|
||||
report.company.intern {
|
||||
tls {
|
||||
ca https://deglacme01.company.intern/acme/acme/directory
|
||||
ca_root /etc/certs/company_root2.crt
|
||||
}
|
||||
respond "ok"
|
||||
}`
|
||||
|
||||
out, _, err := adapter.Adapt([]byte(input), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("adapting caddyfile: %v", err)
|
||||
}
|
||||
|
||||
var config struct {
|
||||
Apps struct {
|
||||
TLS *caddytls.TLS `json:"tls"`
|
||||
} `json:"apps"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &config); err != nil {
|
||||
t.Fatalf("unmarshaling adapted config: %v", err)
|
||||
}
|
||||
if config.Apps.TLS == nil || config.Apps.TLS.Automation == nil {
|
||||
t.Fatal("expected tls automation config")
|
||||
}
|
||||
|
||||
var subjectPolicy *caddytls.AutomationPolicy
|
||||
for _, ap := range config.Apps.TLS.Automation.Policies {
|
||||
if len(ap.SubjectsRaw) == 1 && ap.SubjectsRaw[0] == "report.company.intern" {
|
||||
subjectPolicy = ap
|
||||
break
|
||||
}
|
||||
}
|
||||
if subjectPolicy == nil {
|
||||
t.Fatal("expected subject-specific automation policy")
|
||||
}
|
||||
if len(subjectPolicy.IssuersRaw) != 1 {
|
||||
t.Fatalf("expected one issuer for subject-specific policy, got %d", len(subjectPolicy.IssuersRaw))
|
||||
}
|
||||
|
||||
var issuer caddytls.ACMEIssuer
|
||||
if err := json.Unmarshal(subjectPolicy.IssuersRaw[0], &issuer); err != nil {
|
||||
t.Fatalf("unmarshaling issuer: %v", err)
|
||||
}
|
||||
if issuer.CA != "https://deglacme01.company.intern/acme/acme/directory" {
|
||||
t.Fatalf("expected custom ACME CA, got %q", issuer.CA)
|
||||
}
|
||||
if len(issuer.TrustedRootsPEMFiles) != 1 || issuer.TrustedRootsPEMFiles[0] != "/etc/certs/company_root2.crt" {
|
||||
t.Fatalf("expected trusted roots to include site CA root, got %v", issuer.TrustedRootsPEMFiles)
|
||||
}
|
||||
if issuer.Challenges == nil || issuer.Challenges.TLSALPN == nil || !issuer.Challenges.TLSALPN.Disabled {
|
||||
t.Fatalf("expected tls-alpn challenge to be disabled, got %#v", issuer.Challenges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeACMEIssuers(t *testing.T) {
|
||||
base := &caddytls.ACMEIssuer{
|
||||
Email: "ops@example.com",
|
||||
Challenges: &caddytls.ChallengesConfig{
|
||||
HTTP: &caddytls.HTTPChallengeConfig{
|
||||
AlternatePort: 8080,
|
||||
},
|
||||
TLSALPN: &caddytls.TLSALPNChallengeConfig{
|
||||
Disabled: true,
|
||||
AlternatePort: 8443,
|
||||
},
|
||||
DNS: &caddytls.DNSChallengeConfig{
|
||||
Resolvers: []string{"1.1.1.1"},
|
||||
OverrideDomain: "_acme-challenge.example.net",
|
||||
},
|
||||
},
|
||||
TrustedRootsPEMFiles: []string{"global.pem"},
|
||||
}
|
||||
overrides := &caddytls.ACMEIssuer{
|
||||
CA: "https://deglacme01.company.intern/acme/acme/directory",
|
||||
Challenges: &caddytls.ChallengesConfig{
|
||||
HTTP: &caddytls.HTTPChallengeConfig{
|
||||
Disabled: true,
|
||||
},
|
||||
DNS: &caddytls.DNSChallengeConfig{
|
||||
PropagationTimeout: caddy.Duration(time.Minute),
|
||||
},
|
||||
},
|
||||
TrustedRootsPEMFiles: []string{"site.pem"},
|
||||
}
|
||||
|
||||
merged := mergeACMEIssuers(base, overrides)
|
||||
if merged.CA != overrides.CA {
|
||||
t.Fatalf("expected merged CA %q, got %q", overrides.CA, merged.CA)
|
||||
}
|
||||
if merged.Email != base.Email {
|
||||
t.Fatalf("expected merged email %q, got %q", base.Email, merged.Email)
|
||||
}
|
||||
if len(merged.TrustedRootsPEMFiles) != 2 || merged.TrustedRootsPEMFiles[0] != "global.pem" || merged.TrustedRootsPEMFiles[1] != "site.pem" {
|
||||
t.Fatalf("expected merged roots [global.pem site.pem], got %v", merged.TrustedRootsPEMFiles)
|
||||
}
|
||||
if merged.Challenges == nil || merged.Challenges.HTTP == nil || !merged.Challenges.HTTP.Disabled || merged.Challenges.HTTP.AlternatePort != 8080 {
|
||||
t.Fatalf("expected merged HTTP challenge config to preserve alternate port and apply disable flag, got %#v", merged.Challenges)
|
||||
}
|
||||
if merged.Challenges.TLSALPN == nil || !merged.Challenges.TLSALPN.Disabled || merged.Challenges.TLSALPN.AlternatePort != 8443 {
|
||||
t.Fatalf("expected merged TLS-ALPN challenge config to preserve global settings, got %#v", merged.Challenges)
|
||||
}
|
||||
if merged.Challenges.DNS == nil || merged.Challenges.DNS.PropagationTimeout != caddy.Duration(time.Minute) || len(merged.Challenges.DNS.Resolvers) != 1 || merged.Challenges.DNS.Resolvers[0] != "1.1.1.1" || merged.Challenges.DNS.OverrideDomain != "_acme-challenge.example.net" {
|
||||
t.Fatalf("expected merged DNS challenge config to preserve global values and apply overrides, got %#v", merged.Challenges)
|
||||
}
|
||||
|
||||
if base.CA != "" {
|
||||
t.Fatalf("expected base issuer to remain unchanged, got CA %q", base.CA)
|
||||
}
|
||||
if len(base.TrustedRootsPEMFiles) != 1 || base.TrustedRootsPEMFiles[0] != "global.pem" {
|
||||
t.Fatalf("expected base roots to remain unchanged, got %v", base.TrustedRootsPEMFiles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,6 @@
|
||||
package httpcaddyfile
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
||||
@@ -28,16 +25,14 @@ func init() {
|
||||
RegisterGlobalOption("pki", parsePKIApp)
|
||||
}
|
||||
|
||||
// parsePKIApp parses the global pki option. Syntax:
|
||||
// parsePKIApp parses the global log option. Syntax:
|
||||
//
|
||||
// pki {
|
||||
// ca [<id>] {
|
||||
// name <name>
|
||||
// root_cn <name>
|
||||
// intermediate_cn <name>
|
||||
// intermediate_lifetime <duration>
|
||||
// maintenance_interval <duration>
|
||||
// renewal_window_ratio <ratio>
|
||||
// name <name>
|
||||
// root_cn <name>
|
||||
// intermediate_cn <name>
|
||||
// intermediate_lifetime <duration>
|
||||
// root {
|
||||
// cert <path>
|
||||
// key <path>
|
||||
@@ -102,26 +97,6 @@ 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)
|
||||
@@ -203,15 +178,6 @@ func (st ServerType) buildPKIApp(
|
||||
if _, ok := options["skip_install_trust"]; ok {
|
||||
skipInstallTrust = true
|
||||
}
|
||||
|
||||
// check if auto_https is off - in that case we should not create
|
||||
// any PKI infrastructure even with skip_install_trust directive
|
||||
autoHTTPS := []string{}
|
||||
if ah, ok := options["auto_https"].([]string); ok {
|
||||
autoHTTPS = ah
|
||||
}
|
||||
autoHTTPSOff := slices.Contains(autoHTTPS, "off")
|
||||
|
||||
falseBool := false
|
||||
|
||||
// Load the PKI app configured via global options
|
||||
@@ -252,8 +218,7 @@ func (st ServerType) buildPKIApp(
|
||||
// if there was no CAs defined in any of the servers,
|
||||
// and we were requested to not install trust, then
|
||||
// add one for the default/local CA to do so
|
||||
// only if auto_https is not completely disabled
|
||||
if len(pkiApp.CAs) == 0 && skipInstallTrust && !autoHTTPSOff {
|
||||
if len(pkiApp.CAs) == 0 && skipInstallTrust {
|
||||
ca := new(caddypki.CA)
|
||||
ca.ID = caddypki.DefaultCAID
|
||||
ca.InstallTrust = &falseBool
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
|
||||
@@ -36,30 +35,23 @@ type serverOptions struct {
|
||||
ListenerAddress string
|
||||
|
||||
// These will all map 1:1 to the caddyhttp.Server struct
|
||||
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
|
||||
Name string
|
||||
ListenerWrappersRaw []json.RawMessage
|
||||
ReadTimeout caddy.Duration
|
||||
ReadHeaderTimeout caddy.Duration
|
||||
WriteTimeout caddy.Duration
|
||||
IdleTimeout caddy.Duration
|
||||
KeepAliveInterval caddy.Duration
|
||||
MaxHeaderBytes int
|
||||
EnableFullDuplex bool
|
||||
Protocols []string
|
||||
StrictSNIHost *bool
|
||||
TrustedProxiesRaw json.RawMessage
|
||||
TrustedProxiesStrict int
|
||||
ClientIPHeaders []string
|
||||
ShouldLogCredentials bool
|
||||
Metrics *caddyhttp.Metrics
|
||||
Trace bool // TODO: EXPERIMENTAL
|
||||
}
|
||||
|
||||
func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
|
||||
@@ -103,26 +95,6 @@ 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() {
|
||||
@@ -170,7 +142,6 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
|
||||
return nil, d.Errf("unrecognized timeouts option '%s'", d.Val())
|
||||
}
|
||||
}
|
||||
|
||||
case "keepalive_interval":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
@@ -181,26 +152,6 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
|
||||
}
|
||||
serverOpts.KeepAliveInterval = caddy.Duration(dur)
|
||||
|
||||
case "keepalive_idle":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing keepalive idle duration: %v", err)
|
||||
}
|
||||
serverOpts.KeepAliveIdle = caddy.Duration(dur)
|
||||
|
||||
case "keepalive_count":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
cnt, err := strconv.ParseInt(d.Val(), 10, 32)
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing keepalive count int: %v", err)
|
||||
}
|
||||
serverOpts.KeepAliveCount = int(cnt)
|
||||
|
||||
case "max_header_size":
|
||||
var sizeStr string
|
||||
if !d.AllArgs(&sizeStr) {
|
||||
@@ -276,12 +227,6 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
|
||||
}
|
||||
serverOpts.TrustedProxiesStrict = 1
|
||||
|
||||
case "trusted_proxies_unix":
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
serverOpts.TrustedProxiesUnix = true
|
||||
|
||||
case "client_ip_headers":
|
||||
headers := d.RemainingArgs()
|
||||
for _, header := range headers {
|
||||
@@ -312,17 +257,6 @@ 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())
|
||||
}
|
||||
@@ -370,14 +304,11 @@ 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
|
||||
server.IdleTimeout = opts.IdleTimeout
|
||||
server.KeepAliveInterval = opts.KeepAliveInterval
|
||||
server.KeepAliveIdle = opts.KeepAliveIdle
|
||||
server.KeepAliveCount = opts.KeepAliveCount
|
||||
server.MaxHeaderBytes = opts.MaxHeaderBytes
|
||||
server.EnableFullDuplex = opts.EnableFullDuplex
|
||||
server.Protocols = opts.Protocols
|
||||
@@ -385,9 +316,7 @@ func applyServerOptions(
|
||||
server.TrustedProxiesRaw = opts.TrustedProxiesRaw
|
||||
server.ClientIPHeaders = opts.ClientIPHeaders
|
||||
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)
|
||||
|
||||
@@ -64,13 +64,10 @@ func placeholderShorthands() []string {
|
||||
"{orig_?query}", "{http.request.orig_uri.prefixed_query}",
|
||||
"{method}", "{http.request.method}",
|
||||
"{uri}", "{http.request.uri}",
|
||||
"{%uri}", "{http.request.uri_escaped}",
|
||||
"{path}", "{http.request.uri.path}",
|
||||
"{%path}", "{http.request.uri.path_escaped}",
|
||||
"{dir}", "{http.request.uri.path.dir}",
|
||||
"{file}", "{http.request.uri.path.file}",
|
||||
"{query}", "{http.request.uri.query}",
|
||||
"{%query}", "{http.request.uri.query_escaped}",
|
||||
"{?query}", "{http.request.uri.prefixed_query}",
|
||||
"{remote}", "{http.request.remote}",
|
||||
"{remote_host}", "{http.request.remote.host}",
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
package httpcaddyfile
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
||||
)
|
||||
|
||||
func TestShorthandReplacerSimpleReplacements(t *testing.T) {
|
||||
sr := NewShorthandReplacer()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "host",
|
||||
input: "{host}",
|
||||
want: "{http.request.host}",
|
||||
},
|
||||
{
|
||||
name: "hostport",
|
||||
input: "{hostport}",
|
||||
want: "{http.request.hostport}",
|
||||
},
|
||||
{
|
||||
name: "port",
|
||||
input: "{port}",
|
||||
want: "{http.request.port}",
|
||||
},
|
||||
{
|
||||
name: "method",
|
||||
input: "{method}",
|
||||
want: "{http.request.method}",
|
||||
},
|
||||
{
|
||||
name: "uri",
|
||||
input: "{uri}",
|
||||
want: "{http.request.uri}",
|
||||
},
|
||||
{
|
||||
name: "path",
|
||||
input: "{path}",
|
||||
want: "{http.request.uri.path}",
|
||||
},
|
||||
{
|
||||
name: "query",
|
||||
input: "{query}",
|
||||
want: "{http.request.uri.query}",
|
||||
},
|
||||
{
|
||||
name: "scheme",
|
||||
input: "{scheme}",
|
||||
want: "{http.request.scheme}",
|
||||
},
|
||||
{
|
||||
name: "remote_host",
|
||||
input: "{remote_host}",
|
||||
want: "{http.request.remote.host}",
|
||||
},
|
||||
{
|
||||
name: "remote_port",
|
||||
input: "{remote_port}",
|
||||
want: "{http.request.remote.port}",
|
||||
},
|
||||
{
|
||||
name: "uuid",
|
||||
input: "{uuid}",
|
||||
want: "{http.request.uuid}",
|
||||
},
|
||||
{
|
||||
name: "tls_cipher",
|
||||
input: "{tls_cipher}",
|
||||
want: "{http.request.tls.cipher_suite}",
|
||||
},
|
||||
{
|
||||
name: "tls_version",
|
||||
input: "{tls_version}",
|
||||
want: "{http.request.tls.version}",
|
||||
},
|
||||
{
|
||||
name: "client_ip",
|
||||
input: "{client_ip}",
|
||||
want: "{http.vars.client_ip}",
|
||||
},
|
||||
{
|
||||
name: "upstream_hostport",
|
||||
input: "{upstream_hostport}",
|
||||
want: "{http.reverse_proxy.upstream.hostport}",
|
||||
},
|
||||
{
|
||||
name: "dir",
|
||||
input: "{dir}",
|
||||
want: "{http.request.uri.path.dir}",
|
||||
},
|
||||
{
|
||||
name: "file",
|
||||
input: "{file}",
|
||||
want: "{http.request.uri.path.file}",
|
||||
},
|
||||
{
|
||||
name: "orig_method",
|
||||
input: "{orig_method}",
|
||||
want: "{http.request.orig_method}",
|
||||
},
|
||||
{
|
||||
name: "orig_uri",
|
||||
input: "{orig_uri}",
|
||||
want: "{http.request.orig_uri}",
|
||||
},
|
||||
{
|
||||
name: "orig_path",
|
||||
input: "{orig_path}",
|
||||
want: "{http.request.orig_uri.path}",
|
||||
},
|
||||
{
|
||||
name: "no matching placeholder",
|
||||
input: "{unknown}",
|
||||
want: "{unknown}",
|
||||
},
|
||||
{
|
||||
name: "not a placeholder",
|
||||
input: "plain text",
|
||||
want: "plain text",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "multiple placeholders in one string",
|
||||
input: "{host}:{port}",
|
||||
want: "{http.request.host}:{http.request.port}",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
segment := caddyfile.Segment{{Text: tt.input}}
|
||||
sr.ApplyToSegment(&segment)
|
||||
got := segment[0].Text
|
||||
if got != tt.want {
|
||||
t.Errorf("ApplyToSegment(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShorthandReplacerComplexReplacements(t *testing.T) {
|
||||
sr := NewShorthandReplacer()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "header placeholder",
|
||||
input: "{header.X-Forwarded-For}",
|
||||
want: "{http.request.header.X-Forwarded-For}",
|
||||
},
|
||||
{
|
||||
name: "cookie placeholder",
|
||||
input: "{cookie.session_id}",
|
||||
want: "{http.request.cookie.session_id}",
|
||||
},
|
||||
{
|
||||
name: "labels placeholder",
|
||||
input: "{labels.0}",
|
||||
want: "{http.request.host.labels.0}",
|
||||
},
|
||||
{
|
||||
name: "path segment placeholder",
|
||||
input: "{path.0}",
|
||||
want: "{http.request.uri.path.0}",
|
||||
},
|
||||
{
|
||||
name: "query placeholder",
|
||||
input: "{query.page}",
|
||||
want: "{http.request.uri.query.page}",
|
||||
},
|
||||
{
|
||||
name: "re placeholder with dots",
|
||||
input: "{re.name.group}",
|
||||
want: "{http.regexp.name.group}",
|
||||
},
|
||||
{
|
||||
name: "vars placeholder",
|
||||
input: "{vars.my_var}",
|
||||
want: "{http.vars.my_var}",
|
||||
},
|
||||
{
|
||||
name: "rp placeholder",
|
||||
input: "{rp.upstream.address}",
|
||||
want: "{http.reverse_proxy.upstream.address}",
|
||||
},
|
||||
{
|
||||
name: "resp placeholder",
|
||||
input: "{resp.status_code}",
|
||||
want: "{http.intercept.status_code}",
|
||||
},
|
||||
{
|
||||
name: "err placeholder",
|
||||
input: "{err.status_code}",
|
||||
want: "{http.error.status_code}",
|
||||
},
|
||||
{
|
||||
name: "file_match placeholder",
|
||||
input: "{file_match.relative}",
|
||||
want: "{http.matchers.file.relative}",
|
||||
},
|
||||
{
|
||||
name: "header with hyphen",
|
||||
input: "{header.Content-Type}",
|
||||
want: "{http.request.header.Content-Type}",
|
||||
},
|
||||
{
|
||||
name: "header with underscore",
|
||||
input: "{header.X_Custom_Header}",
|
||||
want: "{http.request.header.X_Custom_Header}",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
segment := caddyfile.Segment{{Text: tt.input}}
|
||||
sr.ApplyToSegment(&segment)
|
||||
got := segment[0].Text
|
||||
if got != tt.want {
|
||||
t.Errorf("ApplyToSegment(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShorthandReplacerApplyToNilSegment(t *testing.T) {
|
||||
sr := NewShorthandReplacer()
|
||||
// Should not panic
|
||||
sr.ApplyToSegment(nil)
|
||||
}
|
||||
|
||||
func TestShorthandReplacerMultipleTokens(t *testing.T) {
|
||||
sr := NewShorthandReplacer()
|
||||
|
||||
segment := caddyfile.Segment{
|
||||
{Text: "{host}"},
|
||||
{Text: "{path}"},
|
||||
{Text: "{header.X-Test}"},
|
||||
{Text: "plain"},
|
||||
}
|
||||
|
||||
sr.ApplyToSegment(&segment)
|
||||
|
||||
expected := []string{
|
||||
"{http.request.host}",
|
||||
"{http.request.uri.path}",
|
||||
"{http.request.header.X-Test}",
|
||||
"plain",
|
||||
}
|
||||
|
||||
for i, want := range expected {
|
||||
if segment[i].Text != want {
|
||||
t.Errorf("token %d: got %q, want %q", i, segment[i].Text, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShorthandReplacerEmptySegment(t *testing.T) {
|
||||
sr := NewShorthandReplacer()
|
||||
segment := caddyfile.Segment{}
|
||||
sr.ApplyToSegment(&segment) // should not panic
|
||||
}
|
||||
|
||||
func TestShorthandReplacerEscapedPlaceholders(t *testing.T) {
|
||||
sr := NewShorthandReplacer()
|
||||
|
||||
// Percent-escaped path placeholder
|
||||
segment := caddyfile.Segment{{Text: "{%path}"}}
|
||||
sr.ApplyToSegment(&segment)
|
||||
if segment[0].Text != "{http.request.uri.path_escaped}" {
|
||||
t.Errorf("got %q, want {http.request.uri.path_escaped}", segment[0].Text)
|
||||
}
|
||||
|
||||
// Percent-escaped query placeholder
|
||||
segment = caddyfile.Segment{{Text: "{%query}"}}
|
||||
sr.ApplyToSegment(&segment)
|
||||
if segment[0].Text != "{http.request.uri.query_escaped}" {
|
||||
t.Errorf("got %q, want {http.request.uri.query_escaped}", segment[0].Text)
|
||||
}
|
||||
|
||||
// Prefixed query
|
||||
segment = caddyfile.Segment{{Text: "{?query}"}}
|
||||
sr.ApplyToSegment(&segment)
|
||||
if segment[0].Text != "{http.request.uri.prefixed_query}" {
|
||||
t.Errorf("got %q, want {http.request.uri.prefixed_query}", segment[0].Text)
|
||||
}
|
||||
}
|
||||
@@ -92,8 +92,26 @@ 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
|
||||
@@ -117,6 +135,12 @@ 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
|
||||
@@ -143,12 +167,6 @@ 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
|
||||
@@ -235,6 +253,16 @@ 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
|
||||
|
||||
@@ -334,11 +362,6 @@ 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
|
||||
@@ -531,7 +554,6 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
|
||||
globalPreferredChains := options["preferred_chains"]
|
||||
globalCertLifetime := options["cert_lifetime"]
|
||||
globalHTTPPort, globalHTTPSPort := options["http_port"], options["https_port"]
|
||||
globalDefaultBind := options["default_bind"]
|
||||
|
||||
if globalEmail != nil && acmeIssuer.Email == "" {
|
||||
acmeIssuer.Email = globalEmail.(string)
|
||||
@@ -542,20 +564,21 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
|
||||
if globalACMECARoot != nil && !slices.Contains(acmeIssuer.TrustedRootsPEMFiles, globalACMECARoot.(string)) {
|
||||
acmeIssuer.TrustedRootsPEMFiles = append(acmeIssuer.TrustedRootsPEMFiles, globalACMECARoot.(string))
|
||||
}
|
||||
if globalACMEDNSok && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.DNS == nil || acmeIssuer.Challenges.DNS.ProviderRaw == nil) {
|
||||
globalDNS := options["dns"]
|
||||
if globalDNS == nil && globalACMEDNS == nil {
|
||||
return fmt.Errorf("acme_dns specified without DNS provider config, but no provider specified with 'dns' global option")
|
||||
if globalACMEDNSok && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.DNS == nil) {
|
||||
if globalACMEDNS == nil {
|
||||
globalACMEDNS = options["dns"]
|
||||
if globalACMEDNS == nil {
|
||||
return fmt.Errorf("acme_dns specified without DNS provider config, but no provider specified with 'dns' global option")
|
||||
}
|
||||
}
|
||||
if acmeIssuer.Challenges == nil {
|
||||
acmeIssuer.Challenges = new(caddytls.ChallengesConfig)
|
||||
acmeIssuer.Challenges = &caddytls.ChallengesConfig{
|
||||
DNS: new(caddytls.DNSChallengeConfig),
|
||||
}
|
||||
if acmeIssuer.Challenges.DNS == nil {
|
||||
acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
|
||||
}
|
||||
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)
|
||||
} else if globalACMEDNS != nil {
|
||||
acmeIssuer.Challenges = &caddytls.ChallengesConfig{
|
||||
DNS: &caddytls.DNSChallengeConfig{
|
||||
ProviderRaw: caddyconfig.JSONModuleObject(globalACMEDNS, "name", globalACMEDNS.(caddy.Module).CaddyModule().ID.Name(), nil),
|
||||
},
|
||||
}
|
||||
}
|
||||
if globalACMEEAB != nil && acmeIssuer.ExternalAccount == nil {
|
||||
@@ -583,318 +606,12 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
|
||||
}
|
||||
acmeIssuer.Challenges.TLSALPN.AlternatePort = globalHTTPSPort.(int)
|
||||
}
|
||||
// If BindHost is still unset, fall back to the first default_bind address if set
|
||||
// This avoids binding the automation policy to the wildcard socket, which is unexpected behavior when a more selective socket is specified via default_bind
|
||||
// In BSD it is valid to bind to the wildcard socket even though a more selective socket is already open (still unexpected behavior by the caller though)
|
||||
// In Linux the same call will error with EADDRINUSE whenever the listener for the automation policy is opened
|
||||
if acmeIssuer.Challenges == nil || (acmeIssuer.Challenges.DNS == nil && acmeIssuer.Challenges.BindHost == "") {
|
||||
if defBinds, ok := globalDefaultBind.([]ConfigValue); ok && len(defBinds) > 0 {
|
||||
if abp, ok := defBinds[0].Value.(addressesWithProtocols); ok && len(abp.addresses) > 0 {
|
||||
if acmeIssuer.Challenges == nil {
|
||||
acmeIssuer.Challenges = new(caddytls.ChallengesConfig)
|
||||
}
|
||||
acmeIssuer.Challenges.BindHost = abp.addresses[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// implicitACMEIssuers returns the issuers to use for ACME-related tls
|
||||
// shortcuts such as ca, ca_root, and dns. If any global cert_issuer options
|
||||
// configure ACME issuers, those become the templates for the local shortcut
|
||||
// configuration; otherwise, default ACME issuers are used.
|
||||
func implicitACMEIssuers(h Helper, acmeIssuer *caddytls.ACMEIssuer) []certmagic.Issuer {
|
||||
globalIssuers, _ := h.Option("cert_issuer").([]certmagic.Issuer)
|
||||
|
||||
var implicitIssuers []certmagic.Issuer
|
||||
for _, issuer := range globalIssuers {
|
||||
acmeWrapper, ok := issuer.(acmeCapable)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
baseIssuer := acmeWrapper.GetACMEIssuer()
|
||||
if baseIssuer == nil {
|
||||
continue
|
||||
}
|
||||
implicitIssuers = append(implicitIssuers, mergeACMEIssuers(baseIssuer, acmeIssuer))
|
||||
}
|
||||
if len(implicitIssuers) > 0 {
|
||||
return implicitIssuers
|
||||
}
|
||||
|
||||
// If an ACME CA endpoint was set locally, the user expects to use only that
|
||||
// CA rather than the usual default fallback issuers.
|
||||
defaultIssuers := caddytls.DefaultIssuers(acmeIssuer.Email)
|
||||
if acmeIssuer.CA != "" {
|
||||
defaultIssuers = []certmagic.Issuer{new(caddytls.ACMEIssuer)}
|
||||
}
|
||||
|
||||
implicitIssuers = make([]certmagic.Issuer, 0, len(defaultIssuers))
|
||||
for _, issuer := range defaultIssuers {
|
||||
acmeWrapper, ok := issuer.(acmeCapable)
|
||||
if !ok {
|
||||
implicitIssuers = append(implicitIssuers, issuer)
|
||||
continue
|
||||
}
|
||||
baseIssuer := acmeWrapper.GetACMEIssuer()
|
||||
if baseIssuer == nil {
|
||||
implicitIssuers = append(implicitIssuers, issuer)
|
||||
continue
|
||||
}
|
||||
implicitIssuers = append(implicitIssuers, mergeACMEIssuers(baseIssuer, acmeIssuer))
|
||||
}
|
||||
return implicitIssuers
|
||||
}
|
||||
|
||||
func mergeACMEIssuers(base, overrides *caddytls.ACMEIssuer) *caddytls.ACMEIssuer {
|
||||
if base == nil {
|
||||
return cloneACMEIssuer(overrides)
|
||||
}
|
||||
|
||||
merged := cloneACMEIssuer(base)
|
||||
if overrides == nil {
|
||||
return merged
|
||||
}
|
||||
|
||||
if overrides.CA != "" {
|
||||
merged.CA = overrides.CA
|
||||
}
|
||||
if overrides.TestCA != "" {
|
||||
merged.TestCA = overrides.TestCA
|
||||
}
|
||||
if overrides.Email != "" {
|
||||
merged.Email = overrides.Email
|
||||
}
|
||||
if overrides.Profile != "" {
|
||||
merged.Profile = overrides.Profile
|
||||
}
|
||||
if overrides.AccountKey != "" {
|
||||
merged.AccountKey = overrides.AccountKey
|
||||
}
|
||||
if overrides.ExternalAccount != nil {
|
||||
merged.ExternalAccount = cloneACMEEAB(overrides.ExternalAccount)
|
||||
}
|
||||
if overrides.ACMETimeout != 0 {
|
||||
merged.ACMETimeout = overrides.ACMETimeout
|
||||
}
|
||||
if len(overrides.TrustedRootsPEMFiles) > 0 {
|
||||
merged.TrustedRootsPEMFiles = appendUniqueStrings(merged.TrustedRootsPEMFiles, overrides.TrustedRootsPEMFiles...)
|
||||
}
|
||||
if overrides.PreferredChains != nil {
|
||||
merged.PreferredChains = cloneChainPreference(overrides.PreferredChains)
|
||||
}
|
||||
if overrides.CertificateLifetime != 0 {
|
||||
merged.CertificateLifetime = overrides.CertificateLifetime
|
||||
}
|
||||
if len(overrides.NetworkProxyRaw) > 0 {
|
||||
merged.NetworkProxyRaw = slices.Clone(overrides.NetworkProxyRaw)
|
||||
}
|
||||
merged.Challenges = mergeChallengesConfig(merged.Challenges, overrides.Challenges)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeChallengesConfig(base, overrides *caddytls.ChallengesConfig) *caddytls.ChallengesConfig {
|
||||
if base == nil {
|
||||
return cloneChallengesConfig(overrides)
|
||||
}
|
||||
merged := cloneChallengesConfig(base)
|
||||
if overrides == nil {
|
||||
return merged
|
||||
}
|
||||
|
||||
merged.HTTP = mergeHTTPChallengeConfig(merged.HTTP, overrides.HTTP)
|
||||
merged.TLSALPN = mergeTLSALPNChallengeConfig(merged.TLSALPN, overrides.TLSALPN)
|
||||
merged.DNS = mergeDNSChallengeConfig(merged.DNS, overrides.DNS)
|
||||
if overrides.BindHost != "" {
|
||||
merged.BindHost = overrides.BindHost
|
||||
}
|
||||
if overrides.Distributed != nil {
|
||||
value := *overrides.Distributed
|
||||
merged.Distributed = &value
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeHTTPChallengeConfig(base, overrides *caddytls.HTTPChallengeConfig) *caddytls.HTTPChallengeConfig {
|
||||
if base == nil {
|
||||
return cloneHTTPChallengeConfig(overrides)
|
||||
}
|
||||
merged := cloneHTTPChallengeConfig(base)
|
||||
if overrides == nil {
|
||||
return merged
|
||||
}
|
||||
|
||||
if overrides.Disabled {
|
||||
merged.Disabled = true
|
||||
}
|
||||
if overrides.AlternatePort != 0 {
|
||||
merged.AlternatePort = overrides.AlternatePort
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeTLSALPNChallengeConfig(base, overrides *caddytls.TLSALPNChallengeConfig) *caddytls.TLSALPNChallengeConfig {
|
||||
if base == nil {
|
||||
return cloneTLSALPNChallengeConfig(overrides)
|
||||
}
|
||||
merged := cloneTLSALPNChallengeConfig(base)
|
||||
if overrides == nil {
|
||||
return merged
|
||||
}
|
||||
|
||||
if overrides.Disabled {
|
||||
merged.Disabled = true
|
||||
}
|
||||
if overrides.AlternatePort != 0 {
|
||||
merged.AlternatePort = overrides.AlternatePort
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func mergeDNSChallengeConfig(base, overrides *caddytls.DNSChallengeConfig) *caddytls.DNSChallengeConfig {
|
||||
if base == nil {
|
||||
return cloneDNSChallengeConfig(overrides)
|
||||
}
|
||||
merged := cloneDNSChallengeConfig(base)
|
||||
if overrides == nil {
|
||||
return merged
|
||||
}
|
||||
|
||||
if len(overrides.ProviderRaw) > 0 {
|
||||
merged.ProviderRaw = slices.Clone(overrides.ProviderRaw)
|
||||
}
|
||||
if overrides.PropagationDelay != 0 {
|
||||
merged.PropagationDelay = overrides.PropagationDelay
|
||||
}
|
||||
if overrides.PropagationTimeout != 0 {
|
||||
merged.PropagationTimeout = overrides.PropagationTimeout
|
||||
}
|
||||
if overrides.Resolvers != nil {
|
||||
merged.Resolvers = slices.Clone(overrides.Resolvers)
|
||||
}
|
||||
if overrides.OverrideDomain != "" {
|
||||
merged.OverrideDomain = overrides.OverrideDomain
|
||||
}
|
||||
if overrides.TTL != 0 {
|
||||
merged.TTL = overrides.TTL
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
func cloneACMEIssuer(iss *caddytls.ACMEIssuer) *caddytls.ACMEIssuer {
|
||||
if iss == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *iss
|
||||
cloned.Challenges = cloneChallengesConfig(iss.Challenges)
|
||||
cloned.ExternalAccount = cloneACMEEAB(iss.ExternalAccount)
|
||||
cloned.TrustedRootsPEMFiles = slices.Clone(iss.TrustedRootsPEMFiles)
|
||||
cloned.PreferredChains = cloneChainPreference(iss.PreferredChains)
|
||||
cloned.NetworkProxyRaw = slices.Clone(iss.NetworkProxyRaw)
|
||||
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneChallengesConfig(cfg *caddytls.ChallengesConfig) *caddytls.ChallengesConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *cfg
|
||||
cloned.HTTP = cloneHTTPChallengeConfig(cfg.HTTP)
|
||||
cloned.TLSALPN = cloneTLSALPNChallengeConfig(cfg.TLSALPN)
|
||||
cloned.DNS = cloneDNSChallengeConfig(cfg.DNS)
|
||||
if cfg.Distributed != nil {
|
||||
value := *cfg.Distributed
|
||||
cloned.Distributed = &value
|
||||
}
|
||||
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneHTTPChallengeConfig(cfg *caddytls.HTTPChallengeConfig) *caddytls.HTTPChallengeConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *cfg
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneTLSALPNChallengeConfig(cfg *caddytls.TLSALPNChallengeConfig) *caddytls.TLSALPNChallengeConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *cfg
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneDNSChallengeConfig(cfg *caddytls.DNSChallengeConfig) *caddytls.DNSChallengeConfig {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *cfg
|
||||
cloned.ProviderRaw = slices.Clone(cfg.ProviderRaw)
|
||||
cloned.Resolvers = slices.Clone(cfg.Resolvers)
|
||||
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneACMEEAB(eab *acme.EAB) *acme.EAB {
|
||||
if eab == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *eab
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneChainPreference(pref *caddytls.ChainPreference) *caddytls.ChainPreference {
|
||||
if pref == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cloned := *pref
|
||||
cloned.RootCommonName = slices.Clone(pref.RootCommonName)
|
||||
cloned.AnyCommonName = slices.Clone(pref.AnyCommonName)
|
||||
if pref.Smallest != nil {
|
||||
value := *pref.Smallest
|
||||
cloned.Smallest = &value
|
||||
}
|
||||
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func appendUniqueStrings(existing []string, additions ...string) []string {
|
||||
for _, value := range additions {
|
||||
if !slices.Contains(existing, value) {
|
||||
existing = append(existing, value)
|
||||
}
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
// newBaseAutomationPolicy returns a new TLS automation policy that gets
|
||||
// its values from the global options map. It should be used as the base
|
||||
// for any other automation policies. A nil policy (and no error) will be
|
||||
@@ -909,8 +626,7 @@ func newBaseAutomationPolicy(
|
||||
_, hasLocalCerts := options["local_certs"]
|
||||
keyType, hasKeyType := options["key_type"]
|
||||
ocspStapling, hasOCSPStapling := options["ocsp_stapling"]
|
||||
renewalWindowRatio, hasRenewalWindowRatio := options["renewal_window_ratio"]
|
||||
hasGlobalAutomationOpts := hasIssuers || hasLocalCerts || hasKeyType || hasOCSPStapling || hasRenewalWindowRatio
|
||||
hasGlobalAutomationOpts := hasIssuers || hasLocalCerts || hasKeyType || hasOCSPStapling
|
||||
|
||||
globalACMECA := options["acme_ca"]
|
||||
globalACMECARoot := options["acme_ca_root"]
|
||||
@@ -957,10 +673,6 @@ func newBaseAutomationPolicy(
|
||||
ap.OCSPOverrides = ocspConfig.ResponderOverrides
|
||||
}
|
||||
|
||||
if hasRenewalWindowRatio {
|
||||
ap.RenewalWindowRatio = renewalWindowRatio.(float64)
|
||||
}
|
||||
|
||||
return ap, nil
|
||||
}
|
||||
|
||||
@@ -981,31 +693,14 @@ func consolidateAutomationPolicies(aps []*caddytls.AutomationPolicy) []*caddytls
|
||||
emptyAPCount := 0
|
||||
origLenAPs := len(aps)
|
||||
// compute the number of empty policies (disregarding subjects) - see #4128
|
||||
// while we're at it,
|
||||
emptyAP := new(caddytls.AutomationPolicy)
|
||||
for i := 0; i < len(aps); i++ {
|
||||
emptyAP.SubjectsRaw = aps[i].SubjectsRaw
|
||||
emptyAP.ManagersRaw = nil
|
||||
if reflect.DeepEqual(aps[i], emptyAP) {
|
||||
// AP is empty
|
||||
emptyAPCount++
|
||||
|
||||
// see if this AP shadows something later
|
||||
shadowIdx := automationPolicyShadows(i, aps)
|
||||
emptyAP.SubjectsRaw = nil
|
||||
if shadowIdx >= 0 {
|
||||
emptyAP.SubjectsRaw = aps[shadowIdx].SubjectsRaw
|
||||
// allow the later policy, which is likely for a wildcard, to have cert
|
||||
// managers ("get_certificate"), since wildcards now cover specific
|
||||
// subdomains by default, when configured (see discussion in #7559)
|
||||
emptyAP.ManagersRaw = aps[shadowIdx].ManagersRaw
|
||||
}
|
||||
|
||||
// if this is the last AP, we can delete it, since auto-https should
|
||||
// pick it up; if it shadows something later that is also empty, we
|
||||
// can similarly delete this; but if it shadows something that is NOT
|
||||
// empty, we must not delete it since the shadowing has a purpose
|
||||
if i == len(aps)-1 || (shadowIdx >= 0 && reflect.DeepEqual(aps[shadowIdx], emptyAP)) {
|
||||
if !automationPolicyHasAllPublicNames(aps[i]) {
|
||||
// if this automation policy has internal names, we might as well remove it
|
||||
// so auto-https can implicitly use the internal issuer
|
||||
aps = slices.Delete(aps, i, i+1)
|
||||
i--
|
||||
}
|
||||
@@ -1139,3 +834,20 @@ 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
|
||||
}
|
||||
|
||||
@@ -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) //nolint:gosec // no SSRF; comes from trusted config
|
||||
resp, err := client.Do(request)
|
||||
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 := range maxAttempts {
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
resp, err = attemptHttpCall(client, request)
|
||||
if err != nil && i < maxAttempts-1 {
|
||||
select {
|
||||
|
||||
+1
-8
@@ -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) //nolint:gosec // false positive: no XSS here
|
||||
_, _ = w.Write(respBody)
|
||||
}
|
||||
body = result
|
||||
}
|
||||
@@ -121,13 +121,6 @@ func (adminLoad) handleLoad(w http.ResponseWriter, r *http.Request) error {
|
||||
}
|
||||
}
|
||||
|
||||
// If this request changed the config, clear the last
|
||||
// config info we have stored, if it is different from
|
||||
// the original source.
|
||||
caddy.ClearLastConfigIfDifferent(
|
||||
r.Header.Get("Caddy-Config-Source-File"),
|
||||
r.Header.Get("Caddy-Config-Source-Adapter"))
|
||||
|
||||
caddy.Log().Named("admin.api").Info("load complete")
|
||||
|
||||
return nil
|
||||
|
||||
+3
-25
@@ -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) //nolint:gosec // no SSRF because URL is hard-coded to localhost, and port comes from config
|
||||
res, err := client.Do(req)
|
||||
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()) //nolint:gosec // false positive, filename comes from std lib, no path traversal
|
||||
os.Remove(f.Name())
|
||||
})
|
||||
if _, err := fmt.Fprintf(f, initConfig, tc.config.AdminPort); err != nil {
|
||||
return err
|
||||
@@ -362,8 +362,6 @@ 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)
|
||||
@@ -374,8 +372,6 @@ 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
|
||||
}
|
||||
@@ -413,8 +409,6 @@ 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)
|
||||
@@ -474,8 +468,6 @@ 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()
|
||||
@@ -504,9 +496,7 @@ 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 {
|
||||
tc.t.Helper()
|
||||
|
||||
resp, err := tc.Client.Do(req) //nolint:gosec // no SSRFs demonstrated
|
||||
resp, err := tc.Client.Do(req)
|
||||
if err != nil {
|
||||
tc.t.Fatalf("failed to call server %s", err)
|
||||
}
|
||||
@@ -520,8 +510,6 @@ 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()
|
||||
@@ -543,8 +531,6 @@ 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)
|
||||
@@ -555,8 +541,6 @@ 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)
|
||||
@@ -567,8 +551,6 @@ 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)
|
||||
@@ -582,8 +564,6 @@ 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)
|
||||
@@ -597,8 +577,6 @@ 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)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package caddytest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -127,118 +126,3 @@ 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")
|
||||
}
|
||||
|
||||
@@ -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(), zapslog.WithName("acmez"))),
|
||||
Logger: slog.New(zapslog.NewHandler(logger.Core())),
|
||||
},
|
||||
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(), zapslog.WithName("acmez"))),
|
||||
Logger: slog.New(zapslog.NewHandler(logger.Core())),
|
||||
},
|
||||
ChallengeSolvers: map[string]acmez.Solver{
|
||||
acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},
|
||||
|
||||
@@ -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 !strings.Contains(err.Error(), "urn:ietf:params:acme:error:rejectedIdentifier") {
|
||||
} else if err != nil && !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 !strings.Contains(err.Error(), "urn:ietf:params:acme:error:rejectedIdentifier") {
|
||||
} else if err != nil && !strings.Contains(err.Error(), "urn:ietf:params:acme:error:rejectedIdentifier") {
|
||||
t.Logf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,28 +55,6 @@ func TestAutoHTTPtoHTTPSRedirectsExplicitPortDifferentFromHTTPSPort(t *testing.T
|
||||
tester.AssertRedirect("http://localhost:9080/", "https://localhost:1234/", http.StatusPermanentRedirect)
|
||||
}
|
||||
|
||||
func TestAutoHTTPtoHTTPSRedirectsPreferHTTPSPortOverAlternatePort(t *testing.T) {
|
||||
tester := caddytest.NewTester(t)
|
||||
tester.InitServer(`
|
||||
{
|
||||
skip_install_trust
|
||||
admin localhost:2999
|
||||
http_port 9080
|
||||
https_port 9443
|
||||
local_certs
|
||||
}
|
||||
localhost {
|
||||
respond "Canonical"
|
||||
}
|
||||
|
||||
localhost:10443 {
|
||||
respond "Alternate"
|
||||
}
|
||||
`, "caddyfile")
|
||||
|
||||
tester.AssertRedirect("http://localhost:9080/", "https://localhost/", http.StatusPermanentRedirect)
|
||||
}
|
||||
|
||||
func TestAutoHTTPRedirectsWithHTTPListenerFirstInAddresses(t *testing.T) {
|
||||
tester := caddytest.NewTester(t)
|
||||
tester.InitServer(`
|
||||
@@ -165,26 +143,3 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
{
|
||||
acme_dns mock foo
|
||||
}
|
||||
|
||||
example.com {
|
||||
respond "Hello World"
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"example.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "Hello World",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tls": {
|
||||
"automation": {
|
||||
"policies": [
|
||||
{
|
||||
"issuers": [
|
||||
{
|
||||
"challenges": {
|
||||
"dns": {
|
||||
"provider": {
|
||||
"argument": "foo",
|
||||
"name": "mock"
|
||||
}
|
||||
}
|
||||
},
|
||||
"module": "acme"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
acme_dns
|
||||
}
|
||||
|
||||
example.com {
|
||||
respond "Hello World"
|
||||
}
|
||||
----------
|
||||
acme_dns specified without DNS provider config, but no provider specified with 'dns' global option
|
||||
@@ -18,9 +18,7 @@ encode gzip zstd {
|
||||
|
||||
# Long way with a block for each encoding
|
||||
encode {
|
||||
zstd {
|
||||
disable_checksum
|
||||
}
|
||||
zstd
|
||||
gzip 5
|
||||
}
|
||||
|
||||
@@ -73,9 +71,7 @@ encode
|
||||
"gzip": {
|
||||
"level": 5
|
||||
},
|
||||
"zstd": {
|
||||
"checksum": false
|
||||
}
|
||||
"zstd": {}
|
||||
},
|
||||
"handler": "encode",
|
||||
"prefer": [
|
||||
|
||||
@@ -46,18 +46,6 @@ app.example.com {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "headers",
|
||||
"request": {
|
||||
"delete": [
|
||||
"Remote-Email"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
@@ -85,18 +73,6 @@ app.example.com {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "headers",
|
||||
"request": {
|
||||
"delete": [
|
||||
"Remote-Groups"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
@@ -124,18 +100,6 @@ app.example.com {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "headers",
|
||||
"request": {
|
||||
"delete": [
|
||||
"Remote-Name"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
@@ -163,18 +127,6 @@ app.example.com {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "headers",
|
||||
"request": {
|
||||
"delete": [
|
||||
"Remote-User"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
@@ -248,4 +200,4 @@ app.example.com {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
: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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,18 +35,6 @@ forward_auth localhost:9000 {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "headers",
|
||||
"request": {
|
||||
"delete": [
|
||||
"1"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
@@ -74,18 +62,6 @@ forward_auth localhost:9000 {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "headers",
|
||||
"request": {
|
||||
"delete": [
|
||||
"B"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
@@ -113,18 +89,6 @@ forward_auth localhost:9000 {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "headers",
|
||||
"request": {
|
||||
"delete": [
|
||||
"3"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
@@ -152,18 +116,6 @@ forward_auth localhost:9000 {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "headers",
|
||||
"request": {
|
||||
"delete": [
|
||||
"D"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
@@ -191,18 +143,6 @@ forward_auth localhost:9000 {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "headers",
|
||||
"request": {
|
||||
"delete": [
|
||||
"5"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
@@ -263,4 +203,4 @@ forward_auth localhost:9000 {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
log {
|
||||
sampling {
|
||||
interval 5m
|
||||
interval 300
|
||||
first 50
|
||||
thereafter 40
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
"logs": {
|
||||
"default": {
|
||||
"sampling": {
|
||||
"interval": 300000000000,
|
||||
"interval": 300,
|
||||
"first": 50,
|
||||
"thereafter": 40
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
{
|
||||
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
{
|
||||
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
{
|
||||
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
{
|
||||
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
{
|
||||
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,6 @@
|
||||
trusted_proxies static private_ranges
|
||||
client_ip_headers Custom-Real-Client-IP X-Forwarded-For
|
||||
client_ip_headers A-Third-One
|
||||
keepalive_interval 20s
|
||||
keepalive_idle 20s
|
||||
keepalive_count 10
|
||||
0rtt off
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,9 +45,6 @@ foo.com {
|
||||
"read_header_timeout": 30000000000,
|
||||
"write_timeout": 30000000000,
|
||||
"idle_timeout": 30000000000,
|
||||
"keepalive_interval": 20000000000,
|
||||
"keepalive_idle": 20000000000,
|
||||
"keepalive_count": 10,
|
||||
"max_header_bytes": 100000000,
|
||||
"enable_full_duplex": true,
|
||||
"routes": [
|
||||
@@ -91,10 +84,9 @@ foo.com {
|
||||
"h2",
|
||||
"h2c",
|
||||
"h3"
|
||||
],
|
||||
"allow_0rtt": false
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
(site) {
|
||||
http://{args[0]} https://{args[0]} {
|
||||
{block}
|
||||
}
|
||||
}
|
||||
import site test.domain {
|
||||
{
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
----------
|
||||
anonymous blocks are not supported
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
admin off
|
||||
auto_https off
|
||||
}
|
||||
|
||||
import testdata/issue_7557_invalid_subdirective_snippet.conf
|
||||
|
||||
:8080 {
|
||||
import test {
|
||||
this_is_nonsense
|
||||
}
|
||||
}
|
||||
|
||||
----------
|
||||
parsing caddyfile tokens for 'reverse_proxy': unrecognized subdirective this_is_nonsense
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
(snippet) {
|
||||
header {
|
||||
reverse_proxy localhost:3000
|
||||
{block}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
(snippet) {
|
||||
header {
|
||||
reverse_proxy localhost:3000
|
||||
{blocks.content_type}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
(site) {
|
||||
https://{args[0]} {
|
||||
{block}
|
||||
}
|
||||
}
|
||||
|
||||
import site test.domain {
|
||||
reverse_proxy http://192.168.1.1:8080 {
|
||||
header_up Host {host}
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"test.domain"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "reverse_proxy",
|
||||
"headers": {
|
||||
"request": {
|
||||
"set": {
|
||||
"Host": [
|
||||
"{http.request.host}"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"upstreams": [
|
||||
{
|
||||
"dial": "192.168.1.1:8080"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
log {
|
||||
format journald {
|
||||
wrap console
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:80 {
|
||||
respond "Hello, World!"
|
||||
}
|
||||
----------
|
||||
{
|
||||
"logging": {
|
||||
"logs": {
|
||||
"default": {
|
||||
"encoder": {
|
||||
"format": "journald",
|
||||
"wrap": {
|
||||
"format": "console"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":80"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "Hello, World!",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
:80
|
||||
|
||||
log {
|
||||
output stdout
|
||||
format filter {
|
||||
wrap console
|
||||
|
||||
# Multiple regexp filters for the same field - this should work now!
|
||||
request>headers>Authorization regexp "Bearer\s+([A-Za-z0-9_-]+)" "Bearer [REDACTED]"
|
||||
request>headers>Authorization regexp "Basic\s+([A-Za-z0-9+/=]+)" "Basic [REDACTED]"
|
||||
request>headers>Authorization regexp "token=([^&\s]+)" "token=[REDACTED]"
|
||||
|
||||
# Single regexp filter - this should continue to work as before
|
||||
request>headers>Cookie regexp "sessionid=[^;]+" "sessionid=[REDACTED]"
|
||||
|
||||
# Mixed filters (non-regexp) - these should work normally
|
||||
request>headers>Server delete
|
||||
request>remote_ip ip_mask {
|
||||
ipv4 24
|
||||
ipv6 32
|
||||
}
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"logging": {
|
||||
"logs": {
|
||||
"default": {
|
||||
"exclude": [
|
||||
"http.log.access.log0"
|
||||
]
|
||||
},
|
||||
"log0": {
|
||||
"writer": {
|
||||
"output": "stdout"
|
||||
},
|
||||
"encoder": {
|
||||
"fields": {
|
||||
"request\u003eheaders\u003eAuthorization": {
|
||||
"filter": "multi_regexp",
|
||||
"operations": [
|
||||
{
|
||||
"regexp": "Bearer\\s+([A-Za-z0-9_-]+)",
|
||||
"value": "Bearer [REDACTED]"
|
||||
},
|
||||
{
|
||||
"regexp": "Basic\\s+([A-Za-z0-9+/=]+)",
|
||||
"value": "Basic [REDACTED]"
|
||||
},
|
||||
{
|
||||
"regexp": "token=([^\u0026\\s]+)",
|
||||
"value": "token=[REDACTED]"
|
||||
}
|
||||
]
|
||||
},
|
||||
"request\u003eheaders\u003eCookie": {
|
||||
"filter": "regexp",
|
||||
"regexp": "sessionid=[^;]+",
|
||||
"value": "sessionid=[REDACTED]"
|
||||
},
|
||||
"request\u003eheaders\u003eServer": {
|
||||
"filter": "delete"
|
||||
},
|
||||
"request\u003eremote_ip": {
|
||||
"filter": "ip_mask",
|
||||
"ipv4_cidr": 24,
|
||||
"ipv6_cidr": 32
|
||||
}
|
||||
},
|
||||
"format": "filter",
|
||||
"wrap": {
|
||||
"format": "console"
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"http.log.access.log0"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":80"
|
||||
],
|
||||
"logs": {
|
||||
"default_logger_name": "log0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,27 @@
|
||||
:80
|
||||
|
||||
log one {
|
||||
log {
|
||||
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.one",
|
||||
"http.log.access.two"
|
||||
"http.log.access.log0"
|
||||
]
|
||||
},
|
||||
"one": {
|
||||
"log0": {
|
||||
"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,
|
||||
@@ -49,35 +29,7 @@ log two {
|
||||
"roll_size_mb": 954
|
||||
},
|
||||
"include": [
|
||||
"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"
|
||||
"http.log.access.log0"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -90,7 +42,7 @@ log two {
|
||||
":80"
|
||||
],
|
||||
"logs": {
|
||||
"default_logger_name": "two"
|
||||
"default_logger_name": "log0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
:80 {
|
||||
log {
|
||||
sampling {
|
||||
interval 5m
|
||||
interval 300
|
||||
first 50
|
||||
thereafter 40
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
"log0": {
|
||||
"sampling": {
|
||||
"interval": 300000000000,
|
||||
"interval": 300,
|
||||
"first": 50,
|
||||
"thereafter": 40
|
||||
},
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
metrics {
|
||||
otlp
|
||||
}
|
||||
}
|
||||
:80 {
|
||||
respond "Hello"
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":80"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "Hello",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"otlp": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
{
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
:8884
|
||||
|
||||
reverse_proxy 127.0.0.1:65535 {
|
||||
lb_retries 3
|
||||
lb_retry_match expression `{rp.status_code} in [502, 503]`
|
||||
lb_retry_match expression `{rp.is_transport_error} || {rp.status_code} == 502`
|
||||
lb_retry_match expression `method('POST') && {rp.status_code} == 503`
|
||||
lb_retry_match `{rp.status_code} == 504`
|
||||
lb_retry_match `{rp.is_transport_error} && method('PUT')`
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":8884"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "reverse_proxy",
|
||||
"load_balancing": {
|
||||
"retries": 3,
|
||||
"retry_match": [
|
||||
{
|
||||
"expression": "{http.reverse_proxy.status_code} in [502, 503]"
|
||||
},
|
||||
{
|
||||
"expression": "{http.reverse_proxy.is_transport_error} || {http.reverse_proxy.status_code} == 502"
|
||||
},
|
||||
{
|
||||
"expression": "method('POST') \u0026\u0026 {http.reverse_proxy.status_code} == 503"
|
||||
},
|
||||
{
|
||||
"expression": "{http.reverse_proxy.status_code} == 504"
|
||||
},
|
||||
{
|
||||
"expression": "{http.reverse_proxy.is_transport_error} \u0026\u0026 method('PUT')"
|
||||
}
|
||||
]
|
||||
},
|
||||
"upstreams": [
|
||||
{
|
||||
"dial": "127.0.0.1:65535"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-147
@@ -1,147 +0,0 @@
|
||||
:8884
|
||||
|
||||
reverse_proxy 127.0.0.1:65535 {
|
||||
lb_retries 5
|
||||
|
||||
# request matchers (backward-compatible, non-expression)
|
||||
lb_retry_match {
|
||||
method POST PUT
|
||||
}
|
||||
lb_retry_match {
|
||||
path /foo*
|
||||
}
|
||||
lb_retry_match {
|
||||
header X-Idempotency-Key *
|
||||
}
|
||||
|
||||
# response status code via expression
|
||||
lb_retry_match {
|
||||
expression `{rp.status_code} in [502, 503, 504]`
|
||||
}
|
||||
|
||||
# response header via expression
|
||||
lb_retry_match {
|
||||
expression `{rp.header.X-Retry} == "true"`
|
||||
}
|
||||
|
||||
# CEL request functions combined with response placeholders
|
||||
lb_retry_match {
|
||||
expression `method('POST') && {rp.status_code} >= 500`
|
||||
}
|
||||
lb_retry_match {
|
||||
expression `path('/api*') && {rp.status_code} in [502, 503]`
|
||||
}
|
||||
lb_retry_match {
|
||||
expression `host('example.com') && {rp.status_code} == 503`
|
||||
}
|
||||
lb_retry_match {
|
||||
expression `query({'retry': 'true'}) && {rp.status_code} >= 500`
|
||||
}
|
||||
lb_retry_match {
|
||||
expression `header({'X-Idempotency-Key': '*'}) && {rp.status_code} in [502, 503]`
|
||||
}
|
||||
lb_retry_match {
|
||||
expression `protocol('https') && {rp.status_code} == 502`
|
||||
}
|
||||
lb_retry_match {
|
||||
expression `path_regexp('^/api/v[0-9]+/') && {rp.status_code} >= 500`
|
||||
}
|
||||
lb_retry_match {
|
||||
expression `header_regexp('Content-Type', '^application/json') && {rp.status_code} == 502`
|
||||
}
|
||||
|
||||
# transport error handling via placeholder
|
||||
lb_retry_match {
|
||||
expression `{rp.is_transport_error} || {rp.status_code} in [502, 503]`
|
||||
}
|
||||
lb_retry_match {
|
||||
expression `{rp.is_transport_error} && method('POST')`
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":8884"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "reverse_proxy",
|
||||
"load_balancing": {
|
||||
"retries": 5,
|
||||
"retry_match": [
|
||||
{
|
||||
"method": [
|
||||
"POST",
|
||||
"PUT"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": [
|
||||
"/foo*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": {
|
||||
"X-Idempotency-Key": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"expression": "{http.reverse_proxy.status_code} in [502, 503, 504]"
|
||||
},
|
||||
{
|
||||
"expression": "{http.reverse_proxy.header.X-Retry} == \"true\""
|
||||
},
|
||||
{
|
||||
"expression": "method('POST') \u0026\u0026 {http.reverse_proxy.status_code} \u003e= 500"
|
||||
},
|
||||
{
|
||||
"expression": "path('/api*') \u0026\u0026 {http.reverse_proxy.status_code} in [502, 503]"
|
||||
},
|
||||
{
|
||||
"expression": "host('example.com') \u0026\u0026 {http.reverse_proxy.status_code} == 503"
|
||||
},
|
||||
{
|
||||
"expression": "query({'retry': 'true'}) \u0026\u0026 {http.reverse_proxy.status_code} \u003e= 500"
|
||||
},
|
||||
{
|
||||
"expression": "header({'X-Idempotency-Key': '*'}) \u0026\u0026 {http.reverse_proxy.status_code} in [502, 503]"
|
||||
},
|
||||
{
|
||||
"expression": "protocol('https') \u0026\u0026 {http.reverse_proxy.status_code} == 502"
|
||||
},
|
||||
{
|
||||
"expression": "path_regexp('^/api/v[0-9]+/') \u0026\u0026 {http.reverse_proxy.status_code} \u003e= 500"
|
||||
},
|
||||
{
|
||||
"expression": "header_regexp('Content-Type', '^application/json') \u0026\u0026 {http.reverse_proxy.status_code} == 502"
|
||||
},
|
||||
{
|
||||
"expression": "{http.reverse_proxy.is_transport_error} || {http.reverse_proxy.status_code} in [502, 503]"
|
||||
},
|
||||
{
|
||||
"expression": "{http.reverse_proxy.is_transport_error} \u0026\u0026 method('POST')"
|
||||
}
|
||||
]
|
||||
},
|
||||
"upstreams": [
|
||||
{
|
||||
"dial": "127.0.0.1:65535"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
https://example.com {
|
||||
reverse_proxy https://localhost:54321 {
|
||||
stream_buffer_size 8KB
|
||||
}
|
||||
}
|
||||
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"example.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "reverse_proxy",
|
||||
"stream_buffer_size": 8000,
|
||||
"transport": {
|
||||
"protocol": "http",
|
||||
"tls": {}
|
||||
},
|
||||
"upstreams": [
|
||||
{
|
||||
"dial": "localhost:54321"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
{
|
||||
servers {
|
||||
trusted_proxies_unix
|
||||
}
|
||||
}
|
||||
|
||||
example.com {
|
||||
reverse_proxy https://local:8080
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"example.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "reverse_proxy",
|
||||
"transport": {
|
||||
"protocol": "http",
|
||||
"tls": {}
|
||||
},
|
||||
"upstreams": [
|
||||
{
|
||||
"dial": "local:8080"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
],
|
||||
"trusted_proxies_unix": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
{
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,11 @@ b.com {
|
||||
"via": "http"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"subjects": [
|
||||
"b.com"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# example from https://github.com/caddyserver/caddy/issues/7559
|
||||
*.test.local {
|
||||
tls {
|
||||
get_certificate http http://cert-server:9000/certs
|
||||
}
|
||||
respond "wildcard"
|
||||
}
|
||||
|
||||
# certificate for this subdomain is covered by wildcard above
|
||||
subdomain.test.local {
|
||||
respond "subdomain"
|
||||
}
|
||||
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"subdomain.test.local"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "subdomain",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
},
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"*.test.local"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "wildcard",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tls": {
|
||||
"automation": {
|
||||
"policies": [
|
||||
{
|
||||
"subjects": [
|
||||
"*.test.local"
|
||||
],
|
||||
"get_certificate": [
|
||||
{
|
||||
"url": "http://cert-server:9000/certs",
|
||||
"via": "http"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
localhost
|
||||
|
||||
respond "hello from localhost"
|
||||
tls {
|
||||
client_auth {
|
||||
mode request
|
||||
trust_pool combined {
|
||||
source inline {
|
||||
trust_der MIIDSzCCAjOgAwIBAgIUfIRObjWNUA4jxQ/0x8BOCvE2Vw4wDQYJKoZIhvcNAQELBQAwFjEUMBIGA1UEAwwLRWFzeS1SU0EgQ0EwHhcNMTkwODI4MTYyNTU5WhcNMjkwODI1MTYyNTU5WjAWMRQwEgYDVQQDDAtFYXN5LVJTQSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK5m5elxhQfMp/3aVJ4JnpN9PUSz6LlP6LePAPFU7gqohVVFVtDkChJAG3FNkNQNlieVTja/bgH9IcC6oKbROwdY1h0MvNV8AHHigvl03WuJD8g2ReVFXXwsnrPmKXCFzQyMI6TYk3m2gYrXsZOU1GLnfMRC3KAMRgE2F45twOs9hqG169YJ6mM2eQjzjCHWI6S2/iUYvYxRkCOlYUbLsMD/AhgAf1plzg6LPqNxtdlwxZnA0ytgkmhK67HtzJu0+ovUCsMv0RwcMhsEo9T8nyFAGt9XLZ63X5WpBCTUApaAUhnG0XnerjmUWb6eUWw4zev54sEfY5F3x002iQaW6cECAwEAAaOBkDCBjTAdBgNVHQ4EFgQU4CBUbZsS2GaNIkGRz/cBsD5ivjswUQYDVR0jBEowSIAU4CBUbZsS2GaNIkGRz/cBsD5ivjuhGqQYMBYxFDASBgNVBAMMC0Vhc3ktUlNBIENBghR8hE5uNY1QDiPFD/THwE4K8TZXDjAMBgNVHRMEBTADAQH/MAsGA1UdDwQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAKB3V4HIzoiO/Ch6WMj9bLJ2FGbpkMrcb/Eq01hT5zcfKD66lVS1MlK+cRL446Z2b2KDP1oFyVs+qmrmtdwrWgD+nfe2sBmmIHo9m9KygMkEOfG3MghGTEcS+0cTKEcoHYWYyOqQh6jnedXY8Cdm4GM1hAc9MiL3/sqV8YCVSLNnkoNysmr06/rZ0MCUZPGUtRmfd0heWhrfzAKw2HLgX+RAmpOE2MZqWcjvqKGyaRiaZks4nJkP6521aC2Lgp0HhCz1j8/uQ5ldoDszCnu/iro0NAsNtudTMD+YoLQxLqdleIh6CW+illc2VdXwj7mn6J04yns9jfE2jRjW/yTLFuQ==
|
||||
}
|
||||
source file {
|
||||
pem_file ../caddy.ca.cer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "hello from localhost",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
],
|
||||
"tls_connection_policies": [
|
||||
{
|
||||
"match": {
|
||||
"sni": [
|
||||
"localhost"
|
||||
]
|
||||
},
|
||||
"client_authentication": {
|
||||
"ca": {
|
||||
"provider": "combined",
|
||||
"sources": [
|
||||
{
|
||||
"provider": "inline",
|
||||
"trusted_ca_certs": [
|
||||
"MIIDSzCCAjOgAwIBAgIUfIRObjWNUA4jxQ/0x8BOCvE2Vw4wDQYJKoZIhvcNAQELBQAwFjEUMBIGA1UEAwwLRWFzeS1SU0EgQ0EwHhcNMTkwODI4MTYyNTU5WhcNMjkwODI1MTYyNTU5WjAWMRQwEgYDVQQDDAtFYXN5LVJTQSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK5m5elxhQfMp/3aVJ4JnpN9PUSz6LlP6LePAPFU7gqohVVFVtDkChJAG3FNkNQNlieVTja/bgH9IcC6oKbROwdY1h0MvNV8AHHigvl03WuJD8g2ReVFXXwsnrPmKXCFzQyMI6TYk3m2gYrXsZOU1GLnfMRC3KAMRgE2F45twOs9hqG169YJ6mM2eQjzjCHWI6S2/iUYvYxRkCOlYUbLsMD/AhgAf1plzg6LPqNxtdlwxZnA0ytgkmhK67HtzJu0+ovUCsMv0RwcMhsEo9T8nyFAGt9XLZ63X5WpBCTUApaAUhnG0XnerjmUWb6eUWw4zev54sEfY5F3x002iQaW6cECAwEAAaOBkDCBjTAdBgNVHQ4EFgQU4CBUbZsS2GaNIkGRz/cBsD5ivjswUQYDVR0jBEowSIAU4CBUbZsS2GaNIkGRz/cBsD5ivjuhGqQYMBYxFDASBgNVBAMMC0Vhc3ktUlNBIENBghR8hE5uNY1QDiPFD/THwE4K8TZXDjAMBgNVHRMEBTADAQH/MAsGA1UdDwQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAKB3V4HIzoiO/Ch6WMj9bLJ2FGbpkMrcb/Eq01hT5zcfKD66lVS1MlK+cRL446Z2b2KDP1oFyVs+qmrmtdwrWgD+nfe2sBmmIHo9m9KygMkEOfG3MghGTEcS+0cTKEcoHYWYyOqQh6jnedXY8Cdm4GM1hAc9MiL3/sqV8YCVSLNnkoNysmr06/rZ0MCUZPGUtRmfd0heWhrfzAKw2HLgX+RAmpOE2MZqWcjvqKGyaRiaZks4nJkP6521aC2Lgp0HhCz1j8/uQ5ldoDszCnu/iro0NAsNtudTMD+YoLQxLqdleIh6CW+illc2VdXwj7mn6J04yns9jfE2jRjW/yTLFuQ=="
|
||||
]
|
||||
},
|
||||
{
|
||||
"pem_files": [
|
||||
"../caddy.ca.cer"
|
||||
],
|
||||
"provider": "file"
|
||||
}
|
||||
]
|
||||
},
|
||||
"mode": "request"
|
||||
}
|
||||
},
|
||||
{}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
localhost
|
||||
|
||||
respond "hello from localhost"
|
||||
tls {
|
||||
client_auth {
|
||||
mode require_and_verify
|
||||
trust_pool combined {
|
||||
source inline {
|
||||
trust_der MIIDSzCCAjOgAwIBAgIUfIRObjWNUA4jxQ/0x8BOCvE2Vw4wDQYJKoZIhvcNAQELBQAwFjEUMBIGA1UEAwwLRWFzeS1SU0EgQ0EwHhcNMTkwODI4MTYyNTU5WhcNMjkwODI1MTYyNTU5WjAWMRQwEgYDVQQDDAtFYXN5LVJTQSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK5m5elxhQfMp/3aVJ4JnpN9PUSz6LlP6LePAPFU7gqohVVFVtDkChJAG3FNkNQNlieVTja/bgH9IcC6oKbROwdY1h0MvNV8AHHigvl03WuJD8g2ReVFXXwsnrPmKXCFzQyMI6TYk3m2gYrXsZOU1GLnfMRC3KAMRgE2F45twOs9hqG169YJ6mM2eQjzjCHWI6S2/iUYvYxRkCOlYUbLsMD/AhgAf1plzg6LPqNxtdlwxZnA0ytgkmhK67HtzJu0+ovUCsMv0RwcMhsEo9T8nyFAGt9XLZ63X5WpBCTUApaAUhnG0XnerjmUWb6eUWw4zev54sEfY5F3x002iQaW6cECAwEAAaOBkDCBjTAdBgNVHQ4EFgQU4CBUbZsS2GaNIkGRz/cBsD5ivjswUQYDVR0jBEowSIAU4CBUbZsS2GaNIkGRz/cBsD5ivjuhGqQYMBYxFDASBgNVBAMMC0Vhc3ktUlNBIENBghR8hE5uNY1QDiPFD/THwE4K8TZXDjAMBgNVHRMEBTADAQH/MAsGA1UdDwQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAKB3V4HIzoiO/Ch6WMj9bLJ2FGbpkMrcb/Eq01hT5zcfKD66lVS1MlK+cRL446Z2b2KDP1oFyVs+qmrmtdwrWgD+nfe2sBmmIHo9m9KygMkEOfG3MghGTEcS+0cTKEcoHYWYyOqQh6jnedXY8Cdm4GM1hAc9MiL3/sqV8YCVSLNnkoNysmr06/rZ0MCUZPGUtRmfd0heWhrfzAKw2HLgX+RAmpOE2MZqWcjvqKGyaRiaZks4nJkP6521aC2Lgp0HhCz1j8/uQ5ldoDszCnu/iro0NAsNtudTMD+YoLQxLqdleIh6CW+illc2VdXwj7mn6J04yns9jfE2jRjW/yTLFuQ==
|
||||
}
|
||||
source pki_root {
|
||||
authority local
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "hello from localhost",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
],
|
||||
"tls_connection_policies": [
|
||||
{
|
||||
"match": {
|
||||
"sni": [
|
||||
"localhost"
|
||||
]
|
||||
},
|
||||
"client_authentication": {
|
||||
"ca": {
|
||||
"provider": "combined",
|
||||
"sources": [
|
||||
{
|
||||
"provider": "inline",
|
||||
"trusted_ca_certs": [
|
||||
"MIIDSzCCAjOgAwIBAgIUfIRObjWNUA4jxQ/0x8BOCvE2Vw4wDQYJKoZIhvcNAQELBQAwFjEUMBIGA1UEAwwLRWFzeS1SU0EgQ0EwHhcNMTkwODI4MTYyNTU5WhcNMjkwODI1MTYyNTU5WjAWMRQwEgYDVQQDDAtFYXN5LVJTQSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAK5m5elxhQfMp/3aVJ4JnpN9PUSz6LlP6LePAPFU7gqohVVFVtDkChJAG3FNkNQNlieVTja/bgH9IcC6oKbROwdY1h0MvNV8AHHigvl03WuJD8g2ReVFXXwsnrPmKXCFzQyMI6TYk3m2gYrXsZOU1GLnfMRC3KAMRgE2F45twOs9hqG169YJ6mM2eQjzjCHWI6S2/iUYvYxRkCOlYUbLsMD/AhgAf1plzg6LPqNxtdlwxZnA0ytgkmhK67HtzJu0+ovUCsMv0RwcMhsEo9T8nyFAGt9XLZ63X5WpBCTUApaAUhnG0XnerjmUWb6eUWw4zev54sEfY5F3x002iQaW6cECAwEAAaOBkDCBjTAdBgNVHQ4EFgQU4CBUbZsS2GaNIkGRz/cBsD5ivjswUQYDVR0jBEowSIAU4CBUbZsS2GaNIkGRz/cBsD5ivjuhGqQYMBYxFDASBgNVBAMMC0Vhc3ktUlNBIENBghR8hE5uNY1QDiPFD/THwE4K8TZXDjAMBgNVHRMEBTADAQH/MAsGA1UdDwQEAwIBBjANBgkqhkiG9w0BAQsFAAOCAQEAKB3V4HIzoiO/Ch6WMj9bLJ2FGbpkMrcb/Eq01hT5zcfKD66lVS1MlK+cRL446Z2b2KDP1oFyVs+qmrmtdwrWgD+nfe2sBmmIHo9m9KygMkEOfG3MghGTEcS+0cTKEcoHYWYyOqQh6jnedXY8Cdm4GM1hAc9MiL3/sqV8YCVSLNnkoNysmr06/rZ0MCUZPGUtRmfd0heWhrfzAKw2HLgX+RAmpOE2MZqWcjvqKGyaRiaZks4nJkP6521aC2Lgp0HhCz1j8/uQ5ldoDszCnu/iro0NAsNtudTMD+YoLQxLqdleIh6CW+illc2VdXwj7mn6J04yns9jfE2jRjW/yTLFuQ=="
|
||||
]
|
||||
},
|
||||
{
|
||||
"authority": [
|
||||
"local"
|
||||
],
|
||||
"provider": "pki_root"
|
||||
}
|
||||
]
|
||||
},
|
||||
"mode": "require_and_verify"
|
||||
}
|
||||
},
|
||||
{}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
localhost
|
||||
|
||||
respond "hello from localhost"
|
||||
tls {
|
||||
client_auth {
|
||||
mode request
|
||||
trust_pool system
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "hello from localhost",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
],
|
||||
"tls_connection_policies": [
|
||||
{
|
||||
"match": {
|
||||
"sni": [
|
||||
"localhost"
|
||||
]
|
||||
},
|
||||
"client_authentication": {
|
||||
"ca": {
|
||||
"provider": "system"
|
||||
},
|
||||
"mode": "request"
|
||||
}
|
||||
},
|
||||
{}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
{
|
||||
acme_dns mock foo
|
||||
}
|
||||
|
||||
localhost {
|
||||
tls {
|
||||
dns mock bar
|
||||
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": "foo",
|
||||
"name": "mock"
|
||||
}
|
||||
}
|
||||
},
|
||||
"module": "acme"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
dns mock foo
|
||||
}
|
||||
|
||||
localhost {
|
||||
tls {
|
||||
dns mock bar
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"dns": {
|
||||
"argument": "foo",
|
||||
"name": "mock"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
{
|
||||
acme_dns mock
|
||||
}
|
||||
|
||||
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": {
|
||||
"name": "mock"
|
||||
},
|
||||
"resolvers": [
|
||||
"8.8.8.8",
|
||||
"8.8.4.4"
|
||||
]
|
||||
}
|
||||
},
|
||||
"module": "acme"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"issuers": [
|
||||
{
|
||||
"challenges": {
|
||||
"dns": {
|
||||
"provider": {
|
||||
"name": "mock"
|
||||
}
|
||||
}
|
||||
},
|
||||
"module": "acme"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/caddyserver/caddy/v2/caddytest"
|
||||
)
|
||||
|
||||
func newH2ListenerWithVersionsWithTLSTester(t *testing.T, serverVersions []string, clientVersions []string) *caddytest.Tester {
|
||||
const baseConfig = `
|
||||
{
|
||||
skip_install_trust
|
||||
admin localhost:2999
|
||||
http_port 9080
|
||||
https_port 9443
|
||||
servers :9443 {
|
||||
protocols %s
|
||||
}
|
||||
}
|
||||
localhost {
|
||||
respond "{http.request.tls.proto} {http.request.proto}"
|
||||
}
|
||||
`
|
||||
tester := caddytest.NewTester(t)
|
||||
tester.InitServer(fmt.Sprintf(baseConfig, strings.Join(serverVersions, " ")), "caddyfile")
|
||||
|
||||
tr := tester.Client.Transport.(*http.Transport)
|
||||
tr.TLSClientConfig.NextProtos = clientVersions
|
||||
tr.Protocols = new(http.Protocols)
|
||||
if slices.Contains(clientVersions, "h2") {
|
||||
tr.ForceAttemptHTTP2 = true
|
||||
tr.Protocols.SetHTTP2(true)
|
||||
}
|
||||
if !slices.Contains(clientVersions, "http/1.1") {
|
||||
tr.Protocols.SetHTTP1(false)
|
||||
}
|
||||
|
||||
return tester
|
||||
}
|
||||
|
||||
func TestH2ListenerWithTLS(t *testing.T) {
|
||||
tests := []struct {
|
||||
serverVersions []string
|
||||
clientVersions []string
|
||||
expectedBody string
|
||||
failed bool
|
||||
}{
|
||||
{[]string{"h2"}, []string{"h2"}, "h2 HTTP/2.0", false},
|
||||
{[]string{"h2"}, []string{"http/1.1"}, "", true},
|
||||
{[]string{"h1"}, []string{"http/1.1"}, "http/1.1 HTTP/1.1", false},
|
||||
{[]string{"h1"}, []string{"h2"}, "", true},
|
||||
{[]string{"h2", "h1"}, []string{"h2"}, "h2 HTTP/2.0", false},
|
||||
{[]string{"h2", "h1"}, []string{"http/1.1"}, "http/1.1 HTTP/1.1", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tester := newH2ListenerWithVersionsWithTLSTester(t, tc.serverVersions, tc.clientVersions)
|
||||
t.Logf("running with server versions %v and client versions %v:", tc.serverVersions, tc.clientVersions)
|
||||
if tc.failed {
|
||||
resp, err := tester.Client.Get("https://localhost:9443")
|
||||
if err == nil {
|
||||
t.Errorf("unexpected response: %d", resp.StatusCode)
|
||||
}
|
||||
} else {
|
||||
tester.AssertGetResponse("https://localhost:9443", 200, tc.expectedBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newH2ListenerWithVersionsWithoutTLSTester(t *testing.T, serverVersions []string, clientVersions []string) *caddytest.Tester {
|
||||
const baseConfig = `
|
||||
{
|
||||
skip_install_trust
|
||||
admin localhost:2999
|
||||
http_port 9080
|
||||
servers :9080 {
|
||||
protocols %s
|
||||
}
|
||||
}
|
||||
http://localhost {
|
||||
respond "{http.request.proto}"
|
||||
}
|
||||
`
|
||||
tester := caddytest.NewTester(t)
|
||||
tester.InitServer(fmt.Sprintf(baseConfig, strings.Join(serverVersions, " ")), "caddyfile")
|
||||
|
||||
tr := tester.Client.Transport.(*http.Transport)
|
||||
tr.Protocols = new(http.Protocols)
|
||||
if slices.Contains(clientVersions, "h2c") {
|
||||
tr.Protocols.SetHTTP1(false)
|
||||
tr.Protocols.SetUnencryptedHTTP2(true)
|
||||
} else if slices.Contains(clientVersions, "http/1.1") {
|
||||
tr.Protocols.SetHTTP1(true)
|
||||
tr.Protocols.SetUnencryptedHTTP2(false)
|
||||
}
|
||||
|
||||
return tester
|
||||
}
|
||||
|
||||
func TestH2ListenerWithoutTLS(t *testing.T) {
|
||||
tests := []struct {
|
||||
serverVersions []string
|
||||
clientVersions []string
|
||||
expectedBody string
|
||||
failed bool
|
||||
}{
|
||||
{[]string{"h2c"}, []string{"h2c"}, "HTTP/2.0", false},
|
||||
{[]string{"h2c"}, []string{"http/1.1"}, "", true},
|
||||
{[]string{"h1"}, []string{"http/1.1"}, "HTTP/1.1", false},
|
||||
{[]string{"h1"}, []string{"h2c"}, "", true},
|
||||
{[]string{"h2c", "h1"}, []string{"h2c"}, "HTTP/2.0", false},
|
||||
{[]string{"h2c", "h1"}, []string{"http/1.1"}, "HTTP/1.1", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tester := newH2ListenerWithVersionsWithoutTLSTester(t, tc.serverVersions, tc.clientVersions)
|
||||
t.Logf("running with server versions %v and client versions %v:", tc.serverVersions, tc.clientVersions)
|
||||
if tc.failed {
|
||||
resp, err := tester.Client.Get("http://localhost:9080")
|
||||
if err == nil {
|
||||
t.Errorf("unexpected response: %d", resp.StatusCode)
|
||||
}
|
||||
} else {
|
||||
tester.AssertGetResponse("http://localhost:9080", 200, tc.expectedBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package integration
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"math/rand"
|
||||
"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.NewChaCha8([32]byte{}).Read(body)
|
||||
rand.New(rand.NewSource(0)).Read(body)
|
||||
|
||||
tester := setupListenerWrapperTest(t, func(writer http.ResponseWriter, request *http.Request) {
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
@@ -15,9 +15,7 @@ func init() {
|
||||
}
|
||||
|
||||
// MockDNSProvider is a mock DNS provider, for testing config with DNS modules.
|
||||
type MockDNSProvider struct {
|
||||
Argument string `json:"argument,omitempty"` // optional argument useful for testing
|
||||
}
|
||||
type MockDNSProvider struct{}
|
||||
|
||||
// CaddyModule returns the Caddy module information.
|
||||
func (MockDNSProvider) CaddyModule() caddy.ModuleInfo {
|
||||
@@ -33,15 +31,7 @@ func (MockDNSProvider) Provision(ctx caddy.Context) error {
|
||||
}
|
||||
|
||||
// UnmarshalCaddyfile sets up the module from Caddyfile tokens.
|
||||
func (p *MockDNSProvider) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
|
||||
d.Next() // consume directive name
|
||||
|
||||
if d.NextArg() {
|
||||
p.Argument = d.Val()
|
||||
}
|
||||
if d.NextArg() {
|
||||
return d.Errf("unexpected argument '%s'", d.Val())
|
||||
}
|
||||
func (MockDNSProvider) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestLeafCertLifetimeLessThanIntermediate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
`, "json", "should be less than intermediate certificate lifetime")
|
||||
`, "json", "certificate lifetime (168h0m0s) should be less than intermediate certificate lifetime (168h0m0s)")
|
||||
}
|
||||
|
||||
func TestIntermediateLifetimeLessThanRoot(t *testing.T) {
|
||||
@@ -103,5 +103,5 @@ func TestIntermediateLifetimeLessThanRoot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
`, "json", "intermediate certificate lifetime must be less than actual root certificate lifetime")
|
||||
`, "json", "intermediate certificate lifetime must be less than root certificate lifetime (86400h0m0s)")
|
||||
}
|
||||
|
||||
@@ -1,595 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/caddyserver/caddy/v2/caddytest"
|
||||
)
|
||||
@@ -327,41 +327,6 @@ 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(`
|
||||
{
|
||||
@@ -371,6 +336,12 @@ 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
|
||||
@@ -384,68 +355,8 @@ func TestReverseProxyHealthCheck(t *testing.T) {
|
||||
}
|
||||
}
|
||||
`, "caddyfile")
|
||||
tester.AssertGetResponse("http://localhost:9080/", 200, "Hello, World!")
|
||||
}
|
||||
|
||||
// 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")
|
||||
time.Sleep(100 * time.Millisecond) // TODO: for some reason this test seems particularly flaky, getting 503 when it should be 200, unless we wait
|
||||
tester.AssertGetResponse("http://localhost:9080/", 200, "Hello, World!")
|
||||
}
|
||||
|
||||
@@ -563,233 +474,3 @@ func TestReverseProxyHealthCheckUnixSocketWithoutPort(t *testing.T) {
|
||||
|
||||
tester.AssertGetResponse("http://localhost:9080/", 200, "Hello, World!")
|
||||
}
|
||||
|
||||
// TestReverseProxyRetryMatchStatusCode verifies that lb_retry_match with a
|
||||
// CEL expression matching on {rp.status_code} causes the request to be
|
||||
// retried on the next upstream when the first upstream returns a matching
|
||||
// status code
|
||||
func TestReverseProxyRetryMatchStatusCode(t *testing.T) {
|
||||
// Bad upstream: returns 502
|
||||
badSrv := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}),
|
||||
}
|
||||
badLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
go badSrv.Serve(badLn)
|
||||
t.Cleanup(func() { badSrv.Close(); badLn.Close() })
|
||||
|
||||
// Good upstream: returns 200
|
||||
goodSrv := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}),
|
||||
}
|
||||
goodLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
go goodSrv.Serve(goodLn)
|
||||
t.Cleanup(func() { goodSrv.Close(); goodLn.Close() })
|
||||
|
||||
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 {
|
||||
reverse_proxy %s %s {
|
||||
lb_policy round_robin
|
||||
lb_retries 1
|
||||
lb_retry_match {
|
||||
expression `+"`{rp.status_code} in [502, 503]`"+`
|
||||
}
|
||||
}
|
||||
}
|
||||
`, goodLn.Addr().String(), badLn.Addr().String()), "caddyfile")
|
||||
|
||||
tester.AssertGetResponse("http://localhost:9080/", 200, "ok")
|
||||
}
|
||||
|
||||
// TestReverseProxyRetryMatchHeader verifies that lb_retry_match with a CEL
|
||||
// expression matching on {rp.header.*} causes the request to be retried when
|
||||
// the upstream sets a matching response header
|
||||
func TestReverseProxyRetryMatchHeader(t *testing.T) {
|
||||
var badHits atomic.Int32
|
||||
|
||||
// Bad upstream: returns 200 but signals retry via header
|
||||
badSrv := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
badHits.Add(1)
|
||||
w.Header().Set("X-Upstream-Retry", "true")
|
||||
w.Write([]byte("bad"))
|
||||
}),
|
||||
}
|
||||
badLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
go badSrv.Serve(badLn)
|
||||
t.Cleanup(func() { badSrv.Close(); badLn.Close() })
|
||||
|
||||
// Good upstream: returns 200 without retry header
|
||||
goodSrv := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("good"))
|
||||
}),
|
||||
}
|
||||
goodLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
go goodSrv.Serve(goodLn)
|
||||
t.Cleanup(func() { goodSrv.Close(); goodLn.Close() })
|
||||
|
||||
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 {
|
||||
reverse_proxy %s %s {
|
||||
lb_policy round_robin
|
||||
lb_retries 1
|
||||
lb_retry_match {
|
||||
expression `+"`{rp.header.X-Upstream-Retry} == \"true\"`"+`
|
||||
}
|
||||
}
|
||||
}
|
||||
`, goodLn.Addr().String(), badLn.Addr().String()), "caddyfile")
|
||||
|
||||
tester.AssertGetResponse("http://localhost:9080/", 200, "good")
|
||||
|
||||
if badHits.Load() != 1 {
|
||||
t.Errorf("bad upstream hits: got %d, want 1", badHits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReverseProxyRetryMatchCombined verifies that a CEL expression combining
|
||||
// request path matching with response status code matching works correctly -
|
||||
// only retrying when both conditions are met
|
||||
func TestReverseProxyRetryMatchCombined(t *testing.T) {
|
||||
// Upstream: returns 502 for all requests
|
||||
srv := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
}),
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
go srv.Serve(ln)
|
||||
t.Cleanup(func() { srv.Close(); ln.Close() })
|
||||
|
||||
// Good upstream
|
||||
goodSrv := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}),
|
||||
}
|
||||
goodLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
go goodSrv.Serve(goodLn)
|
||||
t.Cleanup(func() { goodSrv.Close(); goodLn.Close() })
|
||||
|
||||
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 {
|
||||
reverse_proxy %s %s {
|
||||
lb_policy round_robin
|
||||
lb_retries 1
|
||||
lb_retry_match {
|
||||
expression `+"`path('/retry*') && {rp.status_code} in [502, 503]`"+`
|
||||
}
|
||||
}
|
||||
}
|
||||
`, goodLn.Addr().String(), ln.Addr().String()), "caddyfile")
|
||||
|
||||
// /retry path matches the expression - should retry to good upstream
|
||||
tester.AssertGetResponse("http://localhost:9080/retry", 200, "ok")
|
||||
|
||||
// /other path does NOT match - should return the 502
|
||||
req, _ := http.NewRequest(http.MethodGet, "http://localhost:9080/other", nil)
|
||||
tester.AssertResponse(req, 502, "")
|
||||
}
|
||||
|
||||
// TestReverseProxyRetryMatchIsTransportError verifies that the
|
||||
// {rp.is_transport_error} == true CEL function correctly identifies transport errors
|
||||
// and allows retrying them alongside response-based matching
|
||||
func TestReverseProxyRetryMatchIsTransportError(t *testing.T) {
|
||||
// Good upstream: returns 200
|
||||
goodSrv := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("ok"))
|
||||
}),
|
||||
}
|
||||
goodLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
go goodSrv.Serve(goodLn)
|
||||
t.Cleanup(func() { goodSrv.Close(); goodLn.Close() })
|
||||
|
||||
// Broken upstream: accepts connections but closes immediately
|
||||
brokenLn, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { brokenLn.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, err := brokenLn.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
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 {
|
||||
reverse_proxy %s %s {
|
||||
lb_policy round_robin
|
||||
lb_retries 1
|
||||
lb_retry_match {
|
||||
expression `+"`{rp.is_transport_error} || {rp.status_code} in [502, 503]`"+`
|
||||
}
|
||||
}
|
||||
}
|
||||
`, goodLn.Addr().String(), brokenLn.Addr().String()), "caddyfile")
|
||||
|
||||
// Transport error on broken upstream should be retried to good upstream
|
||||
tester.AssertGetResponse("http://localhost:9080/", 200, "ok")
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
# Used by import_block_snippet_invalid_subdirective.caddyfiletest
|
||||
|
||||
(test) {
|
||||
reverse_proxy {
|
||||
{block}
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "time/tzdata"
|
||||
|
||||
caddycmd "github.com/caddyserver/caddy/v2/cmd"
|
||||
|
||||
// plug in Caddy modules here
|
||||
|
||||
+4
-14
@@ -9,14 +9,9 @@ import (
|
||||
)
|
||||
|
||||
var defaultFactory = newRootCommandFactory(func() *cobra.Command {
|
||||
bin := caddy.CustomBinaryName
|
||||
if bin == "" {
|
||||
bin = "caddy"
|
||||
}
|
||||
|
||||
long := caddy.CustomLongDescription
|
||||
if long == "" {
|
||||
long = `Caddy is an extensible server platform written in Go.
|
||||
return &cobra.Command{
|
||||
Use: "caddy",
|
||||
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
|
||||
@@ -96,12 +91,7 @@ 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user