Compare commits

..

5 Commits

Author SHA1 Message Date
Mohammed Al Sahaf 18b346f6f9 r/RegisterType/RegisterNamespace/g
Co-Author: Matt Holt
2023-12-14 23:50:24 +03:00
Mohammed Al Sahaf 52441e3037 follow the linter's commands 2023-12-14 23:38:08 +03:00
Mohammed Al Sahaf b825a10927 own the usage of reflection into the RegisterType
allowing the users to only pass instances of the interfaces
2023-12-14 18:14:18 +03:00
Mohammed Al Sahaf 52f43d2f4c remove invalid test 2023-12-14 18:02:38 +03:00
Mohammed Al Sahaf 5e24e84288 core: add type registry
Facilitates validation of type adherence to namespace requirements
2023-12-14 18:02:15 +03:00
468 changed files with 8777 additions and 34709 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[*] [*]
end_of_line = lf end_of_line = lf
[caddytest/integration/caddyfile_adapt/*.caddyfiletest] [caddytest/integration/caddyfile_adapt/*.txt]
indent_style = tab indent_style = tab
+1 -1
View File
@@ -25,7 +25,7 @@ Other menu items:
You can have a huge impact on the project by helping with its code. To contribute code to Caddy, first submit or comment in an issue to discuss your contribution, then open a [pull request](https://github.com/caddyserver/caddy/pulls) (PR). If you're new to our community, that's okay: **we gladly welcome pull requests from anyone, regardless of your native language or coding experience.** You can get familiar with Caddy's code base by using [code search at Sourcegraph](https://sourcegraph.com/github.com/caddyserver/caddy). You can have a huge impact on the project by helping with its code. To contribute code to Caddy, first submit or comment in an issue to discuss your contribution, then open a [pull request](https://github.com/caddyserver/caddy/pulls) (PR). If you're new to our community, that's okay: **we gladly welcome pull requests from anyone, regardless of your native language or coding experience.** You can get familiar with Caddy's code base by using [code search at Sourcegraph](https://sourcegraph.com/github.com/caddyserver/caddy).
We hold contributions to a high standard for quality :bowtie:, so don't be surprised if we ask for revisions—even if it seems small or insignificant. Please don't take it personally. :blue_heart: If your change is on the right track, we can guide you to make it mergeable. We hold contributions to a high standard for quality :bowtie:, so don't be surprised if we ask for revisions—even if it seems small or insignificant. Please don't take it personally. :blue_heart: If your change is on the right track, we can guide you to make it mergable.
Here are some of the expectations we have of contributors: Here are some of the expectations we have of contributors:
-31
View File
@@ -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"'
-5
View File
@@ -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 -7
View File
@@ -5,11 +5,11 @@ The Caddy project would like to make sure that it stays on top of all practicall
## Supported Versions ## Supported Versions
| Version | Supported | | Version | Supported |
| -------- | ----------| | ------- | ------------------ |
| 2.latest | ✔️ | | 2.x | ✔️ |
| 1.x | :x: | | 1.x | :x: |
| < 1.x | :x: | | < 1.x | :x: |
## Acceptable Scope ## Acceptable Scope
@@ -48,9 +48,9 @@ We consider publicly-registered domain names to be public information. This nece
It will speed things up if you suggest a working patch, such as a code diff, and explain why and how it works. Reports that are not actionable, do not contain enough information, are too pushy/demanding, or are not able to convince us that it is a viable and practical attack on the web server itself may be deferred to a later time or possibly ignored, depending on available resources. Priority will be given to credible, responsible reports that are constructive, specific, and actionable. (We get a lot of invalid reports.) Thank you for understanding. It will speed things up if you suggest a working patch, such as a code diff, and explain why and how it works. Reports that are not actionable, do not contain enough information, are too pushy/demanding, or are not able to convince us that it is a viable and practical attack on the web server itself may be deferred to a later time or possibly ignored, depending on available resources. Priority will be given to credible, responsible reports that are constructive, specific, and actionable. (We get a lot of invalid reports.) Thank you for understanding.
When you are ready, please submit a [new private vulnerability report](https://github.com/caddyserver/caddy/security/advisories/new). When you are ready, please email Matt Holt (the author) directly: matt at dyanim dot com.
Please don't encrypt the message. It only makes the process more complicated. Please don't encrypt the email body. It only makes the process more complicated.
Please also understand that due to our nature as an open source project, we do not have a budget to award security bounties. We can only thank you. Please also understand that due to our nature as an open source project, we do not have a budget to award security bounties. We can only thank you.
-15
View File
@@ -3,20 +3,5 @@ version: 2
updates: updates:
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
open-pull-requests-limit: 1
groups:
actions-deps:
patterns:
- "*"
schedule:
interval: "monthly"
- package-ecosystem: "gomod"
directory: "/"
open-pull-requests-limit: 1
groups:
all-updates:
patterns:
- "*"
schedule: schedule:
interval: "monthly" interval: "monthly"
-29
View File
@@ -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._
-30
View File
@@ -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@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- uses: github/ai-moderator@6bcdb2a79c2e564db8d76d7d4439d91a044c4eb6
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
-221
View File
@@ -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');
+29 -106
View File
@@ -12,68 +12,52 @@ on:
- master - master
- 2.* - 2.*
env:
GOFLAGS: '-tags=nobadger,nomysql,nopgx'
# https://github.com/actions/setup-go/issues/491
GOTOOLCHAIN: local
permissions:
contents: read
jobs: jobs:
test: test:
strategy: strategy:
# Default is true, cancels jobs for other platforms in the matrix if one fails # Default is true, cancels jobs for other platforms in the matrix if one fails
fail-fast: false fail-fast: false
matrix: matrix:
os: os:
- linux - ubuntu-latest
- mac - macos-latest
- windows - windows-latest
go: go:
- '1.25' - '1.20'
- '1.21'
include: include:
# Set the minimum Go patch version for the given Go minor # Set the minimum Go patch version for the given Go minor
# Usable via ${{ matrix.GO_SEMVER }} # Usable via ${{ matrix.GO_SEMVER }}
- go: '1.25' - go: '1.20'
GO_SEMVER: '~1.25.0' GO_SEMVER: '~1.20.6'
- go: '1.21'
GO_SEMVER: '~1.21.0'
# Set some variables per OS, usable via ${{ matrix.VAR }} # 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)
# CADDY_BIN_PATH: the path to the compiled Caddy binary, for artifact publishing # CADDY_BIN_PATH: the path to the compiled Caddy binary, for artifact publishing
# SUCCESS: the typical value for $? per OS (Windows/pwsh returns 'True') # SUCCESS: the typical value for $? per OS (Windows/pwsh returns 'True')
- os: linux - os: ubuntu-latest
OS_LABEL: ubuntu-latest
CADDY_BIN_PATH: ./cmd/caddy/caddy CADDY_BIN_PATH: ./cmd/caddy/caddy
SUCCESS: 0 SUCCESS: 0
- os: mac - os: macos-latest
OS_LABEL: macos-14
CADDY_BIN_PATH: ./cmd/caddy/caddy CADDY_BIN_PATH: ./cmd/caddy/caddy
SUCCESS: 0 SUCCESS: 0
- os: windows - os: windows-latest
OS_LABEL: windows-latest
CADDY_BIN_PATH: ./cmd/caddy/caddy.exe CADDY_BIN_PATH: ./cmd/caddy/caddy.exe
SUCCESS: 'True' SUCCESS: 'True'
runs-on: ${{ matrix.OS_LABEL }} runs-on: ${{ matrix.os }}
permissions:
contents: read
pull-requests: read
actions: write # to allow uploading artifacts and cache
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4
- name: Install Go - name: Install Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 uses: actions/setup-go@v4
with: with:
go-version: ${{ matrix.GO_SEMVER }} go-version: ${{ matrix.GO_SEMVER }}
check-latest: true check-latest: true
@@ -113,18 +97,11 @@ jobs:
run: | run: |
go build -trimpath -ldflags="-w -s" -v go build -trimpath -ldflags="-w -s" -v
- name: Smoke test Caddy
working-directory: ./cmd/caddy
run: |
./caddy start
./caddy stop
- name: Publish Build Artifact - name: Publish Build Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@v3
with: with:
name: caddy_${{ runner.os }}_go${{ matrix.go }}_${{ steps.vars.outputs.short_sha }} name: caddy_${{ runner.os }}_go${{ matrix.go }}_${{ steps.vars.outputs.short_sha }}
path: ${{ matrix.CADDY_BIN_PATH }} path: ${{ matrix.CADDY_BIN_PATH }}
compression-level: 0
# Commented bits below were useful to allow the job to continue # Commented bits below were useful to allow the job to continue
# even if the tests fail, so we can publish the report separately # even if the tests fail, so we can publish the report separately
@@ -147,65 +124,33 @@ jobs:
# To return the correct result even though we set 'continue-on-error: true' # To return the correct result even though we set 'continue-on-error: true'
# - name: Coerce correct build result # - name: Coerce correct build result
# if: matrix.os != 'windows' && steps.step_test.outputs.status != ${{ matrix.SUCCESS }} # if: matrix.os != 'windows-latest' && steps.step_test.outputs.status != ${{ matrix.SUCCESS }}
# run: | # run: |
# echo "step_test ${{ steps.step_test.outputs.status }}\n" # echo "step_test ${{ steps.step_test.outputs.status }}\n"
# exit 1 # exit 1
s390x-test: s390x-test:
name: test (s390x on IBM Z) name: test (s390x on IBM Z)
permissions:
contents: read
pull-requests: read
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event.pull_request.head.repo.full_name == 'caddyserver/caddy' && github.actor != 'dependabot[bot]' if: github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]'
continue-on-error: true # August 2020: s390x VM is down due to weather and power issues continue-on-error: true # August 2020: s390x VM is down due to weather and power issues
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
allowed-endpoints: ci-s390x.caddyserver.com:22
- name: Checkout code - name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4
- name: Run Tests - name: Run Tests
run: | run: |
set +e
mkdir -p ~/.ssh && echo -e "${SSH_KEY//_/\\n}" > ~/.ssh/id_ecdsa && chmod og-rwx ~/.ssh/id_ecdsa mkdir -p ~/.ssh && echo -e "${SSH_KEY//_/\\n}" > ~/.ssh/id_ecdsa && chmod og-rwx ~/.ssh/id_ecdsa
# short sha is enough? # short sha is enough?
short_sha=$(git rev-parse --short HEAD) short_sha=$(git rev-parse --short HEAD)
# To shorten the following lines
ssh_opts="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
ssh_host="$CI_USER@ci-s390x.caddyserver.com"
# The environment is fresh, so there's no point in keeping accepting and adding the key. # The environment is fresh, so there's no point in keeping accepting and adding the key.
rsync -arz -e "ssh $ssh_opts" --progress --delete --exclude '.git' . "$ssh_host":/var/tmp/"$short_sha" rsync -arz -e "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" --progress --delete --exclude '.git' . "$CI_USER"@ci-s390x.caddyserver.com:/var/tmp/"$short_sha"
ssh $ssh_opts -t "$ssh_host" bash <<EOF ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -t "$CI_USER"@ci-s390x.caddyserver.com "cd /var/tmp/$short_sha; go version; go env; printf "\n\n";CGO_ENABLED=0 go test -v ./..."
cd /var/tmp/$short_sha
go version
go env
printf "\n\n"
retries=3
exit_code=0
while ((retries > 0)); do
CGO_ENABLED=0 go test -p 1 -v ./...
exit_code=$?
if ((exit_code == 0)); then
break
fi
echo "\n\nTest failed: \$exit_code, retrying..."
((retries--))
done
echo "Remote exit code: \$exit_code"
exit \$exit_code
EOF
test_result=$? test_result=$?
# There's no need leaving the files around # There's no need leaving the files around
ssh $ssh_opts "$ssh_host" "rm -rf /var/tmp/'$short_sha'" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "$CI_USER"@ci-s390x.caddyserver.com "rm -rf /var/tmp/'$short_sha'"
echo "Test exit code: $test_result" echo "Test exit code: $test_result"
exit $test_result exit $test_result
@@ -215,35 +160,13 @@ jobs:
goreleaser-check: goreleaser-check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
if: github.event.pull_request.head.repo.full_name == 'caddyserver/caddy' && github.actor != 'dependabot[bot]'
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout code - name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4
- uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 - uses: goreleaser/goreleaser-action@v5
with: with:
version: latest version: latest
args: check args: check
- name: Install Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with:
go-version: "~1.25"
check-latest: true
- name: Install xcaddy
run: |
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
xcaddy version
- uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
with:
version: latest
args: build --single-target --snapshot
env: env:
TAG: ${{ github.head_ref || github.ref_name }} TAG: ${{ steps.vars.outputs.version_tag }}
+17 -26
View File
@@ -10,56 +10,41 @@ on:
- master - master
- 2.* - 2.*
env:
GOFLAGS: '-tags=nobadger,nomysql,nopgx'
CGO_ENABLED: '0'
# https://github.com/actions/setup-go/issues/491
GOTOOLCHAIN: local
permissions:
contents: read
jobs: jobs:
build: cross-build-test:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
goos: goos:
- 'aix' - 'aix'
- 'android'
- 'linux' - 'linux'
- 'solaris' - 'solaris'
- 'illumos' - 'illumos'
- 'dragonfly' - 'dragonfly'
- 'freebsd' - 'freebsd'
- 'openbsd' - 'openbsd'
- 'plan9'
- 'windows' - 'windows'
- 'darwin' - 'darwin'
- 'netbsd' - 'netbsd'
go: go:
- '1.25' - '1.21'
include: include:
# Set the minimum Go patch version for the given Go minor # Set the minimum Go patch version for the given Go minor
# Usable via ${{ matrix.GO_SEMVER }} # Usable via ${{ matrix.GO_SEMVER }}
- go: '1.25' - go: '1.21'
GO_SEMVER: '~1.25.0' GO_SEMVER: '~1.21.0'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
continue-on-error: true continue-on-error: true
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout code - name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4
- name: Install Go - name: Install Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 uses: actions/setup-go@v4
with: with:
go-version: ${{ matrix.GO_SEMVER }} go-version: ${{ matrix.GO_SEMVER }}
check-latest: true check-latest: true
@@ -76,9 +61,15 @@ jobs:
- name: Run Build - name: Run Build
env: env:
CGO_ENABLED: 0
GOOS: ${{ matrix.goos }} GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goos == 'aix' && 'ppc64' || 'amd64' }} GOARCH: ${{ matrix.goos == 'aix' && 'ppc64' || 'amd64' }}
shell: bash shell: bash
continue-on-error: true continue-on-error: true
working-directory: ./cmd/caddy working-directory: ./cmd/caddy
run: go build -trimpath -o caddy-"$GOOS"-$GOARCH 2> /dev/null run: |
GOOS=$GOOS GOARCH=$GOARCH go build -trimpath -o caddy-"$GOOS"-$GOARCH 2> /dev/null
if [ $? -ne 0 ]; then
echo "::warning ::$GOOS Build Failed"
exit 0
fi
+17 -61
View File
@@ -13,10 +13,6 @@ on:
permissions: permissions:
contents: read contents: read
env:
# https://github.com/actions/setup-go/issues/491
GOTOOLCHAIN: local
jobs: jobs:
# From https://github.com/golangci/golangci-lint-action # From https://github.com/golangci/golangci-lint-action
golangci: golangci:
@@ -27,38 +23,27 @@ jobs:
strategy: strategy:
matrix: matrix:
os: os:
- linux - ubuntu-latest
- mac - macos-latest
- windows - windows-latest
runs-on: ${{ matrix.os }}
include:
- os: linux
OS_LABEL: ubuntu-latest
- os: mac
OS_LABEL: macos-14
- os: windows
OS_LABEL: windows-latest
runs-on: ${{ matrix.OS_LABEL }}
steps: steps:
- name: Harden the runner (Audit all outbound calls) - uses: actions/checkout@v4
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 - uses: actions/setup-go@v4
with: with:
egress-policy: audit go-version: '~1.21.0'
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with:
go-version: '~1.25'
check-latest: true check-latest: true
# Workaround for https://github.com/golangci/golangci-lint-action/issues/135
skip-pkg-cache: true
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 uses: golangci/golangci-lint-action@v3
with: with:
version: latest version: v1.54
# Workaround for https://github.com/golangci/golangci-lint-action/issues/135
skip-pkg-cache: true
# Windows times out frequently after about 5m50s if we don't set a longer timeout. # Windows times out frequently after about 5m50s if we don't set a longer timeout.
args: --timeout 10m args: --timeout 10m
@@ -67,39 +52,10 @@ jobs:
# only-new-issues: true # only-new-issues: true
govulncheck: govulncheck:
permissions:
contents: read
pull-requests: read
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: govulncheck - name: govulncheck
uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 uses: golang/govulncheck-action@v1
with: with:
go-version-input: '~1.25.0' go-version-input: '~1.21.0'
check-latest: true check-latest: true
dependency-review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: 'Checkout Repository'
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: 'Dependency Review'
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
with:
comment-summary-in-pr: on-failure
# https://github.com/actions/dependency-review-action/issues/430#issuecomment-1468975566
base-ref: ${{ github.event.pull_request.base.sha || 'master' }}
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
-249
View File
@@ -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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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
+19 -410
View File
@@ -5,342 +5,21 @@ on:
tags: tags:
- 'v*.*.*' - 'v*.*.*'
env:
# https://github.com/actions/setup-go/issues/491
GOTOOLCHAIN: local
permissions:
contents: read
jobs: 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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: release:
name: Release name: Release
needs: verify-tag
if: ${{ needs.verify-tag.outputs.verification_passed == 'true' }}
strategy: strategy:
matrix: matrix:
os: os:
- ubuntu-latest - ubuntu-latest
go: go:
- '1.25' - '1.21'
include: include:
# Set the minimum Go patch version for the given Go minor # Set the minimum Go patch version for the given Go minor
# Usable via ${{ matrix.GO_SEMVER }} # Usable via ${{ matrix.GO_SEMVER }}
- go: '1.25' - go: '1.21'
GO_SEMVER: '~1.25.0' GO_SEMVER: '~1.21.0'
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
# https://github.com/sigstore/cosign/issues/1258#issuecomment-1002251233 # https://github.com/sigstore/cosign/issues/1258#issuecomment-1002251233
@@ -350,28 +29,21 @@ jobs:
# https://docs.github.com/en/rest/overview/permissions-required-for-github-apps#permission-on-contents # 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` # "Releases" is part of `contents`, so it needs the `write`
contents: write contents: write
issues: write
pull-requests: write
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout code - name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install Go - name: Install Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 uses: actions/setup-go@v4
with: with:
go-version: ${{ matrix.GO_SEMVER }} go-version: ${{ matrix.GO_SEMVER }}
check-latest: true check-latest: true
# Force fetch upstream tags -- because 65 minutes # Force fetch upstream tags -- because 65 minutes
# tl;dr: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.2.2 runs this line: # tl;dr: actions/checkout@v4 runs this line:
# git -c protocol.version=2 fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +ebc278ec98bb24f2852b61fde2a9bf2e3d83818b:refs/tags/ # 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: # which makes its own local lightweight tag, losing all the annotations in the process. Our earlier script ran:
# git fetch --prune --unshallow # git fetch --prune --unshallow
@@ -414,21 +86,27 @@ jobs:
- name: Install Cloudsmith CLI - name: Install Cloudsmith CLI
run: pip install --upgrade 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 - name: Install Cosign
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # main uses: sigstore/cosign-installer@main
- name: Cosign version - name: Cosign version
run: cosign version run: cosign version
- name: Install Syft - name: Install Syft
uses: anchore/sbom-action/download-syft@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # main uses: anchore/sbom-action/download-syft@main
- name: Syft version - name: Syft version
run: syft version run: syft version
- name: Install xcaddy
run: |
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
xcaddy version
# GoReleaser will take care of publishing those artifacts into the release # GoReleaser will take care of publishing those artifacts into the release
- name: Run GoReleaser - name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 uses: goreleaser/goreleaser-action@v5
with: with:
version: latest version: latest
args: release --clean --timeout 60m args: release --clean --timeout 60m
@@ -494,72 +172,3 @@ jobs:
echo "Pushing $filename to 'testing'" echo "Pushing $filename to 'testing'"
cloudsmith push deb caddy/testing/any-distro/any-version $filename cloudsmith push deb caddy/testing/any-distro/any-version $filename
done 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}`);
}
}
+3 -14
View File
@@ -5,9 +5,6 @@ on:
release: release:
types: [published] types: [published]
permissions:
contents: read
jobs: jobs:
release: release:
name: Release Published name: Release Published
@@ -16,20 +13,12 @@ jobs:
os: os:
- ubuntu-latest - ubuntu-latest
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
permissions:
contents: read
pull-requests: read
actions: write
steps: steps:
# See https://github.com/peter-evans/repository-dispatch # See https://github.com/peter-evans/repository-dispatch
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Trigger event on caddyserver/dist - name: Trigger event on caddyserver/dist
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 uses: peter-evans/repository-dispatch@v2
with: with:
token: ${{ secrets.REPO_DISPATCH_TOKEN }} token: ${{ secrets.REPO_DISPATCH_TOKEN }}
repository: caddyserver/dist repository: caddyserver/dist
@@ -37,7 +26,7 @@ jobs:
client-payload: '{"tag": "${{ github.event.release.tag_name }}"}' client-payload: '{"tag": "${{ github.event.release.tag_name }}"}'
- name: Trigger event on caddyserver/caddy-docker - name: Trigger event on caddyserver/caddy-docker
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0 uses: peter-evans/repository-dispatch@v2
with: with:
token: ${{ secrets.REPO_DISPATCH_TOKEN }} token: ${{ secrets.REPO_DISPATCH_TOKEN }}
repository: caddyserver/caddy-docker repository: caddyserver/caddy-docker
-86
View File
@@ -1,86 +0,0 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: OpenSSF Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: '20 2 * * 5'
push:
branches: [ "master", "2.*" ]
pull_request:
branches: [ "master", "2.*" ]
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
# `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
# Uncomment the permissions below if installing in a private repository.
# contents: read
# actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecard on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true
# (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore
# file_mode: git
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: SARIF file
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.29.5
with:
sarif_file: results.sarif
-1
View File
@@ -3,7 +3,6 @@ _gitignore/
Caddyfile Caddyfile
Caddyfile.* Caddyfile.*
!caddyfile/ !caddyfile/
!caddyfile.go
# artifacts from pprof tooling # artifacts from pprof tooling
*.prof *.prof
+100 -110
View File
@@ -1,122 +1,112 @@
version: "2" linters-settings:
run: errcheck:
issues-exit-code: 1 ignore: fmt:.*,go.uber.org/zap/zapcore:^Add.*
tests: false ignoretests: true
build-tags: gci:
- nobadger sections:
- nomysql - standard # Standard section: captures all standard packages.
- nopgx - default # Default section: contains all imports that could not be matched to another section type.
output: - prefix(github.com/caddyserver/caddy/v2/cmd) # ensure that this is always at the top and always has a line break.
formats: - prefix(github.com/caddyserver/caddy) # Custom section: groups all imports with the specified Prefix.
text: # Skip generated files.
path: stdout # Default: true
print-linter-name: true skip-generated: true
print-issued-lines: true # Enable custom order of sections.
# If `true`, make the section order the same as the order of `sections`.
# Default: false
custom-order: true
linters: linters:
default: none disable-all: true
enable: enable:
- asasalint
- asciicheck
- bidichk
- bodyclose - bodyclose
- decorder
- dogsled
- dupl
- dupword
- durationcheck
- errcheck - errcheck
- errname - gci
- exhaustive - gofumpt
- gosec - gosec
- gosimple
- govet - govet
- importas
- ineffassign - ineffassign
- misspell - misspell
- prealloc - prealloc
- promlinter
- sloglint
- sqlclosecheck
- staticcheck - staticcheck
- testableexamples - typecheck
- testifylint
- tparallel
- unconvert - unconvert
- unused - unused
- wastedassign # these are implicitly disabled:
- whitespace # - asciicheck
- zerologlint # - depguard
settings: # - dogsled
staticcheck: # - dupl
checks: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022", "-QF1006", "-QF1008"] # default, and exclude 1 more undesired check # - exhaustive
errcheck: # - exportloopref
exclude-functions: # - funlen
- fmt.* # - gci
- (go.uber.org/zap/zapcore.ObjectEncoder).AddObject # - gochecknoglobals
- (go.uber.org/zap/zapcore.ObjectEncoder).AddArray # - gochecknoinits
exhaustive: # - gocognit
ignore-enum-types: reflect.Kind|svc.Cmd # - goconst
exclusions: # - gocritic
generated: lax # - gocyclo
presets: # - godot
- comments # - godox
- common-false-positives # - goerr113
- legacy # - gofumpt
- std-error-handling # - goheader
rules: # - golint
- linters: # - gomnd
- gosec # - gomodguard
text: G115 # TODO: Either we should fix the issues or nuke the linter if it's bad # - goprintffuncname
- linters: # - interfacer
- gosec # - lll
text: G107 # we aren't calling unknown URL # - maligned
- linters: # - nakedret
- gosec # - nestif
text: G203 # as a web server that's expected to handle any template, this is totally in the hands of the user. # - nlreturn
- linters: # - noctx
- gosec # - nolintlint
text: G204 # we're shelling out to known commands, not relying on user-defined input. # - rowserrcheck
- linters: # - scopelint
- gosec # - sqlclosecheck
# the choice of weakrand is deliberate, hence the named import "weakrand" # - stylecheck
path: modules/caddyhttp/reverseproxy/selectionpolicies.go # - testpackage
text: G404 # - unparam
- linters: # - whitespace
- gosec # - wsl
path: modules/caddyhttp/reverseproxy/streaming.go
text: G404 run:
- linters: # default concurrency is a available CPU number.
- dupl # concurrency: 4 # explicitly omit this value to fully utilize available resources.
path: modules/logging/filters.go deadline: 5m
- linters: issues-exit-code: 1
- dupl tests: false
path: modules/caddyhttp/matchers.go
- linters: # output configuration options
- dupl output:
path: modules/caddyhttp/vars.go format: 'colored-line-number'
- linters: print-issued-lines: true
- errcheck print-linter-name: true
path: _test\.go
paths: issues:
- third_party$ exclude-rules:
- builtin$ # we aren't calling unknown URL
- examples$ - text: 'G107' # G107: Url provided to HTTP request as taint input
formatters: linters:
enable: - gosec
- gci # as a web server that's expected to handle any template, this is totally in the hands of the user.
- gofmt - text: 'G203' # G203: Use of unescaped data in HTML templates
- gofumpt linters:
- goimports - gosec
settings: # we're shelling out to known commands, not relying on user-defined input.
gci: - text: 'G204' # G204: Audit use of command execution
sections: linters:
- standard # Standard section: captures all standard packages. - gosec
- default # Default section: contains all imports that could not be matched to another section type. # the choice of weakrand is deliberate, hence the named import "weakrand"
- prefix(github.com/caddyserver/caddy/v2/cmd) # ensure that this is always at the top and always has a line break. - path: modules/caddyhttp/reverseproxy/selectionpolicies.go
- prefix(github.com/caddyserver/caddy) # Custom section: groups all imports with the specified Prefix. text: 'G404' # G404: Insecure random number source (rand)
custom-order: true linters:
exclusions: - gosec
generated: lax - path: modules/caddyhttp/reverseproxy/streaming.go
paths: text: 'G404' # G404: Insecure random number source (rand)
- third_party$ linters:
- builtin$ - gosec
- examples$
+2 -13
View File
@@ -1,5 +1,3 @@
version: 2
before: before:
hooks: hooks:
# The build is done in this particular way to build Caddy in a designated directory named in .gitignore. # The build is done in this particular way to build Caddy in a designated directory named in .gitignore.
@@ -12,9 +10,6 @@ before:
- mkdir -p caddy-build - mkdir -p caddy-build
- cp cmd/caddy/main.go caddy-build/main.go - cp cmd/caddy/main.go caddy-build/main.go
- /bin/sh -c 'cd ./caddy-build && go mod init caddy' - /bin/sh -c 'cd ./caddy-build && go mod init caddy'
# prepare syso files for windows embedding
- /bin/sh -c 'for a in amd64 arm arm64; do XCADDY_SKIP_BUILD=1 GOOS=windows GOARCH=$a xcaddy build {{.Env.TAG}}; done'
- /bin/sh -c '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 # 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 # so we run: TAG=$(git describe --abbrev=0) goreleaser release --rm-dist --skip-publish --skip-validate
- go mod edit -require=github.com/caddyserver/caddy/v2@{{.Env.TAG}} ./caddy-build/go.mod - go mod edit -require=github.com/caddyserver/caddy/v2@{{.Env.TAG}} ./caddy-build/go.mod
@@ -34,6 +29,7 @@ builds:
- env: - env:
- CGO_ENABLED=0 - CGO_ENABLED=0
- GO111MODULE=on - GO111MODULE=on
main: main.go
dir: ./caddy-build dir: ./caddy-build
binary: caddy binary: caddy
goos: goos:
@@ -81,10 +77,6 @@ builds:
- -mod=readonly - -mod=readonly
ldflags: ldflags:
- -s -w - -s -w
tags:
- nobadger
- nomysql
- nopgx
signs: signs:
- cmd: cosign - cmd: cosign
@@ -111,7 +103,7 @@ archives:
- id: default - id: default
format_overrides: format_overrides:
- goos: windows - goos: windows
formats: zip format: zip
name_template: >- name_template: >-
{{ .ProjectName }}_ {{ .ProjectName }}_
{{- .Version }}_ {{- .Version }}_
@@ -192,9 +184,6 @@ nfpms:
preremove: ./caddy-dist/scripts/preremove.sh preremove: ./caddy-dist/scripts/preremove.sh
postremove: ./caddy-dist/scripts/postremove.sh postremove: ./caddy-dist/scripts/postremove.sh
provides:
- httpd
release: release:
github: github:
owner: caddyserver owner: caddyserver
-20
View File
@@ -1,20 +0,0 @@
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.16.3
hooks:
- id: gitleaks
- repo: https://github.com/golangci/golangci-lint
rev: v1.52.2
hooks:
- id: golangci-lint-config-verify
- id: golangci-lint
- id: golangci-lint-fmt
- repo: https://github.com/jumanjihouse/pre-commit-hooks
rev: 3.0.0
hooks:
- id: shellcheck
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
+8 -10
View File
@@ -14,10 +14,9 @@
<p align="center">Caddy is an extensible server platform that uses TLS by default.</p> <p align="center">Caddy is an extensible server platform that uses TLS by default.</p>
<p align="center"> <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://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://pkg.go.dev/github.com/caddyserver/caddy/v2"><img src="https://img.shields.io/badge/godoc-reference-%23007d9c.svg"></a>
<br> <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://twitter.com/caddyserver" title="@caddyserver on Twitter"><img src="https://img.shields.io/badge/twitter-@caddyserver-55acee.svg" 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> <a href="https://caddy.community" title="Caddy Forum"><img src="https://img.shields.io/badge/community-forum-ff69b4.svg" alt="Caddy Forum"></a>
<br> <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://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>
@@ -57,7 +56,7 @@
</p> </p>
## [Features](https://caddyserver.com/features) ## [Features](https://caddyserver.com/v2)
- **Easy configuration** with the [Caddyfile](https://caddyserver.com/docs/caddyfile) - **Easy configuration** with the [Caddyfile](https://caddyserver.com/docs/caddyfile)
- **Powerful configuration** with its [native JSON config](https://caddyserver.com/docs/json/) - **Powerful configuration** with its [native JSON config](https://caddyserver.com/docs/json/)
@@ -68,7 +67,6 @@
- Fully-managed local CA for internal names & IPs - Fully-managed local CA for internal names & IPs
- Can coordinate with other Caddy instances in a cluster - Can coordinate with other Caddy instances in a cluster
- Multi-issuer fallback - Multi-issuer fallback
- Encrypted ClientHello (ECH) support
- **Stays up when other servers go down** due to TLS/OCSP/certificate-related issues - **Stays up when other servers go down** due to TLS/OCSP/certificate-related issues
- **Production-ready** after serving trillions of requests and managing millions of TLS certificates - **Production-ready** after serving trillions of requests and managing millions of TLS certificates
- **Scales to hundreds of thousands of sites** as proven in production - **Scales to hundreds of thousands of sites** as proven in production
@@ -77,7 +75,7 @@
- **Runs anywhere** with **no external dependencies** (not even libc) - **Runs anywhere** with **no external dependencies** (not even libc)
- Written in Go, a language with higher **memory safety guarantees** than other servers - Written in Go, a language with higher **memory safety guarantees** than other servers
- Actually **fun to use** - Actually **fun to use**
- So much more to [discover](https://caddyserver.com/features) - So much more to [discover](https://caddyserver.com/v2)
## Install ## Install
@@ -89,7 +87,7 @@ See [our online documentation](https://caddyserver.com/docs/install) for other i
Requirements: Requirements:
- [Go 1.25.0 or newer](https://golang.org/dl/) - [Go 1.20 or newer](https://golang.org/dl/)
### For development ### For development
@@ -133,7 +131,7 @@ $ xcaddy build
4. Initialize a Go module: `go mod init caddy` 4. Initialize a Go module: `go mod init caddy`
5. (Optional) Pin Caddy version: `go get github.com/caddyserver/caddy/v2@version` replacing `version` with a git tag, commit, or branch name. 5. (Optional) Pin Caddy version: `go get github.com/caddyserver/caddy/v2@version` replacing `version` with a git tag, commit, or branch name.
6. (Optional) Add plugins by adding their import: `_ "import/path/here"` 6. (Optional) Add plugins by adding their import: `_ "import/path/here"`
7. Compile: `go build -tags=nobadger,nomysql,nopgx` 7. Compile: `go build`
@@ -178,7 +176,7 @@ The docs are also open source. You can contribute to them here: https://github.c
## Getting help ## Getting help
- We advise companies using Caddy to secure a support contract through [Ardan Labs](https://www.ardanlabs.com) before help is needed. - We advise companies using Caddy to secure a support contract through [Ardan Labs](https://www.ardanlabs.com/my/contact-us?dd=caddy) before help is needed.
- A [sponsorship](https://github.com/sponsors/mholt) goes a long way! We can offer private help to sponsors. If Caddy is benefitting your company, please consider a sponsorship. This not only helps fund full-time work to ensure the longevity of the project, it provides your company the resources, support, and discounts you need; along with being a great look for your company to your customers and potential customers! - A [sponsorship](https://github.com/sponsors/mholt) goes a long way! We can offer private help to sponsors. If Caddy is benefitting your company, please consider a sponsorship. This not only helps fund full-time work to ensure the longevity of the project, it provides your company the resources, support, and discounts you need; along with being a great look for your company to your customers and potential customers!
@@ -194,8 +192,8 @@ Matthew Holt began developing Caddy in 2014 while studying computer science at B
**The name "Caddy" is trademarked.** The name of the software is "Caddy", not "Caddy Server" or "CaddyServer". Please call it "Caddy" or, if you wish to clarify, "the Caddy web server". Caddy is a registered trademark of Stack Holdings GmbH. **The name "Caddy" is trademarked.** The name of the software is "Caddy", not "Caddy Server" or "CaddyServer". Please call it "Caddy" or, if you wish to clarify, "the Caddy web server". Caddy is a registered trademark of Stack Holdings GmbH.
- _Project on X: [@caddyserver](https://x.com/caddyserver)_ - _Project on Twitter: [@caddyserver](https://twitter.com/caddyserver)_
- _Author on X: [@mholt6](https://x.com/mholt6)_ - _Author on Twitter: [@mholt6](https://twitter.com/mholt6)_
Caddy is a project of [ZeroSSL](https://zerossl.com), a Stack Holdings company. Caddy is a project of [ZeroSSL](https://zerossl.com), a Stack Holdings company.
+73 -96
View File
@@ -26,6 +26,7 @@ import (
"expvar" "expvar"
"fmt" "fmt"
"hash" "hash"
"hash/fnv"
"io" "io"
"net" "net"
"net/http" "net/http"
@@ -34,21 +35,19 @@ import (
"os" "os"
"path" "path"
"regexp" "regexp"
"slices"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/caddyserver/certmagic" "github.com/caddyserver/certmagic"
"github.com/cespare/xxhash/v2"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
) )
func init() { func init() {
// The hard-coded default `DefaultAdminListen` can be overridden // The hard-coded default `DefaultAdminListen` can be overidden
// by setting the `CADDY_ADMIN` environment variable. // by setting the `CADDY_ADMIN` environment variable.
// The environment variable may be used by packagers to change // The environment variable may be used by packagers to change
// the default admin address to something more appropriate for // the default admin address to something more appropriate for
@@ -56,6 +55,7 @@ func init() {
if env, exists := os.LookupEnv("CADDY_ADMIN"); exists { if env, exists := os.LookupEnv("CADDY_ADMIN"); exists {
DefaultAdminListen = env DefaultAdminListen = env
} }
RegisterNamespace("caddy.config_loaders", []interface{}{(*ConfigLoader)(nil)})
} }
// AdminConfig configures Caddy's API endpoint, which is used // AdminConfig configures Caddy's API endpoint, which is used
@@ -214,15 +214,14 @@ type AdminPermissions struct {
// newAdminHandler reads admin's config and returns an http.Handler suitable // newAdminHandler reads admin's config and returns an http.Handler suitable
// for use in an admin endpoint server, which will be listening on listenAddr. // for use in an admin endpoint server, which will be listening on listenAddr.
func (admin *AdminConfig) newAdminHandler(addr NetworkAddress, remote bool, _ Context) adminHandler { func (admin *AdminConfig) newAdminHandler(addr NetworkAddress, remote bool) adminHandler {
muxWrap := adminHandler{mux: http.NewServeMux()} muxWrap := adminHandler{mux: http.NewServeMux()}
// secure the local or remote endpoint respectively // secure the local or remote endpoint respectively
if remote { if remote {
muxWrap.remoteControl = admin.Remote muxWrap.remoteControl = admin.Remote
} else { } else {
// see comment in allowedOrigins() as to why we disable the host check for unix/fd networks muxWrap.enforceHost = !addr.isWildcardInterface()
muxWrap.enforceHost = !addr.isWildcardInterface() && !addr.IsUnixNetwork() && !addr.IsFdNetwork()
muxWrap.allowedOrigins = admin.allowedOrigins(addr) muxWrap.allowedOrigins = admin.allowedOrigins(addr)
muxWrap.enforceOrigin = admin.EnforceOrigin muxWrap.enforceOrigin = admin.EnforceOrigin
} }
@@ -271,6 +270,7 @@ func (admin *AdminConfig) newAdminHandler(addr NetworkAddress, remote bool, _ Co
// register third-party module endpoints // register third-party module endpoints
for _, m := range GetModules("admin.api") { for _, m := range GetModules("admin.api") {
router := m.New().(AdminRouter) router := m.New().(AdminRouter)
handlerLabel := m.ID.Name()
for _, route := range router.Routes() { for _, route := range router.Routes() {
addRoute(route.Pattern, handlerLabel, route.Handler) addRoute(route.Pattern, handlerLabel, route.Handler)
} }
@@ -311,43 +311,47 @@ func (admin AdminConfig) allowedOrigins(addr NetworkAddress) []*url.URL {
for _, o := range admin.Origins { for _, o := range admin.Origins {
uniqueOrigins[o] = struct{}{} uniqueOrigins[o] = struct{}{}
} }
// RFC 2616, Section 14.26: if admin.Origins == nil {
// "A client MUST include a Host header field in all HTTP/1.1 request
// messages. If the requested URI does not include an Internet host
// name for the service being requested, then the Host header field MUST
// be given with an empty value."
//
// UPDATE July 2023: Go broke this by patching a minor security bug in 1.20.6.
// Understandable, but frustrating. See:
// https://github.com/golang/go/issues/60374
// See also the discussion here:
// https://github.com/golang/go/issues/61431
//
// We can no longer conform to RFC 2616 Section 14.26 from either Go or curl
// in purity. (Curl allowed no host between 7.40 and 7.50, but now requires a
// bogus host; see https://superuser.com/a/925610.) If we disable Host/Origin
// security checks, the infosec community assures me that it is secure to do
// so, because:
//
// 1) Browsers do not allow access to unix sockets
// 2) DNS is irrelevant to unix sockets
//
// If either of those two statements ever fail to hold true, it is not the
// fault of Caddy.
//
// Thus, we do not fill out allowed origins and do not enforce Host
// requirements for unix sockets. Enforcing it leads to confusion and
// frustration, when UDS have their own permissions from the OS.
// Enforcing host requirements here is effectively security theater,
// and a false sense of security.
//
// See also the discussion in #6832.
if admin.Origins == nil && !addr.IsUnixNetwork() && !addr.IsFdNetwork() {
if addr.isLoopback() { if addr.isLoopback() {
uniqueOrigins[net.JoinHostPort("localhost", addr.port())] = struct{}{} if addr.IsUnixNetwork() {
uniqueOrigins[net.JoinHostPort("::1", addr.port())] = struct{}{} // RFC 2616, Section 14.26:
uniqueOrigins[net.JoinHostPort("127.0.0.1", addr.port())] = struct{}{} // "A client MUST include a Host header field in all HTTP/1.1 request
} else { // messages. If the requested URI does not include an Internet host
// name for the service being requested, then the Host header field MUST
// be given with an empty value."
//
// UPDATE July 2023: Go broke this by patching a minor security bug in 1.20.6.
// Understandable, but frustrating. See:
// https://github.com/golang/go/issues/60374
// See also the discussion here:
// https://github.com/golang/go/issues/61431
//
// We can no longer conform to RFC 2616 Section 14.26 from either Go or curl
// in purity. (Curl allowed no host between 7.40 and 7.50, but now requires a
// bogus host; see https://superuser.com/a/925610.) If we disable Host/Origin
// security checks, the infosec community assures me that it is secure to do
// so, because:
// 1) Browsers do not allow access to unix sockets
// 2) DNS is irrelevant to unix sockets
//
// I am not quite ready to trust either of those external factors, so instead
// of disabling Host/Origin checks, we now allow specific Host values when
// accessing the admin endpoint over unix sockets. I definitely don't trust
// DNS (e.g. I don't trust 'localhost' to always resolve to the local host),
// and IP shouldn't even be used, but if it is for some reason, I think we can
// at least be reasonably assured that 127.0.0.1 and ::1 route to the local
// machine, meaning that a hypothetical browser origin would have to be on the
// local machine as well.
uniqueOrigins[""] = struct{}{}
uniqueOrigins["127.0.0.1"] = struct{}{}
uniqueOrigins["::1"] = struct{}{}
} else {
uniqueOrigins[net.JoinHostPort("localhost", addr.port())] = struct{}{}
uniqueOrigins[net.JoinHostPort("::1", addr.port())] = struct{}{}
uniqueOrigins[net.JoinHostPort("127.0.0.1", addr.port())] = struct{}{}
}
}
if !addr.IsUnixNetwork() {
uniqueOrigins[addr.JoinHostPort(0)] = struct{}{} uniqueOrigins[addr.JoinHostPort(0)] = struct{}{}
} }
} }
@@ -378,9 +382,7 @@ func (admin AdminConfig) allowedOrigins(addr NetworkAddress) []*url.URL {
// for the admin endpoint exists in cfg, a default one is used, so // for the admin endpoint exists in cfg, a default one is used, so
// that there is always an admin server (unless it is explicitly // that there is always an admin server (unless it is explicitly
// configured to be disabled). // configured to be disabled).
// Critically note that some elements and functionality of the context func replaceLocalAdminServer(cfg *Config) error {
// may not be ready, e.g. storage. Tread carefully.
func replaceLocalAdminServer(cfg *Config, ctx Context) error {
// always* be sure to close down the old admin endpoint // always* be sure to close down the old admin endpoint
// as gracefully as possible, even if the new one is // as gracefully as possible, even if the new one is
// disabled -- careful to use reference to the current // disabled -- careful to use reference to the current
@@ -422,14 +424,7 @@ func replaceLocalAdminServer(cfg *Config, ctx Context) error {
return err return err
} }
handler := cfg.Admin.newAdminHandler(addr, false, ctx) handler := cfg.Admin.newAdminHandler(addr, false)
// 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
}
ln, err := addr.Listen(context.TODO(), 0, net.ListenConfig{}) ln, err := addr.Listen(context.TODO(), 0, net.ListenConfig{})
if err != nil { if err != nil {
@@ -480,6 +475,7 @@ func manageIdentity(ctx Context, cfg *Config) error {
// import the caddytls package -- but it works // import the caddytls package -- but it works
if cfg.Admin.Identity.IssuersRaw == nil { if cfg.Admin.Identity.IssuersRaw == nil {
cfg.Admin.Identity.IssuersRaw = []json.RawMessage{ cfg.Admin.Identity.IssuersRaw = []json.RawMessage{
json.RawMessage(`{"module": "zerossl"}`),
json.RawMessage(`{"module": "acme"}`), json.RawMessage(`{"module": "acme"}`),
} }
} }
@@ -550,14 +546,7 @@ func replaceRemoteAdminServer(ctx Context, cfg *Config) error {
// make the HTTP handler but disable Host/Origin enforcement // make the HTTP handler but disable Host/Origin enforcement
// because we are using TLS authentication instead // because we are using TLS authentication instead
handler := cfg.Admin.newAdminHandler(addr, true, ctx) handler := cfg.Admin.newAdminHandler(addr, true)
// 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
}
// create client certificate pool for TLS mutual auth, and extract public keys // create client certificate pool for TLS mutual auth, and extract public keys
// so that we can enforce access controls at the application layer // so that we can enforce access controls at the application layer
@@ -688,7 +677,13 @@ func (remote RemoteAdmin) enforceAccessControls(r *http.Request) error {
// key recognized; make sure its HTTP request is permitted // key recognized; make sure its HTTP request is permitted
for _, accessPerm := range adminAccess.Permissions { for _, accessPerm := range adminAccess.Permissions {
// verify method // verify method
methodFound := accessPerm.Methods == nil || slices.Contains(accessPerm.Methods, r.Method) methodFound := accessPerm.Methods == nil
for _, method := range accessPerm.Methods {
if method == r.Method {
methodFound = true
break
}
}
if !methodFound { if !methodFound {
return APIError{ return APIError{
HTTPStatus: http.StatusForbidden, HTTPStatus: http.StatusForbidden,
@@ -884,9 +879,13 @@ func (h adminHandler) handleError(w http.ResponseWriter, r *http.Request, err er
// a trustworthy/expected value. This helps to mitigate DNS // a trustworthy/expected value. This helps to mitigate DNS
// rebinding attacks. // rebinding attacks.
func (h adminHandler) checkHost(r *http.Request) error { func (h adminHandler) checkHost(r *http.Request) error {
allowed := slices.ContainsFunc(h.allowedOrigins, func(u *url.URL) bool { var allowed bool
return r.Host == u.Host for _, allowedOrigin := range h.allowedOrigins {
}) if r.Host == allowedOrigin.Host {
allowed = true
break
}
}
if !allowed { if !allowed {
return APIError{ return APIError{
HTTPStatus: http.StatusForbidden, HTTPStatus: http.StatusForbidden,
@@ -946,9 +945,9 @@ func (h adminHandler) originAllowed(origin *url.URL) bool {
return false return false
} }
// etagHasher returns the hasher we used on the config to both // etagHasher returns a the hasher we used on the config to both
// produce and verify ETags. // produce and verify ETags.
func etagHasher() hash.Hash { return xxhash.New() } func etagHasher() hash.Hash32 { return fnv.New32a() }
// makeEtag returns an Etag header value (including quotes) for // makeEtag returns an Etag header value (including quotes) for
// the given config path and hash of contents at that path. // the given config path and hash of contents at that path.
@@ -956,28 +955,17 @@ func makeEtag(path string, hash hash.Hash) string {
return fmt.Sprintf(`"%s %x"`, path, hash.Sum(nil)) return fmt.Sprintf(`"%s %x"`, path, hash.Sum(nil))
} }
// This buffer pool is used to keep buffers for
// reading the config file during eTag header generation
var bufferPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func handleConfig(w http.ResponseWriter, r *http.Request) error { func handleConfig(w http.ResponseWriter, r *http.Request) error {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
// Set the ETag as a trailer header.
// The alternative is to write the config to a buffer, and
// then hash that.
w.Header().Set("Trailer", "ETag")
hash := etagHasher() hash := etagHasher()
configWriter := io.MultiWriter(w, hash)
// Read the config into a buffer instead of writing directly to
// the response writer, as we want to set the ETag as the header,
// not the trailer.
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufferPool.Put(buf)
configWriter := io.MultiWriter(buf, hash)
err := readConfig(r.URL.Path, configWriter) err := readConfig(r.URL.Path, configWriter)
if err != nil { if err != nil {
return APIError{HTTPStatus: http.StatusBadRequest, Err: err} return APIError{HTTPStatus: http.StatusBadRequest, Err: err}
@@ -986,10 +974,6 @@ func handleConfig(w http.ResponseWriter, r *http.Request) error {
// we could consider setting up a sync.Pool for the summed // we could consider setting up a sync.Pool for the summed
// hashes to reduce GC pressure. // hashes to reduce GC pressure.
w.Header().Set("Etag", makeEtag(r.URL.Path, hash)) w.Header().Set("Etag", makeEtag(r.URL.Path, hash))
_, err = w.Write(buf.Bytes())
if err != nil {
return APIError{HTTPStatus: http.StatusInternalServerError, Err: err}
}
return nil return nil
@@ -1029,13 +1013,6 @@ func handleConfig(w http.ResponseWriter, r *http.Request) error {
return err 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: default:
return APIError{ return APIError{
HTTPStatus: http.StatusMethodNotAllowed, HTTPStatus: http.StatusMethodNotAllowed,
@@ -1157,7 +1134,7 @@ traverseLoop:
return fmt.Errorf("[%s] invalid array index '%s': %v", return fmt.Errorf("[%s] invalid array index '%s': %v",
path, idxStr, err) path, idxStr, err)
} }
if idx < 0 || (method != http.MethodPut && idx >= len(arr)) || idx > len(arr) { if idx < 0 || idx >= len(arr) {
return fmt.Errorf("[%s] array index out of bounds: %s", path, idxStr) return fmt.Errorf("[%s] array index out of bounds: %s", path, idxStr)
} }
} }
+5 -727
View File
@@ -15,20 +15,12 @@
package caddy package caddy
import ( import (
"context"
"crypto/x509"
"encoding/json" "encoding/json"
"fmt" "fmt"
"maps"
"net/http" "net/http"
"net/http/httptest"
"reflect" "reflect"
"sync" "sync"
"testing" "testing"
"github.com/caddyserver/certmagic"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
) )
var testCfg = []byte(`{ var testCfg = []byte(`{
@@ -149,9 +141,11 @@ func TestLoadConcurrent(t *testing.T) {
var wg sync.WaitGroup var wg sync.WaitGroup
for i := 0; i < 100; i++ { for i := 0; i < 100; i++ {
wg.Go(func() { wg.Add(1)
go func() {
_ = Load(testCfg, true) _ = Load(testCfg, true)
}) wg.Done()
}()
} }
wg.Wait() wg.Wait()
} }
@@ -205,723 +199,7 @@ func TestETags(t *testing.T) {
} }
func BenchmarkLoad(b *testing.B) { func BenchmarkLoad(b *testing.B) {
for b.Loop() { for i := 0; i < b.N; i++ {
Load(testCfg, true) Load(testCfg, true)
} }
} }
func TestAdminHandlerErrorHandling(t *testing.T) {
initAdminMetrics()
handler := adminHandler{
mux: http.NewServeMux(),
}
handler.mux.Handle("/error", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
err := fmt.Errorf("test error")
handler.handleError(w, r, err)
}))
req := httptest.NewRequest(http.MethodGet, "/error", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code == http.StatusOK {
t.Error("expected error response, got success")
}
var apiErr APIError
if err := json.NewDecoder(rr.Body).Decode(&apiErr); err != nil {
t.Fatalf("decoding response: %v", err)
}
if apiErr.Message != "test error" {
t.Errorf("expected error message 'test error', got '%s'", apiErr.Message)
}
}
func initAdminMetrics() {
if adminMetrics.requestErrors != nil {
prometheus.Unregister(adminMetrics.requestErrors)
}
if adminMetrics.requestCount != nil {
prometheus.Unregister(adminMetrics.requestCount)
}
adminMetrics.requestErrors = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "caddy",
Subsystem: "admin_http",
Name: "request_errors_total",
Help: "Number of errors that occurred handling admin endpoint requests",
}, []string{"handler", "path", "method"})
adminMetrics.requestCount = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "caddy",
Subsystem: "admin_http",
Name: "requests_total",
Help: "Count of requests to the admin endpoint",
}, []string{"handler", "path", "code", "method"}) // Added code and method labels
prometheus.MustRegister(adminMetrics.requestErrors)
prometheus.MustRegister(adminMetrics.requestCount)
}
func TestAdminHandlerBuiltinRouteErrors(t *testing.T) {
initAdminMetrics()
cfg := &Config{
Admin: &AdminConfig{
Listen: "localhost:2019",
},
}
err := replaceLocalAdminServer(cfg, Context{})
if err != nil {
t.Fatalf("setting up admin server: %v", err)
}
defer func() {
stopAdminServer(localAdminServer)
}()
tests := []struct {
name string
path string
method string
expectedStatus int
}{
{
name: "stop endpoint wrong method",
path: "/stop",
method: http.MethodGet,
expectedStatus: http.StatusMethodNotAllowed,
},
{
name: "config endpoint wrong content-type",
path: "/config/",
method: http.MethodPost,
expectedStatus: http.StatusBadRequest,
},
{
name: "config ID missing ID",
path: "/id/",
method: http.MethodGet,
expectedStatus: http.StatusBadRequest,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req := httptest.NewRequest(test.method, fmt.Sprintf("http://localhost:2019%s", test.path), nil)
rr := httptest.NewRecorder()
localAdminServer.Handler.ServeHTTP(rr, req)
if rr.Code != test.expectedStatus {
t.Errorf("expected status %d but got %d", test.expectedStatus, rr.Code)
}
metricValue := testGetMetricValue(map[string]string{
"path": test.path,
"handler": "admin",
"method": test.method,
})
if metricValue != 1 {
t.Errorf("expected error metric to be incremented once, got %v", metricValue)
}
})
}
}
func testGetMetricValue(labels map[string]string) float64 {
promLabels := prometheus.Labels{}
maps.Copy(promLabels, labels)
metric, err := adminMetrics.requestErrors.GetMetricWith(promLabels)
if err != nil {
return 0
}
pb := &dto.Metric{}
metric.Write(pb)
return pb.GetCounter().GetValue()
}
type mockRouter struct {
routes []AdminRoute
}
func (m mockRouter) Routes() []AdminRoute {
return m.routes
}
type mockModule struct {
mockRouter
}
func (m *mockModule) CaddyModule() ModuleInfo {
return ModuleInfo{
ID: "admin.api.mock",
New: func() Module {
mm := &mockModule{
mockRouter: mockRouter{
routes: m.routes,
},
}
return mm
},
}
}
func TestNewAdminHandlerRouterRegistration(t *testing.T) {
originalModules := make(map[string]ModuleInfo)
maps.Copy(originalModules, modules)
defer func() {
modules = originalModules
}()
mockRoute := AdminRoute{
Pattern: "/mock",
Handler: AdminHandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
w.WriteHeader(http.StatusOK)
return nil
}),
}
mock := &mockModule{
mockRouter: mockRouter{
routes: []AdminRoute{mockRoute},
},
}
RegisterModule(mock)
addr, err := ParseNetworkAddress("localhost:2019")
if err != nil {
t.Fatalf("Failed to parse address: %v", err)
}
admin := &AdminConfig{
EnforceOrigin: false,
}
handler := admin.newAdminHandler(addr, false, Context{})
req := httptest.NewRequest("GET", "/mock", nil)
req.Host = "localhost:2019"
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
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 {
mockRouter
provisionErr error
provisioned bool
}
func (m *mockProvisionableRouter) Provision(Context) error {
m.provisioned = true
return m.provisionErr
}
type mockProvisionableModule struct {
*mockProvisionableRouter
}
func (m *mockProvisionableModule) CaddyModule() ModuleInfo {
return ModuleInfo{
ID: "admin.api.mock_provision",
New: func() Module {
mm := &mockProvisionableModule{
mockProvisionableRouter: &mockProvisionableRouter{
mockRouter: m.mockRouter,
provisionErr: m.provisionErr,
},
}
return mm
},
}
}
func TestAdminRouterProvisioning(t *testing.T) {
tests := []struct {
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,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
originalModules := make(map[string]ModuleInfo)
maps.Copy(originalModules, modules)
defer func() {
modules = originalModules
}()
mockRoute := AdminRoute{
Pattern: "/mock",
Handler: AdminHandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
return nil
}),
}
// Create provisionable module
mock := &mockProvisionableModule{
mockProvisionableRouter: &mockProvisionableRouter{
mockRouter: mockRouter{
routes: []AdminRoute{mockRoute},
},
provisionErr: test.provisionErr,
},
}
RegisterModule(mock)
admin := &AdminConfig{}
addr, err := ParseNetworkAddress("localhost:2019")
if err != nil {
t.Fatalf("Failed to parse address: %v", err)
}
_ = admin.newAdminHandler(addr, false, Context{})
err = admin.provisionAdminRouters(Context{})
if test.wantErr {
if err == nil {
t.Error("Expected error but got nil")
}
} else {
if err != nil {
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))
}
})
}
}
func TestAllowedOriginsUnixSocket(t *testing.T) {
// see comment in allowedOrigins() as to why we do not fill out allowed origins for UDS
tests := []struct {
name string
addr NetworkAddress
origins []string
expectOrigins []string
}{
{
name: "unix socket with default origins",
addr: NetworkAddress{
Network: "unix",
Host: "/tmp/caddy.sock",
},
origins: nil, // default origins
expectOrigins: []string{},
},
{
name: "unix socket with custom origins",
addr: NetworkAddress{
Network: "unix",
Host: "/tmp/caddy.sock",
},
origins: []string{"example.com"},
expectOrigins: []string{
"example.com",
},
},
{
name: "tcp socket on localhost gets all loopback addresses",
addr: NetworkAddress{
Network: "tcp",
Host: "localhost",
StartPort: 2019,
EndPort: 2019,
},
origins: nil,
expectOrigins: []string{
"localhost:2019",
"[::1]:2019",
"127.0.0.1:2019",
},
},
}
for i, test := range tests {
t.Run(test.name, func(t *testing.T) {
admin := AdminConfig{
Origins: test.origins,
}
got := admin.allowedOrigins(test.addr)
var gotOrigins []string
for _, u := range got {
gotOrigins = append(gotOrigins, u.Host)
}
if len(gotOrigins) != len(test.expectOrigins) {
t.Errorf("%d: Expected %d origins but got %d", i, len(test.expectOrigins), len(gotOrigins))
return
}
expectMap := make(map[string]struct{})
for _, origin := range test.expectOrigins {
expectMap[origin] = struct{}{}
}
gotMap := make(map[string]struct{})
for _, origin := range gotOrigins {
gotMap[origin] = struct{}{}
}
if !reflect.DeepEqual(expectMap, gotMap) {
t.Errorf("%d: Origins mismatch.\nExpected: %v\nGot: %v", i, test.expectOrigins, gotOrigins)
}
})
}
}
func TestReplaceRemoteAdminServer(t *testing.T) {
const testCert = `MIIDCTCCAfGgAwIBAgIUXsqJ1mY8pKlHQtI3HJ23x2eZPqwwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIzMDEwMTAwMDAwMFoXDTI0MDEw
MTAwMDAwMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEA4O4S6BSoYcoxvRqI+h7yPOjF6KjntjzVVm9M+uHK4lzX
F1L3pSxJ2nDD4wZEV3FJ5yFOHVFqkG2vXG3BIczOlYG7UeNmKbQnKc5kZj3HGUrS
VGEktA4OJbeZhhWP15gcXN5eDM2eH3g9BFXVX6AURxLiUXzhNBUEZuj/OEyH9yEF
/qPCE+EjzVvWxvBXwgz/io4r4yok/Vq/bxJ6FlV6R7DX5oJSXyO0VEHZPi9DIyNU
kK3F/r4U1sWiJGWOs8i3YQWZ2ejh1C0aLFZpPcCGGgMNpoF31gyYP6ZuPDUyCXsE
g36UUw1JHNtIXYcLhnXuqj4A8TybTDpgXLqvwA9DBQIDAQABo1MwUTAdBgNVHQ4E
FgQUc13z30pFC63rr/HGKOE7E82vjXwwHwYDVR0jBBgwFoAUc13z30pFC63rr/HG
KOE7E82vjXwwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAHO3j
oeiUXXJ7xD4P8Wj5t9d+E8lE1Xv1Dk3Z+EdG5+dan+RcToE42JJp9zB7FIh5Qz8g
W77LAjqh5oyqz3A2VJcyVgfE3uJP1R1mJM7JfGHf84QH4TZF2Q1RZY4SZs0VQ6+q
5wSlIZ4NXDy4Q4XkIJBGS61wT8IzYFXYBpx4PCP1Qj0PIE4sevEGwjsBIgxK307o
BxF8AWe6N6e4YZmQLGjQ+SeH0iwZb6vpkHyAY8Kj2hvK+cq2P7vU3VGi0t3r1F8L
IvrXHCvO2BMNJ/1UK1M4YNX8LYJqQhg9hEsIROe1OE/m3VhxIYMJI+qZXk9yHfgJ
vq+SH04xKhtFudVBAQ==`
tests := []struct {
name string
cfg *Config
wantErr bool
}{
{
name: "nil config",
cfg: nil,
wantErr: false,
},
{
name: "nil admin config",
cfg: &Config{
Admin: nil,
},
wantErr: false,
},
{
name: "nil remote config",
cfg: &Config{
Admin: &AdminConfig{},
},
wantErr: false,
},
{
name: "invalid listen address",
cfg: &Config{
Admin: &AdminConfig{
Remote: &RemoteAdmin{
Listen: "invalid:address",
},
},
},
wantErr: true,
},
{
name: "valid config",
cfg: &Config{
Admin: &AdminConfig{
Identity: &IdentityConfig{},
Remote: &RemoteAdmin{
Listen: "localhost:2021",
AccessControl: []*AdminAccess{
{
PublicKeys: []string{testCert},
Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/test"}}},
},
},
},
},
},
wantErr: false,
},
{
name: "invalid certificate",
cfg: &Config{
Admin: &AdminConfig{
Identity: &IdentityConfig{},
Remote: &RemoteAdmin{
Listen: "localhost:2021",
AccessControl: []*AdminAccess{
{
PublicKeys: []string{"invalid-cert-data"},
Permissions: []AdminPermissions{{Methods: []string{"GET"}, Paths: []string{"/test"}}},
},
},
},
},
},
wantErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx := Context{
Context: context.Background(),
cfg: test.cfg,
}
if test.cfg != nil {
test.cfg.storage = &certmagic.FileStorage{Path: t.TempDir()}
}
if test.cfg != nil && test.cfg.Admin != nil && test.cfg.Admin.Identity != nil {
identityCertCache = certmagic.NewCache(certmagic.CacheOptions{
GetConfigForCert: func(certmagic.Certificate) (*certmagic.Config, error) {
return &certmagic.Config{}, nil
},
})
}
err := replaceRemoteAdminServer(ctx, test.cfg)
if test.wantErr {
if err == nil {
t.Error("Expected error but got nil")
}
} else {
if err != nil {
t.Errorf("Expected no error but got: %v", err)
}
}
// Clean up
if remoteAdminServer != nil {
_ = stopAdminServer(remoteAdminServer)
}
})
}
}
type mockIssuer struct {
configSet *certmagic.Config
}
func (m *mockIssuer) Issue(ctx context.Context, csr *x509.CertificateRequest) (*certmagic.IssuedCertificate, error) {
return &certmagic.IssuedCertificate{
Certificate: []byte(csr.Raw),
}, nil
}
func (m *mockIssuer) SetConfig(cfg *certmagic.Config) {
m.configSet = cfg
}
func (m *mockIssuer) IssuerKey() string {
return "mock"
}
type mockIssuerModule struct {
*mockIssuer
}
func (m *mockIssuerModule) CaddyModule() ModuleInfo {
return ModuleInfo{
ID: "tls.issuance.acme",
New: func() Module {
return &mockIssuerModule{mockIssuer: new(mockIssuer)}
},
}
}
func TestManageIdentity(t *testing.T) {
originalModules := make(map[string]ModuleInfo)
maps.Copy(originalModules, modules)
defer func() {
modules = originalModules
}()
RegisterModule(&mockIssuerModule{})
certPEM := []byte(`-----BEGIN CERTIFICATE-----
MIIDujCCAqKgAwIBAgIIE31FZVaPXTUwDQYJKoZIhvcNAQEFBQAwSTELMAkGA1UE
BhMCVVMxEzARBgNVBAoTCkdvb2dsZSBJbmMxJTAjBgNVBAMTHEdvb2dsZSBJbnRl
cm5ldCBBdXRob3JpdHkgRzIwHhcNMTQwMTI5MTMyNzQzWhcNMTQwNTI5MDAwMDAw
WjBpMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwN
TW91bnRhaW4gVmlldzETMBEGA1UECgwKR29vZ2xlIEluYzEYMBYGA1UEAwwPbWFp
bC5nb29nbGUuY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE3lcub2pUwkjC
5GJQA2ZZfJJi6d1QHhEmkX9VxKYGp6gagZuRqJWy9TXP6++1ZzQQxqZLD0TkuxZ9
8i9Nz00000CCBjCCAQQwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMGgG
CCsGAQUFBwEBBFwwWjArBggrBgEFBQcwAoYfaHR0cDovL3BraS5nb29nbGUuY29t
L0dJQUcyLmNydDArBggrBgEFBQcwAYYfaHR0cDovL2NsaWVudHMxLmdvb2dsZS5j
b20vb2NzcDAdBgNVHQ4EFgQUiJxtimAuTfwb+aUtBn5UYKreKvMwDAYDVR0TAQH/
BAIwADAfBgNVHSMEGDAWgBRK3QYWG7z2aLV29YG2u2IaulqBLzAXBgNVHREEEDAO
ggxtYWlsLmdvb2dsZTANBgkqhkiG9w0BAQUFAAOCAQEAMP6IWgNGZE8wP9TjFjSZ
3mmW3A1eIr0CuPwNZ2LJ5ZD1i70ojzcj4I9IdP5yPg9CAEV4hNASbM1LzfC7GmJE
tPzW5tRmpKVWZGRgTgZI8Hp/xZXMwLh9ZmXV4kESFAGj5G5FNvJyUV7R5Eh+7OZX
7G4jJ4ZGJh+5jzN9HdJJHQHGYNIYOzC7+HH9UMwCjX9vhQ4RjwFZJThS2Yb+y7pb
9yxTJZoXC6J0H5JpnZb7kZEJ+Xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
-----END CERTIFICATE-----`)
keyPEM := []byte(`-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRS0LmTwUT0iwP
...
-----END PRIVATE KEY-----`)
testStorage := certmagic.FileStorage{Path: t.TempDir()}
err := testStorage.Store(context.Background(), "localhost/localhost.crt", certPEM)
if err != nil {
t.Fatal(err)
}
err = testStorage.Store(context.Background(), "localhost/localhost.key", keyPEM)
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
cfg *Config
wantErr bool
checkState func(*testing.T, *Config)
}{
{
name: "nil config",
cfg: nil,
},
{
name: "nil admin config",
cfg: &Config{
Admin: nil,
},
},
{
name: "nil identity config",
cfg: &Config{
Admin: &AdminConfig{},
},
},
{
name: "default issuer when none specified",
cfg: &Config{
Admin: &AdminConfig{
Identity: &IdentityConfig{
Identifiers: []string{"localhost"},
},
},
storage: &testStorage,
},
checkState: func(t *testing.T, cfg *Config) {
if len(cfg.Admin.Identity.issuers) == 0 {
t.Error("Expected at least 1 issuer to be configured")
return
}
if _, ok := cfg.Admin.Identity.issuers[0].(*mockIssuerModule); !ok {
t.Error("Expected mock issuer to be configured")
}
},
},
{
name: "custom issuer",
cfg: &Config{
Admin: &AdminConfig{
Identity: &IdentityConfig{
Identifiers: []string{"localhost"},
IssuersRaw: []json.RawMessage{
json.RawMessage(`{"module": "acme"}`),
},
},
},
storage: &certmagic.FileStorage{Path: "testdata"},
},
checkState: func(t *testing.T, cfg *Config) {
if len(cfg.Admin.Identity.issuers) != 1 {
t.Fatalf("Expected 1 issuer, got %d", len(cfg.Admin.Identity.issuers))
}
mockIss, ok := cfg.Admin.Identity.issuers[0].(*mockIssuerModule)
if !ok {
t.Fatal("Expected mock issuer")
}
if mockIss.configSet == nil {
t.Error("Issuer config was not set")
}
},
},
{
name: "invalid issuer module",
cfg: &Config{
Admin: &AdminConfig{
Identity: &IdentityConfig{
Identifiers: []string{"localhost"},
IssuersRaw: []json.RawMessage{
json.RawMessage(`{"module": "doesnt_exist"}`),
},
},
},
},
wantErr: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if identityCertCache != nil {
// Reset the cert cache before each test
identityCertCache.Stop()
identityCertCache = nil
}
ctx := Context{
Context: context.Background(),
cfg: test.cfg,
moduleInstances: make(map[string][]Module),
}
err := manageIdentity(ctx, test.cfg)
if test.wantErr {
if err == nil {
t.Error("Expected error but got nil")
}
return
}
if err != nil {
t.Fatalf("Expected no error but got: %v", err)
}
if test.checkState != nil {
test.checkState(t, test.cfg)
}
})
}
}
+71 -321
View File
@@ -22,7 +22,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"log" "log"
"net/http" "net/http"
"os" "os"
@@ -39,10 +38,18 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/caddyserver/caddy/v2/internal/filesystems"
"github.com/caddyserver/caddy/v2/notify" "github.com/caddyserver/caddy/v2/notify"
) )
func init() {
RegisterNamespace("", []interface{}{
(*App)(nil),
})
RegisterNamespace("caddy.storage", []interface{}{
(*StorageConverter)(nil),
})
}
// Config is the top (or beginning) of the Caddy configuration structure. // Config is the top (or beginning) of the Caddy configuration structure.
// Caddy config is expressed natively as a JSON document. If you prefer // Caddy config is expressed natively as a JSON document. If you prefer
// not to work with JSON directly, there are [many config adapters](/docs/config-adapters) // not to work with JSON directly, there are [many config adapters](/docs/config-adapters)
@@ -74,24 +81,21 @@ type Config struct {
// module is `caddy.storage.file_system` (the local file system), // module is `caddy.storage.file_system` (the local file system),
// and the default path // and the default path
// [depends on the OS and environment](/docs/conventions#data-directory). // [depends on the OS and environment](/docs/conventions#data-directory).
// A storage `module` should implement the following interfaces:
// - [StorageConverter](https://pkg.go.dev/github.com/caddyserver/caddy/v2#StorageConverter)
StorageRaw json.RawMessage `json:"storage,omitempty" caddy:"namespace=caddy.storage inline_key=module"` StorageRaw json.RawMessage `json:"storage,omitempty" caddy:"namespace=caddy.storage inline_key=module"`
// AppsRaw are the apps that Caddy will load and run. The // AppsRaw are the apps that Caddy will load and run. The
// app module name is the key, and the app's config is the // app module name is the key, and the app's config is the
// associated value. // associated value.
// An `app` should implement the following interfaces:
// - [caddy.App](https://pkg.go.dev/github.com/caddyserver/caddy/v2?tab=doc#App)
AppsRaw ModuleMap `json:"apps,omitempty" caddy:"namespace="` AppsRaw ModuleMap `json:"apps,omitempty" caddy:"namespace="`
apps map[string]App apps map[string]App
storage certmagic.Storage
// failedApps is a map of apps that failed to provision with their underlying error.
failedApps map[string]error
storage certmagic.Storage
eventEmitter eventEmitter
cancelFunc context.CancelFunc cancelFunc context.CancelFunc
// fileSystems is a dict of fileSystems that will later be loaded from and added to.
fileSystems FileSystems
} }
// App is a thing that Caddy runs. // App is a thing that Caddy runs.
@@ -401,78 +405,6 @@ func unsyncedDecodeAndRun(cfgJSON []byte, allowPersist bool) error {
// will want to use Run instead, which also // will want to use Run instead, which also
// updates the config's raw state. // updates the config's raw state.
func run(newCfg *Config, start bool) (Context, error) { func run(newCfg *Config, start bool) (Context, error) {
ctx, err := provisionContext(newCfg, start)
if err != nil {
globalMetrics.configSuccess.Set(0)
return ctx, err
}
if !start {
return ctx, nil
}
defer func() {
// if newCfg fails to start completely, clean up the already provisioned modules
// partially copied from provisionContext
if err != nil {
globalMetrics.configSuccess.Set(0)
ctx.cfg.cancelFunc()
if currentCtx.cfg != nil {
certmagic.Default.Storage = currentCtx.cfg.storage
}
}
}()
// 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))
for name, a := range ctx.cfg.apps {
err := a.Start()
if err != nil {
// an app failed to start, so we need to stop
// all other apps that were already started
for _, otherAppName := range started {
err2 := ctx.cfg.apps[otherAppName].Stop()
if err2 != nil {
err = fmt.Errorf("%v; additionally, aborting app %s: %v",
err, otherAppName, err2)
}
}
return fmt.Errorf("%s app module: start: %v", name, err)
}
started = append(started, name)
}
return nil
}()
if err != nil {
return ctx, err
}
globalMetrics.configSuccess.Set(1)
globalMetrics.configSuccessTime.SetToCurrentTime()
// TODO: This event is experimental and subject to change.
ctx.emitEvent("started", nil)
// now that the user's config is running, finish setting up anything else,
// such as remote admin endpoint, config loader, etc.
err = finishSettingUp(ctx, ctx.cfg)
return ctx, err
}
// provisionContext creates a new context from the given configuration and provisions
// storage and apps.
// If `newCfg` is nil a new empty configuration will be created.
// If `replaceAdminServer` is true any currently active admin server will be replaced
// with a new admin server based on the provided configuration.
func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error) {
// because we will need to roll back any state // because we will need to roll back any state
// modifications if this function errors, we // modifications if this function errors, we
// keep a single error value and scope all // keep a single error value and scope all
@@ -495,7 +427,6 @@ func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error)
ctx, cancel := NewContext(Context{Context: context.Background(), cfg: newCfg}) ctx, cancel := NewContext(Context{Context: context.Background(), cfg: newCfg})
defer func() { defer func() {
if err != nil { if err != nil {
globalMetrics.configSuccess.Set(0)
// if there were any errors during startup, // if there were any errors during startup,
// we should cancel the new context we created // we should cancel the new context we created
// since the associated config won't be used; // since the associated config won't be used;
@@ -520,12 +451,16 @@ func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error)
return ctx, err return ctx, err
} }
// create the new filesystem map // start the admin endpoint (and stop any prior one)
newCfg.fileSystems = &filesystems.FileSystemMap{} if start {
err = replaceLocalAdminServer(newCfg)
if err != nil {
return ctx, fmt.Errorf("starting caddy administration endpoint: %v", err)
}
}
// prepare the new config for use // prepare the new config for use
newCfg.apps = make(map[string]App) newCfg.apps = make(map[string]App)
newCfg.failedApps = make(map[string]error)
// set up global storage and make it CertMagic's default storage, too // set up global storage and make it CertMagic's default storage, too
err = func() error { err = func() error {
@@ -552,14 +487,6 @@ func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error)
return ctx, err return ctx, err
} }
// start the admin endpoint (and stop any prior one)
if replaceAdminServer {
err = replaceLocalAdminServer(newCfg, ctx)
if err != nil {
return ctx, fmt.Errorf("starting caddy administration endpoint: %v", err)
}
}
// Load and Provision each app and their submodules // Load and Provision each app and their submodules
err = func() error { err = func() error {
for appName := range newCfg.AppsRaw { for appName := range newCfg.AppsRaw {
@@ -569,16 +496,49 @@ func provisionContext(newCfg *Config, replaceAdminServer bool) (Context, error)
} }
return nil return nil
}() }()
return ctx, err if err != nil {
} return ctx, err
}
// ProvisionContext creates a new context from the configuration and provisions storage if !start {
// and app modules. return ctx, nil
// The function is intended for testing and advanced use cases only, typically `Run` should be }
// use to ensure a fully functional caddy instance.
// EXPERIMENTAL: While this is public the interface and implementation details of this function may change. // Provision any admin routers which may need to access
func ProvisionContext(newCfg *Config) (Context, error) { // some of the other apps at runtime
return provisionContext(newCfg, false) err = newCfg.Admin.provisionAdminRouters(ctx)
if err != nil {
return ctx, err
}
// Start
err = func() error {
started := make([]string, 0, len(newCfg.apps))
for name, a := range newCfg.apps {
err := a.Start()
if err != nil {
// an app failed to start, so we need to stop
// all other apps that were already started
for _, otherAppName := range started {
err2 := newCfg.apps[otherAppName].Stop()
if err2 != nil {
err = fmt.Errorf("%v; additionally, aborting app %s: %v",
err, otherAppName, err2)
}
}
return fmt.Errorf("%s app module: start: %v", name, err)
}
started = append(started, name)
}
return nil
}()
if err != nil {
return ctx, err
}
// now that the user's config is running, finish setting up anything else,
// such as remote admin endpoint, config loader, etc.
return ctx, finishSettingUp(ctx, newCfg)
} }
// finishSettingUp should be run after all apps have successfully started. // finishSettingUp should be run after all apps have successfully started.
@@ -717,9 +677,6 @@ func unsyncedStop(ctx Context) {
return return
} }
// TODO: This event is experimental and subject to change.
ctx.emitEvent("stopping", nil)
// stop each app // stop each app
for name, a := range ctx.cfg.apps { for name, a := range ctx.cfg.apps {
err := a.Stop() err := a.Stop()
@@ -749,10 +706,8 @@ func Validate(cfg *Config) error {
// Errors are logged along the way, and an appropriate exit // Errors are logged along the way, and an appropriate exit
// code is emitted. // code is emitted.
func exitProcess(ctx context.Context, logger *zap.Logger) { func exitProcess(ctx context.Context, logger *zap.Logger) {
// let the rest of the program know we're quitting; only do it once // let the rest of the program know we're quitting
if !atomic.CompareAndSwapInt32(exiting, 0, 1) { atomic.StoreInt32(exiting, 1)
return
}
// give the OS or service/process manager our 2 weeks' notice: we quit // give the OS or service/process manager our 2 weeks' notice: we quit
if err := notify.Stopping(); err != nil { if err := notify.Stopping(); err != nil {
@@ -765,7 +720,6 @@ func exitProcess(ctx context.Context, logger *zap.Logger) {
logger.Warn("exiting; byeee!! 👋") logger.Warn("exiting; byeee!! 👋")
exitCode := ExitCodeSuccess exitCode := ExitCodeSuccess
lastContext := ActiveContext()
// stop all apps // stop all apps
if err := Stop(); err != nil { if err := Stop(); err != nil {
@@ -787,16 +741,6 @@ func exitProcess(ctx context.Context, logger *zap.Logger) {
} }
} }
// execute any process-exit callbacks
for _, exitFunc := range lastContext.exitFuncs {
exitFunc(ctx)
}
exitFuncsMu.Lock()
for _, exitFunc := range exitFuncs {
exitFunc(ctx)
}
exitFuncsMu.Unlock()
// shut down admin endpoint(s) in goroutines so that // shut down admin endpoint(s) in goroutines so that
// if this function was called from an admin handler, // if this function was called from an admin handler,
// it has a chance to return gracefully // it has a chance to return gracefully
@@ -835,23 +779,6 @@ var exiting = new(int32) // accessed atomically
// EXPERIMENTAL API: subject to change or removal. // EXPERIMENTAL API: subject to change or removal.
func Exiting() bool { return atomic.LoadInt32(exiting) == 1 } 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
// function should only be registered once forever, NOT once
// per config load (etc).
//
// EXPERIMENTAL API: subject to change or removal.
func OnExit(f func(context.Context)) {
exitFuncsMu.Lock()
exitFuncs = append(exitFuncs, f)
exitFuncsMu.Unlock()
}
var (
exitFuncs []func(context.Context)
exitFuncsMu sync.Mutex
)
// Duration can be an integer or a string. An integer is // Duration can be an integer or a string. An integer is
// interpreted as nanoseconds. If a string, it is a Go // interpreted as nanoseconds. If a string, it is a Go
// time.Duration value such as `300ms`, `1.5h`, or `2h45m`; // time.Duration value such as `300ms`, `1.5h`, or `2h45m`;
@@ -914,12 +841,12 @@ func InstanceID() (uuid.UUID, error) {
appDataDir := AppDataDir() appDataDir := AppDataDir()
uuidFilePath := filepath.Join(appDataDir, "instance.uuid") uuidFilePath := filepath.Join(appDataDir, "instance.uuid")
uuidFileBytes, err := os.ReadFile(uuidFilePath) uuidFileBytes, err := os.ReadFile(uuidFilePath)
if errors.Is(err, fs.ErrNotExist) { if os.IsNotExist(err) {
uuid, err := uuid.NewRandom() uuid, err := uuid.NewRandom()
if err != nil { if err != nil {
return uuid, err return uuid, err
} }
err = os.MkdirAll(appDataDir, 0o700) err = os.MkdirAll(appDataDir, 0o600)
if err != nil { if err != nil {
return uuid, err return uuid, err
} }
@@ -975,11 +902,11 @@ func Version() (simple, full string) {
if CustomVersion != "" { if CustomVersion != "" {
full = CustomVersion full = CustomVersion
simple = CustomVersion simple = CustomVersion
return simple, full return
} }
full = "unknown" full = "unknown"
simple = "unknown" simple = "unknown"
return simple, full return
} }
// find the Caddy module in the dependency list // find the Caddy module in the dependency list
for _, dep := range bi.Deps { for _, dep := range bi.Deps {
@@ -1059,101 +986,9 @@ func Version() (simple, full string) {
} }
} }
return simple, full return
} }
// Event represents something that has happened or is happening.
// An Event value is not synchronized, so it should be copied if
// being used in goroutines.
//
// EXPERIMENTAL: Events are subject to change.
type Event struct {
// If non-nil, the event has been aborted, meaning
// propagation has stopped to other handlers and
// the code should stop what it was doing. Emitters
// may choose to use this as a signal to adjust their
// code path appropriately.
Aborted error
// The data associated with the event. Usually the
// original emitter will be the only one to set or
// change these values, but the field is exported
// so handlers can have full access if needed.
// However, this map is not synchronized, so
// handlers must not use this map directly in new
// goroutines; instead, copy the map to use it in a
// goroutine. Data may be nil.
Data map[string]any
id uuid.UUID
ts time.Time
name string
origin Module
}
// NewEvent creates a new event, but does not emit the event. To emit an
// event, call Emit() on the current instance of the caddyevents app insteaad.
//
// EXPERIMENTAL: Subject to change.
func NewEvent(ctx Context, name string, data map[string]any) (Event, error) {
id, err := uuid.NewRandom()
if err != nil {
return Event{}, fmt.Errorf("generating new event ID: %v", err)
}
name = strings.ToLower(name)
return Event{
Data: data,
id: id,
ts: time.Now(),
name: name,
origin: ctx.Module(),
}, nil
}
func (e Event) ID() uuid.UUID { return e.id }
func (e Event) Timestamp() time.Time { return e.ts }
func (e Event) Name() string { return e.name }
func (e Event) Origin() Module { return e.origin } // Returns the module that originated the event. May be nil, usually if caddy core emits the event.
// CloudEvent exports event e as a structure that, when
// serialized as JSON, is compatible with the
// CloudEvents spec.
func (e Event) CloudEvent() CloudEvent {
dataJSON, _ := json.Marshal(e.Data)
var source string
if e.Origin() == nil {
source = "caddy"
} else {
source = string(e.Origin().CaddyModule().ID)
}
return CloudEvent{
ID: e.id.String(),
Source: source,
SpecVersion: "1.0",
Type: e.name,
Time: e.ts,
DataContentType: "application/json",
Data: dataJSON,
}
}
// CloudEvent is a JSON-serializable structure that
// is compatible with the CloudEvents specification.
// See https://cloudevents.io.
// EXPERIMENTAL: Subject to change.
type CloudEvent struct {
ID string `json:"id"`
Source string `json:"source"`
SpecVersion string `json:"specversion"`
Type string `json:"type"`
Time time.Time `json:"time"`
DataContentType string `json:"datacontenttype,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
}
// ErrEventAborted cancels an event.
var ErrEventAborted = errors.New("event aborted")
// ActiveContext returns the currently-active context. // ActiveContext returns the currently-active context.
// This function is experimental and might be changed // This function is experimental and might be changed
// or removed in the future. // or removed in the future.
@@ -1197,91 +1032,6 @@ var (
rawCfgMu sync.RWMutex 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 // errSameConfig is returned if the new config is the same
// as the old one. This isn't usually an actual, actionable // as the old one. This isn't usually an actual, actionable
// error; it's mostly a sentinel value. // error; it's mostly a sentinel value.
-19
View File
@@ -15,7 +15,6 @@
package caddy package caddy
import ( import (
"context"
"testing" "testing"
"time" "time"
) )
@@ -73,21 +72,3 @@ func TestParseDuration(t *testing.T) {
} }
} }
} }
func TestEvent_CloudEvent_NilOrigin(t *testing.T) {
ctx, _ := NewContext(Context{Context: context.Background()}) // module will be nil by default
event, err := NewEvent(ctx, "started", nil)
if err != nil {
t.Fatalf("NewEvent() error = %v", err)
}
// This should not panic
ce := event.CloudEvent()
if ce.Source != "caddy" {
t.Errorf("Expected CloudEvent Source to be 'caddy', got '%s'", ce.Source)
}
if ce.Type != "started" {
t.Errorf("Expected CloudEvent Type to be 'started', got '%s'", ce.Type)
}
}
+20 -16
View File
@@ -52,7 +52,7 @@ func (a Adapter) Adapt(body []byte, options map[string]any) ([]byte, []caddyconf
return nil, warnings, err return nil, warnings, err
} }
// lint check: see if input was properly formatted; sometimes messy files parse // lint check: see if input was properly formatted; sometimes messy files files parse
// successfully but result in logical errors (the Caddyfile is a bad format, I'm sorry) // successfully but result in logical errors (the Caddyfile is a bad format, I'm sorry)
if warning, different := FormattingDifference(filename, body); different { if warning, different := FormattingDifference(filename, body); different {
warnings = append(warnings, warning) warnings = append(warnings, warning)
@@ -68,7 +68,7 @@ func (a Adapter) Adapt(body []byte, options map[string]any) ([]byte, []caddyconf
// TODO: also perform this check on imported files // TODO: also perform this check on imported files
func FormattingDifference(filename string, body []byte) (caddyconfig.Warning, bool) { func FormattingDifference(filename string, body []byte) (caddyconfig.Warning, bool) {
// replace windows-style newlines to normalize comparison // replace windows-style newlines to normalize comparison
normalizedBody := bytes.ReplaceAll(body, []byte("\r\n"), []byte("\n")) normalizedBody := bytes.Replace(body, []byte("\r\n"), []byte("\n"), -1)
formatted := Format(normalizedBody) formatted := Format(normalizedBody)
if bytes.Equal(formatted, normalizedBody) { if bytes.Equal(formatted, normalizedBody) {
@@ -92,26 +92,30 @@ func FormattingDifference(filename string, body []byte) (caddyconfig.Warning, bo
}, true }, true
} }
// Unmarshaler is a type that can unmarshal Caddyfile tokens to // Unmarshaler is a type that can unmarshal
// set itself up for a JSON encoding. The goal of an unmarshaler // Caddyfile tokens to set itself up for a
// is not to set itself up for actual use, but to set itself up for // JSON encoding. The goal of an unmarshaler
// being marshaled into JSON. Caddyfile-unmarshaled values will not // is not to set itself up for actual use,
// be used directly; they will be encoded as JSON and then used from // but to set itself up for being marshaled
// that. Implementations _may_ be able to support multiple segments // into JSON. Caddyfile-unmarshaled values
// (instances of their directive or batch of tokens); typically this // will not be used directly; they will be
// means wrapping parsing logic in a loop: `for d.Next() { ... }`. // encoded as JSON and then used from that.
// More commonly, only a single segment is supported, so a simple // Implementations must be able to support
// `d.Next()` at the start should be used to consume the module // multiple segments (instances of their
// identifier token (directive name, etc). // directive or batch of tokens); typically
// this means wrapping all token logic in
// a loop: `for d.Next() { ... }`.
type Unmarshaler interface { type Unmarshaler interface {
UnmarshalCaddyfile(d *Dispenser) error UnmarshalCaddyfile(d *Dispenser) error
} }
// ServerType is a type that can evaluate a Caddyfile and set up a caddy config. // ServerType is a type that can evaluate a Caddyfile and set up a caddy config.
type ServerType interface { type ServerType interface {
// Setup takes the server blocks which contain tokens, // Setup takes the server blocks which
// as well as options (e.g. CLI flags) and creates a // contain tokens, as well as options
// Caddy config, along with any warnings or an error. // (e.g. CLI flags) and creates a Caddy
// config, along with any warnings or
// an error.
Setup([]ServerBlock, map[string]any) (*caddy.Config, []caddyconfig.Warning, error) Setup([]ServerBlock, map[string]any) (*caddy.Config, []caddyconfig.Warning, error)
} }
+7 -53
View File
@@ -30,10 +30,6 @@ type Dispenser struct {
tokens []Token tokens []Token
cursor int cursor int
nesting int nesting int
// A map of arbitrary context data that can be used
// to pass through some information to unmarshalers.
context map[string]any
} }
// NewDispenser returns a Dispenser filled with the given tokens. // NewDispenser returns a Dispenser filled with the given tokens.
@@ -308,9 +304,9 @@ func (d *Dispenser) CountRemainingArgs() int {
} }
// RemainingArgs loads any more arguments (tokens on the same line) // RemainingArgs loads any more arguments (tokens on the same line)
// into a slice of strings and returns them. Open curly brace tokens // into a slice and returns them. Open curly brace tokens also indicate
// also indicate the end of arguments, and the curly brace is not // the end of arguments, and the curly brace is not included in
// included in the return value nor is it loaded. // the return value nor is it loaded.
func (d *Dispenser) RemainingArgs() []string { func (d *Dispenser) RemainingArgs() []string {
var args []string var args []string
for d.NextArg() { for d.NextArg() {
@@ -320,9 +316,9 @@ func (d *Dispenser) RemainingArgs() []string {
} }
// RemainingArgsRaw loads any more arguments (tokens on the same line, // RemainingArgsRaw loads any more arguments (tokens on the same line,
// retaining quotes) into a slice of strings and returns them. // retaining quotes) into a slice and returns them. Open curly brace
// Open curly brace tokens also indicate the end of arguments, // tokens also indicate the end of arguments, and the curly brace is
// and the curly brace is not included in the return value nor is it loaded. // not included in the return value nor is it loaded.
func (d *Dispenser) RemainingArgsRaw() []string { func (d *Dispenser) RemainingArgsRaw() []string {
var args []string var args []string
for d.NextArg() { for d.NextArg() {
@@ -331,18 +327,6 @@ func (d *Dispenser) RemainingArgsRaw() []string {
return args 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 // NewFromNextSegment returns a new dispenser with a copy of
// the tokens from the current token until the end of the // the tokens from the current token until the end of the
// "directive" whether that be to the end of the line or // "directive" whether that be to the end of the line or
@@ -427,7 +411,7 @@ func (d *Dispenser) EOFErr() error {
// Err generates a custom parse-time error with a message of msg. // Err generates a custom parse-time error with a message of msg.
func (d *Dispenser) Err(msg string) error { func (d *Dispenser) Err(msg string) error {
return d.WrapErr(errors.New(msg)) return d.Errf(msg)
} }
// Errf is like Err, but for formatted error messages // Errf is like Err, but for formatted error messages
@@ -470,34 +454,6 @@ func (d *Dispenser) DeleteN(amount int) []Token {
return d.tokens return d.tokens
} }
// SetContext sets a key-value pair in the context map.
func (d *Dispenser) SetContext(key string, value any) {
if d.context == nil {
d.context = make(map[string]any)
}
d.context[key] = value
}
// GetContext gets the value of a key in the context map.
func (d *Dispenser) GetContext(key string) any {
if d.context == nil {
return nil
}
return d.context[key]
}
// GetContextString gets the value of a key in the context map
// as a string, or an empty string if the key does not exist.
func (d *Dispenser) GetContextString(key string) string {
if d.context == nil {
return ""
}
if val, ok := d.context[key].(string); ok {
return val
}
return ""
}
// isNewLine determines whether the current token is on a different // isNewLine determines whether the current token is on a different
// line (higher line number) than the previous token. It handles imported // line (higher line number) than the previous token. It handles imported
// tokens correctly. If there isn't a previous token, it returns true. // tokens correctly. If there isn't a previous token, it returns true.
@@ -529,5 +485,3 @@ func (d *Dispenser) isNextOnNewLine() bool {
next := d.tokens[d.cursor+1] next := d.tokens[d.cursor+1]
return isNextOnNewLine(curr, next) return isNextOnNewLine(curr, next)
} }
const MatcherNameCtxKey = "matcher_name"
+1 -61
View File
@@ -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) { func TestDispenser_ArgErr_Err(t *testing.T) {
input := `dir1 { input := `dir1 {
} }
@@ -365,7 +305,7 @@ func TestDispenser_ArgErr_Err(t *testing.T) {
t.Errorf("Expected error message with custom message in it ('foobar'); got '%v'", err) t.Errorf("Expected error message with custom message in it ('foobar'); got '%v'", err)
} }
ErrBarIsFull := errors.New("bar is full") var ErrBarIsFull = errors.New("bar is full")
bookingError := d.Errf("unable to reserve: %w", ErrBarIsFull) bookingError := d.Errf("unable to reserve: %w", ErrBarIsFull)
if !errors.Is(bookingError, ErrBarIsFull) { if !errors.Is(bookingError, ErrBarIsFull) {
t.Errorf("Errf(): should be able to unwrap the error chain") t.Errorf("Errf(): should be able to unwrap the error chain")
+11 -120
View File
@@ -17,7 +17,6 @@ package caddyfile
import ( import (
"bytes" "bytes"
"io" "io"
"slices"
"unicode" "unicode"
) )
@@ -32,14 +31,6 @@ func Format(input []byte) []byte {
out := new(bytes.Buffer) out := new(bytes.Buffer)
rdr := bytes.NewReader(input) rdr := bytes.NewReader(input)
type heredocState int
const (
heredocClosed heredocState = 0
heredocOpening heredocState = 1
heredocOpened heredocState = 2
)
var ( var (
last rune // the last character that was written to the result last rune // the last character that was written to the result
@@ -52,14 +43,9 @@ func Format(input []byte) []byte {
newLines int // count of newlines consumed newLines int // count of newlines consumed
comment bool // whether we're in a comment comment bool // whether we're in a comment
quotes string // encountered quotes ('', '`', '"', '"`', '`"') quoted bool // whether we're in a quoted segment
escaped bool // whether current char is escaped 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 nesting int // indentation level
) )
@@ -88,61 +74,6 @@ func Format(input []byte) []byte {
} }
panic(err) panic(err)
} }
// detect whether we have the start of a heredoc
if quotes == "" && (heredoc == heredocClosed && !heredocEscaped) &&
space && last == '<' && ch == '<' {
write(ch)
heredoc = heredocOpening
space = false
continue
}
if heredoc == heredocOpening {
if ch == '\n' {
if len(heredocMarker) > 0 && heredocMarkerRegexp.MatchString(string(heredocMarker)) {
heredoc = heredocOpened
} else {
heredocMarker = nil
heredoc = heredocClosed
nextLine()
continue
}
write(ch)
continue
}
if unicode.IsSpace(ch) {
// a space means it's just a regular token and not a heredoc
heredocMarker = nil
heredoc = heredocClosed
} else {
heredocMarker = append(heredocMarker, ch)
write(ch)
continue
}
}
// if we're in a heredoc, all characters are read&write as-is
if heredoc == heredocOpened {
heredocClosingMarker = append(heredocClosingMarker, ch)
if len(heredocClosingMarker) > len(heredocMarker)+1 { // We assert that the heredocClosingMarker is followed by a unicode.Space
heredocClosingMarker = heredocClosingMarker[1:]
}
// check if we're done
if unicode.IsSpace(ch) && slices.Equal(heredocClosingMarker[:len(heredocClosingMarker)-1], heredocMarker) {
heredocMarker = nil
heredocClosingMarker = nil
heredoc = heredocClosed
} else {
write(ch)
if ch == '\n' {
heredocClosingMarker = heredocClosingMarker[:0]
}
continue
}
}
if last == '<' && space {
space = false
}
if comment { if comment {
if ch == '\n' { if ch == '\n' {
@@ -167,51 +98,25 @@ func Format(input []byte) []byte {
} }
if escaped { if escaped {
if ch == '<' {
heredocEscaped = true
}
write(ch) write(ch)
escaped = false escaped = false
continue continue
} }
if ch == '`' { if quoted {
switch quotes {
case "\"`":
quotes = "\""
case "`":
quotes = ""
case "\"":
quotes = "\"`"
default:
quotes = "`"
}
}
if quotes == "\"" {
if ch == '"' { if ch == '"' {
quotes = "" quoted = false
} }
write(ch) write(ch)
continue continue
} }
if ch == '"' { if space && ch == '"' {
switch quotes { quoted = true
case "":
if space {
quotes = "\""
}
case "`\"":
quotes = "`"
case "\"`":
quotes = ""
}
} }
if unicode.IsSpace(ch) { if unicode.IsSpace(ch) {
space = true space = true
heredocEscaped = false
if ch == '\n' { if ch == '\n' {
newLines++ newLines++
} }
@@ -241,7 +146,7 @@ func Format(input []byte) []byte {
openBrace = false openBrace = false
if beginningOfLine { if beginningOfLine {
indent() indent()
} else if !openBraceSpace || !unicode.IsSpace(last) { } else if !openBraceSpace {
write(' ') write(' ')
} }
write('{') write('{')
@@ -257,23 +162,14 @@ func Format(input []byte) []byte {
switch { switch {
case ch == '{': case ch == '{':
openBrace = true openBrace = true
openBraceSpace = spacePrior && !beginningOfLine
if openBraceSpace && newLines == 0 {
write(' ')
}
openBraceWritten = false openBraceWritten = false
if quotes == "`" { openBraceSpace = spacePrior && !beginningOfLine
write('{') if openBraceSpace {
openBraceWritten = true write(' ')
continue
} }
continue continue
case ch == '}' && (spacePrior || !openBrace): case ch == '}' && (spacePrior || !openBrace):
if quotes == "`" {
write('}')
continue
}
if last != '\n' { if last != '\n' {
nextLine() nextLine()
} }
@@ -309,11 +205,6 @@ func Format(input []byte) []byte {
write('{') write('{')
openBraceWritten = true openBraceWritten = true
} }
if spacePrior && ch == '<' {
space = true
}
write(ch) write(ch)
beginningOfLine = false beginningOfLine = false
-100
View File
@@ -362,106 +362,6 @@ block {
block { block {
} }
`,
},
{
description: "keep heredoc as-is",
input: `block {
heredoc <<HEREDOC
Here's more than one space Here's more than one space
HEREDOC
}
`,
expect: `block {
heredoc <<HEREDOC
Here's more than one space Here's more than one space
HEREDOC
}
`,
},
{
description: "Mixing heredoc with regular part",
input: `block {
heredoc <<HEREDOC
Here's more than one space Here's more than one space
HEREDOC
respond "More than one space will be eaten" 200
}
block2 {
heredoc <<HEREDOC
Here's more than one space Here's more than one space
HEREDOC
respond "More than one space will be eaten" 200
}
`,
expect: `block {
heredoc <<HEREDOC
Here's more than one space Here's more than one space
HEREDOC
respond "More than one space will be eaten" 200
}
block2 {
heredoc <<HEREDOC
Here's more than one space Here's more than one space
HEREDOC
respond "More than one space will be eaten" 200
}
`,
},
{
description: "Heredoc as regular token",
input: `block {
heredoc <<HEREDOC "More than one space will be eaten"
}
`,
expect: `block {
heredoc <<HEREDOC "More than one space will be eaten"
}
`,
},
{
description: "Escape heredoc",
input: `block {
heredoc \<<HEREDOC
respond "More than one space will be eaten" 200
}
`,
expect: `block {
heredoc \<<HEREDOC
respond "More than one space will be eaten" 200
}
`,
},
{
description: "Preserve braces wrapped by backquotes",
input: "block {respond `All braces should remain: {{now | date \"2006\"}}`}",
expect: "block {respond `All braces should remain: {{now | date \"2006\"}}`}",
},
{
description: "Preserve braces wrapped by quotes",
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}
}
`, `,
}, },
} { } {
+10 -6
View File
@@ -16,24 +16,23 @@ package caddyfile
import ( import (
"fmt" "fmt"
"slices"
) )
type adjacency map[string][]string type adjacency map[string][]string
type importGraph struct { type importGraph struct {
nodes map[string]struct{} nodes map[string]bool
edges adjacency edges adjacency
} }
func (i *importGraph) addNode(name string) { func (i *importGraph) addNode(name string) {
if i.nodes == nil { if i.nodes == nil {
i.nodes = make(map[string]struct{}) i.nodes = make(map[string]bool)
} }
if _, exists := i.nodes[name]; exists { if _, exists := i.nodes[name]; exists {
return return
} }
i.nodes[name] = struct{}{} i.nodes[name] = true
} }
func (i *importGraph) addNodes(names []string) { func (i *importGraph) addNodes(names []string) {
@@ -67,7 +66,7 @@ func (i *importGraph) addEdge(from, to string) error {
} }
if i.nodes == nil { if i.nodes == nil {
i.nodes = make(map[string]struct{}) i.nodes = make(map[string]bool)
} }
if i.edges == nil { if i.edges == nil {
i.edges = make(adjacency) i.edges = make(adjacency)
@@ -92,7 +91,12 @@ func (i *importGraph) areConnected(from, to string) bool {
if !ok { if !ok {
return false return false
} }
return slices.Contains(al, to) for _, v := range al {
if v == to {
return true
}
}
return false
} }
func (i *importGraph) willCycle(from, to string) bool { func (i *importGraph) willCycle(from, to string) bool {
+3 -24
View File
@@ -137,7 +137,7 @@ func (l *lexer) next() (bool, error) {
} }
// detect whether we have the start of a heredoc // detect whether we have the start of a heredoc
if (!quoted && !btQuoted) && (!inHeredoc && !heredocEscaped) && if !(quoted || btQuoted) && !(inHeredoc || heredocEscaped) &&
len(val) > 1 && string(val[:2]) == "<<" { len(val) > 1 && string(val[:2]) == "<<" {
// a space means it's just a regular token and not a heredoc // a space means it's just a regular token and not a heredoc
if ch == ' ' { if ch == ' ' {
@@ -186,7 +186,7 @@ func (l *lexer) next() (bool, error) {
} }
// check if we're done, i.e. that the last few characters are the marker // check if we're done, i.e. that the last few characters are the marker
if len(val) >= len(heredocMarker) && heredocMarker == string(val[len(val)-len(heredocMarker):]) { if len(val) > len(heredocMarker) && heredocMarker == string(val[len(val)-len(heredocMarker):]) {
// set the final value // set the final value
val, err = l.finalizeHeredoc(val, heredocMarker) val, err = l.finalizeHeredoc(val, heredocMarker)
if err != nil { if err != nil {
@@ -313,18 +313,12 @@ func (l *lexer) finalizeHeredoc(val []rune, marker string) ([]rune, error) {
// iterate over each line and strip the whitespace from the front // iterate over each line and strip the whitespace from the front
var out string var out string
for lineNum, lineText := range lines[:len(lines)-1] { for lineNum, lineText := range lines[:len(lines)-1] {
if lineText == "" || lineText == "\r" {
out += "\n"
continue
}
// find an exact match for the padding // find an exact match for the padding
index := strings.Index(lineText, paddingToStrip) index := strings.Index(lineText, paddingToStrip)
// if the padding doesn't match exactly at the start then we can't safely strip // if the padding doesn't match exactly at the start then we can't safely strip
if index != 0 { if index != 0 {
cleanLineText := strings.TrimRight(lineText, "\r\n") return nil, fmt.Errorf("mismatched leading whitespace in heredoc <<%s on line #%d [%s], expected whitespace [%s] to match the closing marker", marker, l.line+lineNum+1, lineText, paddingToStrip)
return nil, fmt.Errorf("mismatched leading whitespace in heredoc <<%s on line #%d [%s], expected whitespace [%s] to match the closing marker", marker, l.line+lineNum+1, cleanLineText, paddingToStrip)
} }
// strip, then append the line, with the newline, to the output. // strip, then append the line, with the newline, to the output.
@@ -341,8 +335,6 @@ func (l *lexer) finalizeHeredoc(val []rune, marker string) ([]rune, error) {
return []rune(out), nil return []rune(out), nil
} }
// Quoted returns true if the token was enclosed in quotes
// (i.e. double quotes, backticks, or heredoc).
func (t Token) Quoted() bool { func (t Token) Quoted() bool {
return t.wasQuoted > 0 return t.wasQuoted > 0
} }
@@ -359,19 +351,6 @@ func (t Token) NumLineBreaks() int {
return lineBreaks return lineBreaks
} }
// Clone returns a deep copy of the token.
func (t Token) Clone() Token {
return Token{
File: t.File,
imports: append([]string{}, t.imports...),
Line: t.Line,
Text: t.Text,
wasQuoted: t.wasQuoted,
heredocMarker: t.heredocMarker,
snippetName: t.snippetName,
}
}
var heredocMarkerRegexp = regexp.MustCompile("^[A-Za-z0-9_-]+$") var heredocMarkerRegexp = regexp.MustCompile("^[A-Za-z0-9_-]+$")
// isNextOnNewLine tests whether t2 is on a different line from t1 // isNextOnNewLine tests whether t2 is on a different line from t1
-54
View File
@@ -285,18 +285,6 @@ EOF same-line-arg
}, },
{ {
input: []byte(`heredoc <<EOF input: []byte(`heredoc <<EOF
EOF
HERE same-line-arg
`),
expected: []Token{
{Line: 1, Text: `heredoc`},
{Line: 1, Text: ``},
{Line: 3, Text: `HERE`},
{Line: 3, Text: `same-line-arg`},
},
},
{
input: []byte(`heredoc <<EOF
EOF same-line-arg EOF same-line-arg
`), `),
expected: []Token{ expected: []Token{
@@ -457,48 +445,6 @@ EOF
expectErr: true, expectErr: true,
errorMessage: "mismatched leading whitespace in heredoc <<EOF on line #2 [ content], expected whitespace [\t\t] to match the closing marker", errorMessage: "mismatched leading whitespace in heredoc <<EOF on line #2 [ content], expected whitespace [\t\t] to match the closing marker",
}, },
{
input: []byte(`heredoc <<EOF
The next line is a blank line
The previous line is a blank line
EOF`),
expected: []Token{
{Line: 1, Text: "heredoc"},
{Line: 1, Text: "The next line is a blank line\n\nThe previous line is a blank line"},
},
},
{
input: []byte(`heredoc <<EOF
One tab indented heredoc with blank next line
One tab indented heredoc with blank previous line
EOF`),
expected: []Token{
{Line: 1, Text: "heredoc"},
{Line: 1, Text: "One tab indented heredoc with blank next line\n\nOne tab indented heredoc with blank previous line"},
},
},
{
input: []byte(`heredoc <<EOF
The next line is a blank line with one tab
The previous line is a blank line with one tab
EOF`),
expected: []Token{
{Line: 1, Text: "heredoc"},
{Line: 1, Text: "The next line is a blank line with one tab\n\t\nThe previous line is a blank line with one tab"},
},
},
{
input: []byte(`heredoc <<EOF
The next line is a blank line with one tab less than the correct indentation
The previous line is a blank line with one tab less than the correct indentation
EOF`),
expectErr: true,
errorMessage: "mismatched leading whitespace in heredoc <<EOF on line #3 [\t], expected whitespace [\t\t] to match the closing marker",
},
} }
for i, testCase := range testCases { for i, testCase := range testCases {
+28 -100
View File
@@ -50,7 +50,7 @@ func Parse(filename string, input []byte) ([]ServerBlock, error) {
p := parser{ p := parser{
Dispenser: NewDispenser(tokens), Dispenser: NewDispenser(tokens),
importGraph: importGraph{ importGraph: importGraph{
nodes: make(map[string]struct{}), nodes: make(map[string]bool),
edges: make(adjacency), edges: make(adjacency),
}, },
} }
@@ -160,14 +160,14 @@ func (p *parser) begin() error {
} }
if ok, name := p.isNamedRoute(); ok { if ok, name := p.isNamedRoute(); ok {
// named routes only have one key, the route name
p.block.Keys = []string{name}
p.block.IsNamedRoute = true
// we just need a dummy leading token to ease parsing later // we just need a dummy leading token to ease parsing later
nameToken := p.Token() nameToken := p.Token()
nameToken.Text = name nameToken.Text = name
// named routes only have one key, the route name
p.block.Keys = []Token{nameToken}
p.block.IsNamedRoute = true
// get all the tokens from the block, including the braces // get all the tokens from the block, including the braces
tokens, err := p.blockTokens(true) tokens, err := p.blockTokens(true)
if err != nil { if err != nil {
@@ -211,16 +211,10 @@ func (p *parser) addresses() error {
var expectingAnother bool var expectingAnother bool
for { for {
value := p.Val() tkn := p.Val()
token := p.Token()
// Reject request matchers if trying to define them globally // special case: import directive replaces tokens during parse-time
if strings.HasPrefix(value, "@") { if tkn == "import" && p.isNewLine() {
return p.Errf("request matchers may not be defined globally, they must be in a site block; found %s", value)
}
// Special case: import directive replaces tokens during parse-time
if value == "import" && p.isNewLine() {
err := p.doImport(0) err := p.doImport(0)
if err != nil { if err != nil {
return err return err
@@ -229,9 +223,9 @@ func (p *parser) addresses() error {
} }
// Open brace definitely indicates end of addresses // Open brace definitely indicates end of addresses
if value == "{" { if tkn == "{" {
if expectingAnother { if expectingAnother {
return p.Errf("Expected another address but had '%s' - check for extra comma", value) return p.Errf("Expected another address but had '%s' - check for extra comma", tkn)
} }
// Mark this server block as being defined with braces. // Mark this server block as being defined with braces.
// This is used to provide a better error message when // This is used to provide a better error message when
@@ -243,15 +237,15 @@ func (p *parser) addresses() error {
} }
// Users commonly forget to place a space between the address and the '{' // Users commonly forget to place a space between the address and the '{'
if strings.HasSuffix(value, "{") { if strings.HasSuffix(tkn, "{") {
return p.Errf("Site addresses cannot end with a curly brace: '%s' - put a space between the token and the brace", value) return p.Errf("Site addresses cannot end with a curly brace: '%s' - put a space between the token and the brace", tkn)
} }
if value != "" { // empty token possible if user typed "" if tkn != "" { // empty token possible if user typed ""
// Trailing comma indicates another address will follow, which // Trailing comma indicates another address will follow, which
// may possibly be on the next line // may possibly be on the next line
if value[len(value)-1] == ',' { if tkn[len(tkn)-1] == ',' {
value = value[:len(value)-1] tkn = tkn[:len(tkn)-1]
expectingAnother = true expectingAnother = true
} else { } else {
expectingAnother = false // but we may still see another one on this line expectingAnother = false // but we may still see another one on this line
@@ -260,17 +254,11 @@ func (p *parser) addresses() error {
// If there's a comma here, it's probably because they didn't use a space // If there's a comma here, it's probably because they didn't use a space
// between their two domains, e.g. "foo.com,bar.com", which would not be // between their two domains, e.g. "foo.com,bar.com", which would not be
// parsed as two separate site addresses. // parsed as two separate site addresses.
if strings.Contains(value, ",") { if strings.Contains(tkn, ",") {
return p.Errf("Site addresses cannot contain a comma ',': '%s' - put a space after the comma to separate site addresses", value) return p.Errf("Site addresses cannot contain a comma ',': '%s' - put a space after the comma to separate site addresses", tkn)
} }
// After the above, a comma surrounded by spaces would result p.block.Keys = append(p.block.Keys, tkn)
// in an empty token which we should ignore
if value != "" {
// Add the token as a site address
token.Text = value
p.block.Keys = append(p.block.Keys, token)
}
} }
// Advance token and possibly break out of loop or return error // Advance token and possibly break out of loop or return error
@@ -369,40 +357,9 @@ func (p *parser) doImport(nesting int) error {
// set up a replacer for non-variadic args replacement // set up a replacer for non-variadic args replacement
repl := makeArgsReplacer(args) repl := makeArgsReplacer(args)
// grab all the tokens (if it exists) from within a block that follows the import
var blockTokens []Token
for currentNesting := p.Nesting(); p.NextBlock(currentNesting); {
blockTokens = append(blockTokens, p.Token())
}
// initialize with size 1
blockMapping := make(map[string][]Token, 1)
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 == "{" {
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); {
currentMappingTokens = append(currentMappingTokens, bd.Token())
}
blockMapping[currentMappingKey] = currentMappingTokens
}
}
// splice out the import directive and its arguments // splice out the import directive and its arguments
// (2 tokens, plus the length of args) // (2 tokens, plus the length of args)
tokensBefore := p.tokens[:p.cursor-1-len(args)-len(blockTokens)] tokensBefore := p.tokens[:p.cursor-1-len(args)]
tokensAfter := p.tokens[p.cursor+1:] tokensAfter := p.tokens[p.cursor+1:]
var importedTokens []Token var importedTokens []Token
var nodes []string var nodes []string
@@ -418,7 +375,7 @@ func (p *parser) doImport(nesting int) error {
// make path relative to the file of the _token_ being processed rather // make path relative to the file of the _token_ being processed rather
// than current working directory (issue #867) and then use glob to get // than current working directory (issue #867) and then use glob to get
// list of matching filenames // list of matching filenames
absFile, err := caddy.FastAbs(p.Dispenser.File()) absFile, err := filepath.Abs(p.Dispenser.File())
if err != nil { if err != nil {
return p.Errf("Failed to get absolute path of file: %s: %v", p.Dispenser.File(), err) return p.Errf("Failed to get absolute path of file: %s: %v", p.Dispenser.File(), err)
} }
@@ -436,6 +393,7 @@ func (p *parser) doImport(nesting int) error {
return p.Errf("Glob pattern may only contain one wildcard (*), but has others: %s", globPattern) return p.Errf("Glob pattern may only contain one wildcard (*), but has others: %s", globPattern)
} }
matches, err = filepath.Glob(globPattern) matches, err = filepath.Glob(globPattern)
if err != nil { if err != nil {
return p.Errf("Failed to use import pattern %s: %v", importPattern, err) return p.Errf("Failed to use import pattern %s: %v", importPattern, err)
} }
@@ -531,28 +489,6 @@ func (p *parser) doImport(nesting int) error {
maybeSnippet = false maybeSnippet = false
} }
} }
// 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 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
}
}
if foundBlockDirective {
tokensCopy = append(tokensCopy, tokensToAdd...)
continue
}
if maybeSnippet { if maybeSnippet {
tokensCopy = append(tokensCopy, token) tokensCopy = append(tokensCopy, token)
@@ -574,7 +510,7 @@ func (p *parser) doImport(nesting int) error {
// splice the imported tokens in the place of the import statement // splice the imported tokens in the place of the import statement
// and rewind cursor so Next() will land on first imported token // and rewind cursor so Next() will land on first imported token
p.tokens = append(tokensBefore, append(tokensCopy, tokensAfter...)...) p.tokens = append(tokensBefore, append(tokensCopy, tokensAfter...)...)
p.cursor -= len(args) + len(blockTokens) + 1 p.cursor -= len(args) + 1
return nil return nil
} }
@@ -612,7 +548,7 @@ func (p *parser) doSingleImport(importFile string) ([]Token, error) {
// Tack the file path onto these tokens so errors show the imported file's name // Tack the file path onto these tokens so errors show the imported file's name
// (we use full, absolute path to avoid bugs: issue #1892) // (we use full, absolute path to avoid bugs: issue #1892)
filename, err := caddy.FastAbs(importFile) filename, err := filepath.Abs(importFile)
if err != nil { if err != nil {
return nil, p.Errf("Failed to get absolute path of file: %s: %v", importFile, err) return nil, p.Errf("Failed to get absolute path of file: %s: %v", importFile, err)
} }
@@ -701,8 +637,8 @@ func (p *parser) closeCurlyBrace() error {
func (p *parser) isNamedRoute() (bool, string) { func (p *parser) isNamedRoute() (bool, string) {
keys := p.block.Keys keys := p.block.Keys
// A named route block is a single key with parens, prefixed with &. // A named route block is a single key with parens, prefixed with &.
if len(keys) == 1 && strings.HasPrefix(keys[0].Text, "&(") && strings.HasSuffix(keys[0].Text, ")") { if len(keys) == 1 && strings.HasPrefix(keys[0], "&(") && strings.HasSuffix(keys[0], ")") {
return true, strings.TrimSuffix(keys[0].Text[2:], ")") return true, strings.TrimSuffix(keys[0][2:], ")")
} }
return false, "" return false, ""
} }
@@ -710,8 +646,8 @@ func (p *parser) isNamedRoute() (bool, string) {
func (p *parser) isSnippet() (bool, string) { func (p *parser) isSnippet() (bool, string) {
keys := p.block.Keys keys := p.block.Keys
// A snippet block is a single key with parens. Nothing else qualifies. // A snippet block is a single key with parens. Nothing else qualifies.
if len(keys) == 1 && strings.HasPrefix(keys[0].Text, "(") && strings.HasSuffix(keys[0].Text, ")") { if len(keys) == 1 && strings.HasPrefix(keys[0], "(") && strings.HasSuffix(keys[0], ")") {
return true, strings.TrimSuffix(keys[0].Text[1:], ")") return true, strings.TrimSuffix(keys[0][1:], ")")
} }
return false, "" return false, ""
} }
@@ -755,19 +691,11 @@ func (p *parser) blockTokens(retainCurlies bool) ([]Token, error) {
// grouped by segments. // grouped by segments.
type ServerBlock struct { type ServerBlock struct {
HasBraces bool HasBraces bool
Keys []Token Keys []string
Segments []Segment Segments []Segment
IsNamedRoute bool IsNamedRoute bool
} }
func (sb ServerBlock) GetKeysText() []string {
res := []string{}
for _, k := range sb.Keys {
res = append(res, k.Text)
}
return res
}
// DispenseDirective returns a dispenser that contains // DispenseDirective returns a dispenser that contains
// all the tokens in the server block. // all the tokens in the server block.
func (sb ServerBlock) DispenseDirective(dir string) *Dispenser { func (sb ServerBlock) DispenseDirective(dir string) *Dispenser {
+23 -98
View File
@@ -18,12 +18,11 @@ import (
"bytes" "bytes"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
func TestParseVariadic(t *testing.T) { func TestParseVariadic(t *testing.T) {
args := make([]string, 10) var args = make([]string, 10)
for i, tc := range []struct { for i, tc := range []struct {
input string input string
result bool result bool
@@ -112,6 +111,7 @@ func TestAllTokens(t *testing.T) {
input := []byte("a b c\nd e") input := []byte("a b c\nd e")
expected := []string{"a", "b", "c", "d", "e"} expected := []string{"a", "b", "c", "d", "e"}
tokens, err := allTokens("TestAllTokens", input) tokens, err := allTokens("TestAllTokens", input)
if err != nil { if err != nil {
t.Fatalf("Expected no error, got %v", err) t.Fatalf("Expected no error, got %v", err)
} }
@@ -149,11 +149,10 @@ func TestParseOneAndImport(t *testing.T) {
"localhost", "localhost",
}, []int{1}}, }, []int{1}},
{ {`localhost:1234
`localhost:1234
dir1 foo bar`, false, []string{ dir1 foo bar`, false, []string{
"localhost:1234", "localhost:1234",
}, []int{3}, }, []int{3},
}, },
{`localhost { {`localhost {
@@ -348,7 +347,7 @@ func TestParseOneAndImport(t *testing.T) {
i, len(test.keys), len(result.Keys)) i, len(test.keys), len(result.Keys))
continue continue
} }
for j, addr := range result.GetKeysText() { for j, addr := range result.Keys {
if addr != test.keys[j] { if addr != test.keys[j] {
t.Errorf("Test %d, key %d: Expected '%s', but was '%s'", t.Errorf("Test %d, key %d: Expected '%s', but was '%s'",
i, j, test.keys[j], addr) i, j, test.keys[j], addr)
@@ -380,9 +379,8 @@ func TestRecursiveImport(t *testing.T) {
} }
isExpected := func(got ServerBlock) bool { isExpected := func(got ServerBlock) bool {
textKeys := got.GetKeysText() if len(got.Keys) != 1 || got.Keys[0] != "localhost" {
if len(textKeys) != 1 || textKeys[0] != "localhost" { t.Errorf("got keys unexpected: expect localhost, got %v", got.Keys)
t.Errorf("got keys unexpected: expect localhost, got %v", textKeys)
return false return false
} }
if len(got.Segments) != 2 { if len(got.Segments) != 2 {
@@ -409,13 +407,13 @@ func TestRecursiveImport(t *testing.T) {
err = os.WriteFile(recursiveFile1, []byte( err = os.WriteFile(recursiveFile1, []byte(
`localhost `localhost
dir1 dir1
import recursive_import_test2`), 0o644) import recursive_import_test2`), 0644)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer os.Remove(recursiveFile1) defer os.Remove(recursiveFile1)
err = os.WriteFile(recursiveFile2, []byte("dir2 1"), 0o644) err = os.WriteFile(recursiveFile2, []byte("dir2 1"), 0644)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -443,7 +441,7 @@ func TestRecursiveImport(t *testing.T) {
err = os.WriteFile(recursiveFile1, []byte( err = os.WriteFile(recursiveFile1, []byte(
`localhost `localhost
dir1 dir1
import `+recursiveFile2), 0o644) import `+recursiveFile2), 0644)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -476,9 +474,8 @@ func TestDirectiveImport(t *testing.T) {
} }
isExpected := func(got ServerBlock) bool { isExpected := func(got ServerBlock) bool {
textKeys := got.GetKeysText() if len(got.Keys) != 1 || got.Keys[0] != "localhost" {
if len(textKeys) != 1 || textKeys[0] != "localhost" { t.Errorf("got keys unexpected: expect localhost, got %v", got.Keys)
t.Errorf("got keys unexpected: expect localhost, got %v", textKeys)
return false return false
} }
if len(got.Segments) != 2 { if len(got.Segments) != 2 {
@@ -498,7 +495,7 @@ func TestDirectiveImport(t *testing.T) {
} }
err = os.WriteFile(directiveFile, []byte(`prop1 1 err = os.WriteFile(directiveFile, []byte(`prop1 1
prop2 2`), 0o644) prop2 2`), 0644)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -556,10 +553,6 @@ func TestParseAll(t *testing.T) {
{"localhost:1234", "http://host2"}, {"localhost:1234", "http://host2"},
}}, }},
{`foo.example.com , example.com`, false, [][]string{
{"foo.example.com", "example.com"},
}},
{`localhost:1234, http://host2,`, true, [][]string{}}, {`localhost:1234, http://host2,`, true, [][]string{}},
{`http://host1.com, http://host2.com { {`http://host1.com, http://host2.com {
@@ -619,11 +612,11 @@ func TestParseAll(t *testing.T) {
} }
for j, block := range blocks { for j, block := range blocks {
if len(block.Keys) != len(test.keys[j]) { if len(block.Keys) != len(test.keys[j]) {
t.Errorf("Test %d: Expected %d keys in block %d, got %d: %v", t.Errorf("Test %d: Expected %d keys in block %d, got %d",
i, len(test.keys[j]), j, len(block.Keys), block.Keys) i, len(test.keys[j]), j, len(block.Keys))
continue continue
} }
for k, addr := range block.GetKeysText() { for k, addr := range block.Keys {
if addr != test.keys[j][k] { if addr != test.keys[j][k] {
t.Errorf("Test %d, block %d, key %d: Expected '%s', but got '%s'", t.Errorf("Test %d, block %d, key %d: Expected '%s', but got '%s'",
i, j, k, test.keys[j][k], addr) i, j, k, test.keys[j][k], addr)
@@ -776,7 +769,7 @@ func TestSnippets(t *testing.T) {
if len(blocks) != 1 { if len(blocks) != 1 {
t.Fatalf("Expect exactly one server block. Got %d.", len(blocks)) t.Fatalf("Expect exactly one server block. Got %d.", len(blocks))
} }
if actual, expected := blocks[0].GetKeysText()[0], "http://example.com"; expected != actual { if actual, expected := blocks[0].Keys[0], "http://example.com"; expected != actual {
t.Errorf("Expected server name to be '%s' but was '%s'", expected, actual) t.Errorf("Expected server name to be '%s' but was '%s'", expected, actual)
} }
if len(blocks[0].Segments) != 2 { if len(blocks[0].Segments) != 2 {
@@ -808,7 +801,7 @@ func TestImportedFilesIgnoreNonDirectiveImportTokens(t *testing.T) {
fileName := writeStringToTempFileOrDie(t, ` fileName := writeStringToTempFileOrDie(t, `
http://example.com { http://example.com {
# This isn't an import directive, it's just an arg with value 'import' # This isn't an import directive, it's just an arg with value 'import'
basic_auth / import password basicauth / import password
} }
`) `)
// Parse the root file that imports the other one. // Parse the root file that imports the other one.
@@ -819,12 +812,12 @@ func TestImportedFilesIgnoreNonDirectiveImportTokens(t *testing.T) {
} }
auth := blocks[0].Segments[0] auth := blocks[0].Segments[0]
line := auth[0].Text + " " + auth[1].Text + " " + auth[2].Text + " " + auth[3].Text line := auth[0].Text + " " + auth[1].Text + " " + auth[2].Text + " " + auth[3].Text
if line != "basic_auth / import password" { if line != "basicauth / import password" {
// Previously, it would be changed to: // Previously, it would be changed to:
// basic_auth / import /path/to/test/dir/password // basicauth / import /path/to/test/dir/password
// referencing a file that (probably) doesn't exist and changing the // referencing a file that (probably) doesn't exist and changing the
// password! // password!
t.Errorf("Expected basic_auth tokens to be 'basic_auth / import password' but got %#q", line) t.Errorf("Expected basicauth tokens to be 'basicauth / import password' but got %#q", line)
} }
} }
@@ -851,7 +844,7 @@ func TestSnippetAcrossMultipleFiles(t *testing.T) {
if len(blocks) != 1 { if len(blocks) != 1 {
t.Fatalf("Expect exactly one server block. Got %d.", len(blocks)) t.Fatalf("Expect exactly one server block. Got %d.", len(blocks))
} }
if actual, expected := blocks[0].GetKeysText()[0], "http://example.com"; expected != actual { if actual, expected := blocks[0].Keys[0], "http://example.com"; expected != actual {
t.Errorf("Expected server name to be '%s' but was '%s'", expected, actual) t.Errorf("Expected server name to be '%s' but was '%s'", expected, actual)
} }
if len(blocks[0].Segments) != 1 { if len(blocks[0].Segments) != 1 {
@@ -862,74 +855,6 @@ func TestSnippetAcrossMultipleFiles(t *testing.T) {
} }
} }
func TestRejectsGlobalMatcher(t *testing.T) {
p := testParser(`
@rejected path /foo
(common) {
gzip foo
errors stderr
}
http://example.com {
import common
}
`)
_, err := p.parseAll()
if err == nil {
t.Fatal("Expected an error, but got nil")
}
expected := "request matchers may not be defined globally, they must be in a site block; found @rejected, at Testfile:2"
if err.Error() != expected {
t.Errorf("Expected error to be '%s' but got '%v'", expected, err)
}
}
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 testParser(input string) parser { func testParser(input string) parser {
return parser{Dispenser: NewTestDispenser(input)} return parser{Dispenser: NewTestDispenser(input)}
} }
+118 -194
View File
@@ -31,7 +31,7 @@ import (
"github.com/caddyserver/caddy/v2/modules/caddyhttp" "github.com/caddyserver/caddy/v2/modules/caddyhttp"
) )
// mapAddressToProtocolToServerBlocks returns a map of listener address to list of server // mapAddressToServerBlocks returns a map of listener address to list of server
// blocks that will be served on that address. To do this, each server block is // blocks that will be served on that address. To do this, each server block is
// expanded so that each one is considered individually, although keys of a // expanded so that each one is considered individually, although keys of a
// server block that share the same address stay grouped together so the config // server block that share the same address stay grouped together so the config
@@ -77,15 +77,10 @@ import (
// repetition may be undesirable, so call consolidateAddrMappings() to map // repetition may be undesirable, so call consolidateAddrMappings() to map
// multiple addresses to the same lists of server blocks (a many:many mapping). // multiple addresses to the same lists of server blocks (a many:many mapping).
// (Doing this is essentially a map-reduce technique.) // (Doing this is essentially a map-reduce technique.)
func (st *ServerType) mapAddressToProtocolToServerBlocks(originalServerBlocks []serverBlock, func (st *ServerType) mapAddressToServerBlocks(originalServerBlocks []serverBlock,
options map[string]any, options map[string]any,
) (map[string]map[string][]serverBlock, error) { ) (map[string][]serverBlock, error) {
addrToProtocolToServerBlocks := map[string]map[string][]serverBlock{} sbmap := make(map[string][]serverBlock)
type keyWithParsedKey struct {
key caddyfile.Token
parsedKey Address
}
for i, sblock := range originalServerBlocks { for i, sblock := range originalServerBlocks {
// within a server block, we need to map all the listener addresses // within a server block, we need to map all the listener addresses
@@ -93,48 +88,27 @@ func (st *ServerType) mapAddressToProtocolToServerBlocks(originalServerBlocks []
// will be served by them; this has the effect of treating each // will be served by them; this has the effect of treating each
// key of a server block as its own, but without having to repeat its // key of a server block as its own, but without having to repeat its
// contents in cases where multiple keys really can be served together // contents in cases where multiple keys really can be served together
addrToProtocolToKeyWithParsedKeys := map[string]map[string][]keyWithParsedKey{} addrToKeys := make(map[string][]string)
for j, key := range sblock.block.Keys { for j, key := range sblock.block.Keys {
parsedKey, err := ParseAddress(key.Text)
if err != nil {
return nil, fmt.Errorf("parsing key: %v", err)
}
parsedKey = parsedKey.Normalize()
// a key can have multiple listener addresses if there are multiple // a key can have multiple listener addresses if there are multiple
// arguments to the 'bind' directive (although they will all have // arguments to the 'bind' directive (although they will all have
// the same port, since the port is defined by the key or is implicit // the same port, since the port is defined by the key or is implicit
// through automatic HTTPS) // through automatic HTTPS)
listeners, err := st.listenersForServerBlockAddress(sblock, parsedKey, options) addrs, err := st.listenerAddrsForServerBlockKey(sblock, key, options)
if err != nil { if err != nil {
return nil, fmt.Errorf("server block %d, key %d (%s): determining listener address: %v", i, j, key.Text, err) return nil, fmt.Errorf("server block %d, key %d (%s): determining listener address: %v", i, j, key, err)
} }
// associate this key with its protocols and each listener address served with them // associate this key with each listener address it is served on
kwpk := keyWithParsedKey{key, parsedKey} for _, addr := range addrs {
for addr, protocols := range listeners { addrToKeys[addr] = append(addrToKeys[addr], key)
protocolToKeyWithParsedKeys, ok := addrToProtocolToKeyWithParsedKeys[addr]
if !ok {
protocolToKeyWithParsedKeys = map[string][]keyWithParsedKey{}
addrToProtocolToKeyWithParsedKeys[addr] = protocolToKeyWithParsedKeys
}
// an empty protocol indicates the default, a nil or empty value in the ListenProtocols array
if len(protocols) == 0 {
protocols[""] = struct{}{}
}
for prot := range protocols {
protocolToKeyWithParsedKeys[prot] = append(
protocolToKeyWithParsedKeys[prot],
kwpk)
}
} }
} }
// make a slice of the map keys so we can iterate in sorted order // make a slice of the map keys so we can iterate in sorted order
addrs := make([]string, 0, len(addrToProtocolToKeyWithParsedKeys)) addrs := make([]string, 0, len(addrToKeys))
for addr := range addrToProtocolToKeyWithParsedKeys { for k := range addrToKeys {
addrs = append(addrs, addr) addrs = append(addrs, k)
} }
sort.Strings(addrs) sort.Strings(addrs)
@@ -144,132 +118,85 @@ func (st *ServerType) mapAddressToProtocolToServerBlocks(originalServerBlocks []
// server block are only the ones which use the address; but // server block are only the ones which use the address; but
// the contents (tokens) are of course the same // the contents (tokens) are of course the same
for _, addr := range addrs { for _, addr := range addrs {
protocolToKeyWithParsedKeys := addrToProtocolToKeyWithParsedKeys[addr] keys := addrToKeys[addr]
// parse keys so that we only have to do it once
prots := make([]string, 0, len(protocolToKeyWithParsedKeys)) parsedKeys := make([]Address, 0, len(keys))
for prot := range protocolToKeyWithParsedKeys { for _, key := range keys {
prots = append(prots, prot) addr, err := ParseAddress(key)
} if err != nil {
sort.Strings(prots) return nil, fmt.Errorf("parsing key '%s': %v", key, err)
protocolToServerBlocks, ok := addrToProtocolToServerBlocks[addr]
if !ok {
protocolToServerBlocks = map[string][]serverBlock{}
addrToProtocolToServerBlocks[addr] = protocolToServerBlocks
}
for _, prot := range prots {
keyWithParsedKeys := protocolToKeyWithParsedKeys[prot]
keys := make([]caddyfile.Token, len(keyWithParsedKeys))
parsedKeys := make([]Address, len(keyWithParsedKeys))
for k, keyWithParsedKey := range keyWithParsedKeys {
keys[k] = keyWithParsedKey.key
parsedKeys[k] = keyWithParsedKey.parsedKey
} }
parsedKeys = append(parsedKeys, addr.Normalize())
protocolToServerBlocks[prot] = append(protocolToServerBlocks[prot], serverBlock{
block: caddyfile.ServerBlock{
Keys: keys,
Segments: sblock.block.Segments,
},
pile: sblock.pile,
parsedKeys: parsedKeys,
})
} }
} sbmap[addr] = append(sbmap[addr], serverBlock{
} block: caddyfile.ServerBlock{
Keys: keys,
return addrToProtocolToServerBlocks, nil Segments: sblock.block.Segments,
} },
pile: sblock.pile,
// consolidateAddrMappings eliminates repetition of identical server blocks in a mapping of keys: parsedKeys,
// single listener addresses to protocols to lists of server blocks. Since multiple addresses
// may serve multiple protocols to identical sites (server block contents), this function turns
// a 1:many mapping into a many:many mapping. Server block contents (tokens) must be
// exactly identical so that reflect.DeepEqual returns true in order for the addresses to be combined.
// Identical entries are deleted from the addrToServerBlocks map. Essentially, each pairing (each
// association from multiple addresses to multiple server blocks; i.e. each element of
// the returned slice) becomes a server definition in the output JSON.
func (st *ServerType) consolidateAddrMappings(addrToProtocolToServerBlocks map[string]map[string][]serverBlock) []sbAddrAssociation {
sbaddrs := make([]sbAddrAssociation, 0, len(addrToProtocolToServerBlocks))
addrs := make([]string, 0, len(addrToProtocolToServerBlocks))
for addr := range addrToProtocolToServerBlocks {
addrs = append(addrs, addr)
}
sort.Strings(addrs)
for _, addr := range addrs {
protocolToServerBlocks := addrToProtocolToServerBlocks[addr]
prots := make([]string, 0, len(protocolToServerBlocks))
for prot := range protocolToServerBlocks {
prots = append(prots, prot)
}
sort.Strings(prots)
for _, prot := range prots {
serverBlocks := protocolToServerBlocks[prot]
// now find other addresses that map to identical
// server blocks and add them to our map of listener
// addresses and protocols, while removing them from
// the original map
listeners := map[string]map[string]struct{}{}
for otherAddr, otherProtocolToServerBlocks := range addrToProtocolToServerBlocks {
for otherProt, otherServerBlocks := range otherProtocolToServerBlocks {
if addr == otherAddr && prot == otherProt || reflect.DeepEqual(serverBlocks, otherServerBlocks) {
listener, ok := listeners[otherAddr]
if !ok {
listener = map[string]struct{}{}
listeners[otherAddr] = listener
}
listener[otherProt] = struct{}{}
delete(otherProtocolToServerBlocks, otherProt)
}
}
}
addresses := make([]string, 0, len(listeners))
for lnAddr := range listeners {
addresses = append(addresses, lnAddr)
}
sort.Strings(addresses)
addressesWithProtocols := make([]addressWithProtocols, 0, len(listeners))
for _, lnAddr := range addresses {
lnProts := listeners[lnAddr]
prots := make([]string, 0, len(lnProts))
for prot := range lnProts {
prots = append(prots, prot)
}
sort.Strings(prots)
addressesWithProtocols = append(addressesWithProtocols, addressWithProtocols{
address: lnAddr,
protocols: prots,
})
}
sbaddrs = append(sbaddrs, sbAddrAssociation{
addressesWithProtocols: addressesWithProtocols,
serverBlocks: serverBlocks,
}) })
} }
} }
return sbmap, nil
}
// consolidateAddrMappings eliminates repetition of identical server blocks in a mapping of
// single listener addresses to lists of server blocks. Since multiple addresses may serve
// identical sites (server block contents), this function turns a 1:many mapping into a
// many:many mapping. Server block contents (tokens) must be exactly identical so that
// reflect.DeepEqual returns true in order for the addresses to be combined. Identical
// entries are deleted from the addrToServerBlocks map. Essentially, each pairing (each
// association from multiple addresses to multiple server blocks; i.e. each element of
// the returned slice) becomes a server definition in the output JSON.
func (st *ServerType) consolidateAddrMappings(addrToServerBlocks map[string][]serverBlock) []sbAddrAssociation {
sbaddrs := make([]sbAddrAssociation, 0, len(addrToServerBlocks))
for addr, sblocks := range addrToServerBlocks {
// we start with knowing that at least this address
// maps to these server blocks
a := sbAddrAssociation{
addresses: []string{addr},
serverBlocks: sblocks,
}
// now find other addresses that map to identical
// server blocks and add them to our list of
// addresses, while removing them from the map
for otherAddr, otherSblocks := range addrToServerBlocks {
if addr == otherAddr {
continue
}
if reflect.DeepEqual(sblocks, otherSblocks) {
a.addresses = append(a.addresses, otherAddr)
delete(addrToServerBlocks, otherAddr)
}
}
sort.Strings(a.addresses)
sbaddrs = append(sbaddrs, a)
}
// sort them by their first address (we know there will always be at least one)
// to avoid problems with non-deterministic ordering (makes tests flaky)
sort.Slice(sbaddrs, func(i, j int) bool {
return sbaddrs[i].addresses[0] < sbaddrs[j].addresses[0]
})
return sbaddrs return sbaddrs
} }
// listenersForServerBlockAddress essentially converts the Caddyfile site addresses to a map from // listenerAddrsForServerBlockKey essentially converts the Caddyfile
// Caddy listener addresses and the protocols to serve them with to the parsed address for each server block. // site addresses to Caddy listener addresses for each server block.
func (st *ServerType) listenersForServerBlockAddress(sblock serverBlock, addr Address, func (st *ServerType) listenerAddrsForServerBlockKey(sblock serverBlock, key string,
options map[string]any, options map[string]any,
) (map[string]map[string]struct{}, error) { ) ([]string, error) {
addr, err := ParseAddress(key)
if err != nil {
return nil, fmt.Errorf("parsing key: %v", err)
}
addr = addr.Normalize()
switch addr.Scheme { switch addr.Scheme {
case "wss": case "wss":
return nil, fmt.Errorf("the scheme wss:// is only supported in browsers; use https:// instead") return nil, fmt.Errorf("the scheme wss:// is only supported in browsers; use https:// instead")
@@ -303,58 +230,55 @@ func (st *ServerType) listenersForServerBlockAddress(sblock serverBlock, addr Ad
// error if scheme and port combination violate convention // error if scheme and port combination violate convention
if (addr.Scheme == "http" && lnPort == httpsPort) || (addr.Scheme == "https" && lnPort == httpPort) { if (addr.Scheme == "http" && lnPort == httpsPort) || (addr.Scheme == "https" && lnPort == httpPort) {
return nil, fmt.Errorf("[%s] scheme and port violate convention", addr.String()) return nil, fmt.Errorf("[%s] scheme and port violate convention", key)
} }
// the bind directive specifies hosts (and potentially network), and the protocols to serve them with, but is optional // the bind directive specifies hosts (and potentially network), but is optional
lnCfgVals := make([]addressesWithProtocols, 0, len(sblock.pile["bind"])) lnHosts := make([]string, 0, len(sblock.pile["bind"]))
for _, cfgVal := range sblock.pile["bind"] { for _, cfgVal := range sblock.pile["bind"] {
if val, ok := cfgVal.Value.(addressesWithProtocols); ok { lnHosts = append(lnHosts, cfgVal.Value.([]string)...)
lnCfgVals = append(lnCfgVals, val)
}
} }
if len(lnCfgVals) == 0 { if len(lnHosts) == 0 {
if defaultBindValues, ok := options["default_bind"].([]ConfigValue); ok { if defaultBind, ok := options["default_bind"].([]string); ok {
for _, defaultBindValue := range defaultBindValues { lnHosts = defaultBind
lnCfgVals = append(lnCfgVals, defaultBindValue.Value.(addressesWithProtocols))
}
} else { } else {
lnCfgVals = []addressesWithProtocols{{ lnHosts = []string{""}
addresses: []string{""},
protocols: nil,
}}
} }
} }
// use a map to prevent duplication // use a map to prevent duplication
listeners := map[string]map[string]struct{}{} listeners := make(map[string]struct{})
for _, lnCfgVal := range lnCfgVals { for _, lnHost := range lnHosts {
for _, lnAddr := range lnCfgVal.addresses { // normally we would simply append the port,
lnNetw, lnHost, _, err := caddy.SplitNetworkAddress(lnAddr) // but if lnHost is IPv6, we need to ensure it
if err != nil { // is enclosed in [ ]; net.JoinHostPort does
return nil, fmt.Errorf("splitting listener address: %v", err) // this for us, but lnHost might also have a
} // network type in front (e.g. "tcp/") leading
networkAddr, err := caddy.ParseNetworkAddress(caddy.JoinNetworkAddress(lnNetw, lnHost, lnPort)) // to "[tcp/::1]" which causes parsing failures
if err != nil { // later; what we need is "tcp/[::1]", so we have
return nil, fmt.Errorf("parsing network address: %v", err) // to split the network and host, then re-combine
} network, host, ok := strings.Cut(lnHost, "/")
if _, ok := listeners[addr.String()]; !ok { if !ok {
listeners[networkAddr.String()] = map[string]struct{}{} host = network
} network = ""
for _, protocol := range lnCfgVal.protocols {
listeners[networkAddr.String()][protocol] = struct{}{}
}
} }
host = strings.Trim(host, "[]") // IPv6
networkAddr := caddy.JoinNetworkAddress(network, host, lnPort)
addr, err := caddy.ParseNetworkAddress(networkAddr)
if err != nil {
return nil, fmt.Errorf("parsing network address: %v", err)
}
listeners[addr.String()] = struct{}{}
} }
return listeners, nil // now turn map into list
} listenersList := make([]string, 0, len(listeners))
for lnStr := range listeners {
listenersList = append(listenersList, lnStr)
}
sort.Strings(listenersList)
// addressesWithProtocols associates a list of listen addresses return listenersList, nil
// with a list of protocols to serve them with
type addressesWithProtocols struct {
addresses []string
protocols []string
} }
// Address represents a site address. It contains // Address represents a site address. It contains
File diff suppressed because it is too large Load Diff
+2 -18
View File
@@ -25,12 +25,11 @@ func TestLogDirectiveSyntax(t *testing.T) {
{ {
input: `:8080 { input: `:8080 {
log { log {
core mock
output file foo.log output file foo.log
} }
} }
`, `,
output: `{"logging":{"logs":{"default":{"exclude":["http.log.access.log0"]},"log0":{"writer":{"filename":"foo.log","output":"file"},"core":{"module":"mock"},"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":{"writer":{"filename":"foo.log","output":"file"},"include":["http.log.access.log0"]}}},"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"logs":{"default_logger_name":"log0"}}}}}}`,
expectError: false, expectError: false,
}, },
{ {
@@ -54,26 +53,11 @@ func TestLogDirectiveSyntax(t *testing.T) {
{ {
input: `:8080 { input: `:8080 {
log name-override { log name-override {
core mock
output file foo.log output file foo.log
} }
} }
`, `,
output: `{"logging":{"logs":{"default":{"exclude":["http.log.access.name-override"]},"name-override":{"writer":{"filename":"foo.log","output":"file"},"core":{"module":"mock"},"include":["http.log.access.name-override"]}}},"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"logs":{"default_logger_name":"name-override"}}}}}}`, output: `{"logging":{"logs":{"default":{"exclude":["http.log.access.name-override"]},"name-override":{"writer":{"filename":"foo.log","output":"file"},"include":["http.log.access.name-override"]}}},"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"logs":{"default_logger_name":"name-override"}}}}}}`,
expectError: false,
},
{
input: `:8080 {
log {
sampling {
interval 2
first 3
thereafter 4
}
}
}
`,
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, expectError: false,
}, },
} { } {
+47 -118
View File
@@ -16,9 +16,7 @@ package httpcaddyfile
import ( import (
"encoding/json" "encoding/json"
"maps"
"net" "net"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -29,33 +27,22 @@ import (
"github.com/caddyserver/caddy/v2/modules/caddyhttp" "github.com/caddyserver/caddy/v2/modules/caddyhttp"
) )
// defaultDirectiveOrder specifies the default order // directiveOrder specifies the order
// to apply directives in HTTP routes. This must only // to apply directives in HTTP routes.
// consist of directives that are included in Caddy's
// standard distribution.
// //
// e.g. The 'root' directive goes near the start in // The root directive goes first in case rewrites or
// case rewrites or redirects depend on existence of // redirects depend on existence of files, i.e. the
// files, i.e. the file matcher, which must know the // file matcher, which must know the root first.
// root first.
// //
// e.g. The 'header' directive goes before 'redir' so // The header directive goes second so that headers
// that headers can be manipulated before doing redirects. // can be manipulated before doing redirects.
// var directiveOrder = []string{
// e.g. The 'respond' directive is near the end because it
// writes a response and terminates the middleware chain.
var defaultDirectiveOrder = []string{
"tracing", "tracing",
// set variables that may be used by other directives
"map", "map",
"vars", "vars",
"fs",
"root", "root",
"log_append", "skip_log",
"skip_log", // TODO: deprecated, renamed to log_skip
"log_skip",
"log_name",
"header", "header",
"copy_response_headers", // only in reverse_proxy's handle_response "copy_response_headers", // only in reverse_proxy's handle_response
@@ -70,13 +57,11 @@ var defaultDirectiveOrder = []string{
"try_files", "try_files",
// middleware handlers; some wrap responses // middleware handlers; some wrap responses
"basicauth", // TODO: deprecated, renamed to basic_auth "basicauth",
"basic_auth",
"forward_auth", "forward_auth",
"request_header", "request_header",
"encode", "encode",
"push", "push",
"intercept",
"templates", "templates",
// special routing & dispatching directives // special routing & dispatching directives
@@ -97,10 +82,16 @@ var defaultDirectiveOrder = []string{
"acme_server", "acme_server",
} }
// directiveOrder specifies the order to apply directives // directiveIsOrdered returns true if dir is
// in HTTP routes, after being modified by either the // a known, ordered (sorted) directive.
// plugins or by the user via the "order" global option. func directiveIsOrdered(dir string) bool {
var directiveOrder = defaultDirectiveOrder for _, d := range directiveOrder {
if d == dir {
return true
}
}
return false
}
// RegisterDirective registers a unique directive dir with an // RegisterDirective registers a unique directive dir with an
// associated unmarshaling (setup) function. When directive dir // associated unmarshaling (setup) function. When directive dir
@@ -137,55 +128,6 @@ func RegisterHandlerDirective(dir string, setupFunc UnmarshalHandlerFunc) {
}) })
} }
// RegisterDirectiveOrder registers the default order for a
// directive from a plugin.
//
// This is useful when a plugin has a well-understood place
// it should run in the middleware pipeline, and it allows
// users to avoid having to define the order themselves.
//
// The directive dir may be placed in the position relative
// to ('before' or 'after') a directive included in Caddy's
// standard distribution. It cannot be relative to another
// plugin's directive.
//
// EXPERIMENTAL: This API may change or be removed.
func RegisterDirectiveOrder(dir string, position Positional, standardDir string) {
// check if directive was already ordered
if slices.Contains(directiveOrder, dir) {
panic("directive '" + dir + "' already ordered")
}
if position != Before && position != After {
panic("the 2nd argument must be either 'before' or 'after', got '" + position + "'")
}
// check if directive exists in standard distribution, since
// we can't allow plugins to depend on one another; we can't
// guarantee the order that plugins are loaded in.
foundStandardDir := slices.Contains(defaultDirectiveOrder, standardDir)
if !foundStandardDir {
panic("the 3rd argument '" + standardDir + "' must be a directive that exists in the standard distribution of Caddy")
}
// insert directive into proper position
newOrder := directiveOrder
for i, d := range newOrder {
if d != standardDir {
continue
}
switch position {
case Before:
newOrder = append(newOrder[:i], append([]string{dir}, newOrder[i:]...)...)
case After:
newOrder = append(newOrder[:i+1], append([]string{dir}, newOrder[i+1:]...)...)
case First, Last:
}
break
}
directiveOrder = newOrder
}
// RegisterGlobalOption registers a unique global option opt with // RegisterGlobalOption registers a unique global option opt with
// an associated unmarshaling (setup) function. When the global // an associated unmarshaling (setup) function. When the global
// option opt is encountered in a Caddyfile, setupFunc will be // option opt is encountered in a Caddyfile, setupFunc will be
@@ -328,6 +270,12 @@ func (h Helper) GroupRoutes(vals []ConfigValue) {
} }
} }
// NewBindAddresses returns config values relevant to adding
// listener bind addresses to the config.
func (h Helper) NewBindAddresses(addrs []string) []ConfigValue {
return []ConfigValue{{Class: "bind", Value: addrs}}
}
// WithDispenser returns a new instance based on d. All others Helper // WithDispenser returns a new instance based on d. All others Helper
// fields are copied, so typically maps are shared with this new instance. // fields are copied, so typically maps are shared with this new instance.
func (h Helper) WithDispenser(d *caddyfile.Dispenser) Helper { func (h Helper) WithDispenser(d *caddyfile.Dispenser) Helper {
@@ -368,7 +316,9 @@ func parseSegmentAsConfig(h Helper) ([]ConfigValue, error) {
// copy existing matcher definitions so we can augment // copy existing matcher definitions so we can augment
// new ones that are defined only in this scope // new ones that are defined only in this scope
matcherDefs := make(map[string]caddy.ModuleMap, len(h.matcherDefs)) matcherDefs := make(map[string]caddy.ModuleMap, len(h.matcherDefs))
maps.Copy(matcherDefs, h.matcherDefs) for key, val := range h.matcherDefs {
matcherDefs[key] = val
}
// find and extract any embedded matcher definitions in this scope // find and extract any embedded matcher definitions in this scope
for i := 0; i < len(segments); i++ { for i := 0; i < len(segments); i++ {
@@ -484,29 +434,12 @@ func sortRoutes(routes []ConfigValue) {
// we can only confidently compare path lengths if both // we can only confidently compare path lengths if both
// directives have a single path to match (issue #5037) // directives have a single path to match (issue #5037)
if iPathLen > 0 && jPathLen > 0 { if iPathLen > 0 && jPathLen > 0 {
// trim the trailing wildcard if there is one
iPathTrimmed := strings.TrimSuffix(iPM[0], "*")
jPathTrimmed := strings.TrimSuffix(jPM[0], "*")
// if both paths are the same except for a trailing wildcard, // if both paths are the same except for a trailing wildcard,
// sort by the shorter path first (which is more specific) // sort by the shorter path first (which is more specific)
if iPathTrimmed == jPathTrimmed { if strings.TrimSuffix(iPM[0], "*") == strings.TrimSuffix(jPM[0], "*") {
return iPathLen < jPathLen return iPathLen < jPathLen
} }
// we use the trimmed length to compare the paths
// https://github.com/caddyserver/caddy/issues/7012#issuecomment-2870142195
// credit to https://github.com/Hellio404
// for sorts with many items, mixing matchers w/ and w/o wildcards will confuse the sort and result in incorrect orders
iPathLen = len(iPathTrimmed)
jPathLen = len(jPathTrimmed)
// if both paths have the same length, sort lexically
// https://github.com/caddyserver/caddy/pull/7015#issuecomment-2871993588
if iPathLen == jPathLen {
return iPathTrimmed < jPathTrimmed
}
// sort most-specific (longest) path first // sort most-specific (longest) path first
return iPathLen > jPathLen return iPathLen > jPathLen
} }
@@ -534,9 +467,9 @@ func sortRoutes(routes []ConfigValue) {
// a "pile" of config values, keyed by class name, // a "pile" of config values, keyed by class name,
// as well as its parsed keys for convenience. // as well as its parsed keys for convenience.
type serverBlock struct { type serverBlock struct {
block caddyfile.ServerBlock block caddyfile.ServerBlock
pile map[string][]ConfigValue // config values obtained from directives pile map[string][]ConfigValue // config values obtained from directives
parsedKeys []Address keys []Address
} }
// hostsFromKeys returns a list of all the non-empty hostnames found in // hostsFromKeys returns a list of all the non-empty hostnames found in
@@ -553,7 +486,7 @@ type serverBlock struct {
func (sb serverBlock) hostsFromKeys(loggerMode bool) []string { func (sb serverBlock) hostsFromKeys(loggerMode bool) []string {
// ensure each entry in our list is unique // ensure each entry in our list is unique
hostMap := make(map[string]struct{}) hostMap := make(map[string]struct{})
for _, addr := range sb.parsedKeys { for _, addr := range sb.keys {
if addr.Host == "" { if addr.Host == "" {
if !loggerMode { if !loggerMode {
// server block contains a key like ":443", i.e. the host portion // server block contains a key like ":443", i.e. the host portion
@@ -585,7 +518,7 @@ func (sb serverBlock) hostsFromKeys(loggerMode bool) []string {
func (sb serverBlock) hostsFromKeysNotHTTP(httpPort string) []string { func (sb serverBlock) hostsFromKeysNotHTTP(httpPort string) []string {
// ensure each entry in our list is unique // ensure each entry in our list is unique
hostMap := make(map[string]struct{}) hostMap := make(map[string]struct{})
for _, addr := range sb.parsedKeys { for _, addr := range sb.keys {
if addr.Host == "" { if addr.Host == "" {
continue continue
} }
@@ -606,29 +539,25 @@ func (sb serverBlock) hostsFromKeysNotHTTP(httpPort string) []string {
// hasHostCatchAllKey returns true if sb has a key that // hasHostCatchAllKey returns true if sb has a key that
// omits a host portion, i.e. it "catches all" hosts. // omits a host portion, i.e. it "catches all" hosts.
func (sb serverBlock) hasHostCatchAllKey() bool { func (sb serverBlock) hasHostCatchAllKey() bool {
return slices.ContainsFunc(sb.parsedKeys, func(addr Address) bool { for _, addr := range sb.keys {
return addr.Host == "" if addr.Host == "" {
}) return true
}
}
return false
} }
// isAllHTTP returns true if all sb keys explicitly specify // isAllHTTP returns true if all sb keys explicitly specify
// the http:// scheme // the http:// scheme
func (sb serverBlock) isAllHTTP() bool { func (sb serverBlock) isAllHTTP() bool {
return !slices.ContainsFunc(sb.parsedKeys, func(addr Address) bool { for _, addr := range sb.keys {
return addr.Scheme != "http" if addr.Scheme != "http" {
}) return false
}
}
return true
} }
// Positional are the supported modes for ordering directives.
type Positional string
const (
Before Positional = "before"
After Positional = "after"
First Positional = "first"
Last Positional = "last"
)
type ( type (
// UnmarshalFunc is a function which can unmarshal Caddyfile // UnmarshalFunc is a function which can unmarshal Caddyfile
// tokens into zero or more config values using a Helper type. // tokens into zero or more config values using a Helper type.
+4 -7
View File
@@ -31,23 +31,20 @@ func TestHostsFromKeys(t *testing.T) {
[]Address{ []Address{
{Original: ":2015", Port: "2015"}, {Original: ":2015", Port: "2015"},
}, },
[]string{}, []string{}, []string{},
[]string{},
}, },
{ {
[]Address{ []Address{
{Original: ":443", Port: "443"}, {Original: ":443", Port: "443"},
}, },
[]string{}, []string{}, []string{},
[]string{},
}, },
{ {
[]Address{ []Address{
{Original: "foo", Host: "foo"}, {Original: "foo", Host: "foo"},
{Original: ":2015", Port: "2015"}, {Original: ":2015", Port: "2015"},
}, },
[]string{}, []string{}, []string{"foo"},
[]string{"foo"},
}, },
{ {
[]Address{ []Address{
@@ -78,7 +75,7 @@ func TestHostsFromKeys(t *testing.T) {
[]string{"example.com:2015"}, []string{"example.com:2015"},
}, },
} { } {
sb := serverBlock{parsedKeys: tc.keys} sb := serverBlock{keys: tc.keys}
// test in normal mode // test in normal mode
actual := sb.hostsFromKeys(false) actual := sb.hostsFromKeys(false)
+127 -314
View File
@@ -15,17 +15,16 @@
package httpcaddyfile package httpcaddyfile
import ( import (
"cmp"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net" "net"
"reflect" "reflect"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/exp/slices"
"github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig" "github.com/caddyserver/caddy/v2/caddyconfig"
@@ -66,11 +65,8 @@ func (st ServerType) Setup(
originalServerBlocks := make([]serverBlock, 0, len(inputServerBlocks)) originalServerBlocks := make([]serverBlock, 0, len(inputServerBlocks))
for _, sblock := range inputServerBlocks { for _, sblock := range inputServerBlocks {
for j, k := range sblock.Keys { for j, k := range sblock.Keys {
if j == 0 && strings.HasPrefix(k.Text, "@") { if j == 0 && strings.HasPrefix(k, "@") {
return nil, warnings, fmt.Errorf("%s:%d: cannot define a matcher outside of a site block: '%s'", k.File, k.Line, k.Text) return nil, warnings, fmt.Errorf("cannot define a matcher outside of a site block: '%s'", k)
}
if _, ok := registeredDirectives[k.Text]; ok {
return nil, warnings, fmt.Errorf("%s:%d: parsed '%s' as a site address, but it is a known directive; directives must appear in a site block", k.File, k.Line, k.Text)
} }
} }
originalServerBlocks = append(originalServerBlocks, serverBlock{ originalServerBlocks = append(originalServerBlocks, serverBlock{
@@ -172,7 +168,7 @@ func (st ServerType) Setup(
} }
// map // map
sbmap, err := st.mapAddressToProtocolToServerBlocks(originalServerBlocks, options) sbmap, err := st.mapAddressToServerBlocks(originalServerBlocks, options)
if err != nil { if err != nil {
return nil, warnings, err return nil, warnings, err
} }
@@ -187,25 +183,12 @@ func (st ServerType) Setup(
return nil, warnings, err return nil, warnings, err
} }
// hoist the metrics config from per-server to global
metrics, _ := options["metrics"].(*caddyhttp.Metrics)
for _, s := range servers {
if s.Metrics != nil {
metrics = cmp.Or(metrics, &caddyhttp.Metrics{})
metrics = &caddyhttp.Metrics{
PerHost: metrics.PerHost || s.Metrics.PerHost,
}
s.Metrics = nil // we don't need it anymore
}
}
// now that each server is configured, make the HTTP app // now that each server is configured, make the HTTP app
httpApp := caddyhttp.App{ httpApp := caddyhttp.App{
HTTPPort: tryInt(options["http_port"], &warnings), HTTPPort: tryInt(options["http_port"], &warnings),
HTTPSPort: tryInt(options["https_port"], &warnings), HTTPSPort: tryInt(options["https_port"], &warnings),
GracePeriod: tryDuration(options["grace_period"], &warnings), GracePeriod: tryDuration(options["grace_period"], &warnings),
ShutdownDelay: tryDuration(options["shutdown_delay"], &warnings), ShutdownDelay: tryDuration(options["shutdown_delay"], &warnings),
Metrics: metrics,
Servers: servers, Servers: servers,
} }
@@ -288,12 +271,6 @@ func (st ServerType) Setup(
if !reflect.DeepEqual(pkiApp, &caddypki.PKI{CAs: make(map[string]*caddypki.CA)}) { if !reflect.DeepEqual(pkiApp, &caddypki.PKI{CAs: make(map[string]*caddypki.CA)}) {
cfg.AppsRaw["pki"] = caddyconfig.JSON(pkiApp, &warnings) cfg.AppsRaw["pki"] = caddyconfig.JSON(pkiApp, &warnings)
} }
if filesystems, ok := options["filesystem"].(caddy.Module); ok {
cfg.AppsRaw["caddy.filesystems"] = caddyconfig.JSON(
filesystems,
&warnings)
}
if storageCvtr, ok := options["storage"].(caddy.StorageConverter); ok { if storageCvtr, ok := options["storage"].(caddy.StorageConverter); ok {
cfg.StorageRaw = caddyconfig.JSONModuleObject(storageCvtr, cfg.StorageRaw = caddyconfig.JSONModuleObject(storageCvtr,
"module", "module",
@@ -303,6 +280,7 @@ func (st ServerType) Setup(
if adminConfig, ok := options["admin"].(*caddy.AdminConfig); ok && adminConfig != nil { if adminConfig, ok := options["admin"].(*caddy.AdminConfig); ok && adminConfig != nil {
cfg.Admin = adminConfig cfg.Admin = adminConfig
} }
if pc, ok := options["persist_config"].(string); ok && pc == "off" { if pc, ok := options["persist_config"].(string); ok && pc == "off" {
if cfg.Admin == nil { if cfg.Admin == nil {
cfg.Admin = new(caddy.AdminConfig) cfg.Admin = new(caddy.AdminConfig)
@@ -350,7 +328,7 @@ func (st ServerType) Setup(
// avoid duplicates by sorting + compacting // avoid duplicates by sorting + compacting
sort.Strings(defaultLog.Exclude) sort.Strings(defaultLog.Exclude)
defaultLog.Exclude = slices.Compact(defaultLog.Exclude) defaultLog.Exclude = slices.Compact[[]string, string](defaultLog.Exclude)
} }
} }
// we may have not actually added anything, so remove if empty // we may have not actually added anything, so remove if empty
@@ -416,20 +394,6 @@ func (ServerType) evaluateGlobalOptionsBlock(serverBlocks []serverBlock, options
options[opt] = append(existingOpts, logOpts...) options[opt] = append(existingOpts, logOpts...)
continue continue
} }
// Also fold multiple "default_bind" options together into an
// array so that server blocks can have multiple binds by default.
if opt == "default_bind" {
existingOpts, ok := options[opt].([]ConfigValue)
if !ok {
existingOpts = []ConfigValue{}
}
defaultBindOpts, ok := val.([]ConfigValue)
if !ok {
return nil, fmt.Errorf("unexpected type from 'default_bind' global options: %T", val)
}
options[opt] = append(existingOpts, defaultBindOpts...)
continue
}
options[opt] = val options[opt] = val
} }
@@ -521,7 +485,7 @@ func (ServerType) extractNamedRoutes(
route.HandlersRaw = []json.RawMessage{caddyconfig.JSONModuleObject(handler, "handler", subroute.CaddyModule().ID.Name(), h.warnings)} route.HandlersRaw = []json.RawMessage{caddyconfig.JSONModuleObject(handler, "handler", subroute.CaddyModule().ID.Name(), h.warnings)}
} }
namedRoutes[sb.block.GetKeysText()[0]] = &route namedRoutes[sb.block.Keys[0]] = &route
} }
options["named_routes"] = namedRoutes options["named_routes"] = namedRoutes
@@ -548,8 +512,8 @@ func (st *ServerType) serversFromPairings(
if hsp, ok := options["https_port"].(int); ok { if hsp, ok := options["https_port"].(int); ok {
httpsPort = strconv.Itoa(hsp) httpsPort = strconv.Itoa(hsp)
} }
autoHTTPS := []string{} autoHTTPS := "on"
if ah, ok := options["auto_https"].([]string); ok { if ah, ok := options["auto_https"].(string); ok {
autoHTTPS = ah autoHTTPS = ah
} }
@@ -559,79 +523,33 @@ func (st *ServerType) serversFromPairings(
// address), otherwise their routes will improperly be added // address), otherwise their routes will improperly be added
// to the same server (see issue #4635) // to the same server (see issue #4635)
for j, sblock1 := range p.serverBlocks { for j, sblock1 := range p.serverBlocks {
for _, key := range sblock1.block.GetKeysText() { for _, key := range sblock1.block.Keys {
for k, sblock2 := range p.serverBlocks { for k, sblock2 := range p.serverBlocks {
if k == j { if k == j {
continue continue
} }
if slices.Contains(sblock2.block.GetKeysText(), key) { if sliceContains(sblock2.block.Keys, key) {
return nil, fmt.Errorf("ambiguous site definition: %s", key) return nil, fmt.Errorf("ambiguous site definition: %s", key)
} }
} }
} }
} }
var (
addresses []string
protocols [][]string
)
for _, addressWithProtocols := range p.addressesWithProtocols {
addresses = append(addresses, addressWithProtocols.address)
protocols = append(protocols, addressWithProtocols.protocols)
}
srv := &caddyhttp.Server{ srv := &caddyhttp.Server{
Listen: addresses, Listen: p.addresses,
ListenProtocols: protocols,
}
// remove srv.ListenProtocols[j] if it only contains the default protocols
for j, lnProtocols := range srv.ListenProtocols {
srv.ListenProtocols[j] = nil
for _, lnProtocol := range lnProtocols {
if lnProtocol != "" {
srv.ListenProtocols[j] = lnProtocols
break
}
}
}
// remove srv.ListenProtocols if it only contains the default protocols for all listen addresses
listenProtocols := srv.ListenProtocols
srv.ListenProtocols = nil
for _, lnProtocols := range listenProtocols {
if lnProtocols != nil {
srv.ListenProtocols = listenProtocols
break
}
} }
// handle the auto_https global option // handle the auto_https global option
for _, val := range autoHTTPS { if autoHTTPS != "on" {
switch val { srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
switch autoHTTPS {
case "off": case "off":
if srv.AutoHTTPS == nil {
srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
}
srv.AutoHTTPS.Disabled = true srv.AutoHTTPS.Disabled = true
case "disable_redirects": case "disable_redirects":
if srv.AutoHTTPS == nil {
srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
}
srv.AutoHTTPS.DisableRedir = true srv.AutoHTTPS.DisableRedir = true
case "disable_certs": case "disable_certs":
if srv.AutoHTTPS == nil {
srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
}
srv.AutoHTTPS.DisableCerts = true srv.AutoHTTPS.DisableCerts = true
case "ignore_loaded_certs": case "ignore_loaded_certs":
if srv.AutoHTTPS == nil {
srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
}
srv.AutoHTTPS.IgnoreLoadedCerts = true srv.AutoHTTPS.IgnoreLoadedCerts = true
} }
} }
@@ -640,7 +558,7 @@ func (st *ServerType) serversFromPairings(
// See ParseAddress() where parsing should later reject paths // See ParseAddress() where parsing should later reject paths
// See https://github.com/caddyserver/caddy/pull/4728 for a full explanation // See https://github.com/caddyserver/caddy/pull/4728 for a full explanation
for _, sblock := range p.serverBlocks { for _, sblock := range p.serverBlocks {
for _, addr := range sblock.parsedKeys { for _, addr := range sblock.keys {
if addr.Path != "" { if addr.Path != "" {
caddy.Log().Named("caddyfile").Warn("Using a path in a site address is deprecated; please use the 'handle' directive instead", zap.String("address", addr.String())) caddy.Log().Named("caddyfile").Warn("Using a path in a site address is deprecated; please use the 'handle' directive instead", zap.String("address", addr.String()))
} }
@@ -658,7 +576,7 @@ func (st *ServerType) serversFromPairings(
var iLongestPath, jLongestPath string var iLongestPath, jLongestPath string
var iLongestHost, jLongestHost string var iLongestHost, jLongestHost string
var iWildcardHost, jWildcardHost bool var iWildcardHost, jWildcardHost bool
for _, addr := range p.serverBlocks[i].parsedKeys { for _, addr := range p.serverBlocks[i].keys {
if strings.Contains(addr.Host, "*") || addr.Host == "" { if strings.Contains(addr.Host, "*") || addr.Host == "" {
iWildcardHost = true iWildcardHost = true
} }
@@ -669,7 +587,7 @@ func (st *ServerType) serversFromPairings(
iLongestPath = addr.Path iLongestPath = addr.Path
} }
} }
for _, addr := range p.serverBlocks[j].parsedKeys { for _, addr := range p.serverBlocks[j].keys {
if strings.Contains(addr.Host, "*") || addr.Host == "" { if strings.Contains(addr.Host, "*") || addr.Host == "" {
jWildcardHost = true jWildcardHost = true
} }
@@ -701,7 +619,7 @@ func (st *ServerType) serversFromPairings(
}) })
var hasCatchAllTLSConnPolicy, addressQualifiesForTLS bool var hasCatchAllTLSConnPolicy, addressQualifiesForTLS bool
autoHTTPSWillAddConnPolicy := srv.AutoHTTPS == nil || !srv.AutoHTTPS.Disabled autoHTTPSWillAddConnPolicy := autoHTTPS != "off"
// if needed, the ServerLogConfig is initialized beforehand so // if needed, the ServerLogConfig is initialized beforehand so
// that all server blocks can populate it with data, even when not // that all server blocks can populate it with data, even when not
@@ -747,14 +665,6 @@ func (st *ServerType) serversFromPairings(
} }
} }
// collect hosts that are forced to be automated
forceAutomatedNames := make(map[string]struct{})
if _, ok := sblock.pile["tls.force_automate"]; ok {
for _, host := range hosts {
forceAutomatedNames[host] = struct{}{}
}
}
// tls: connection policies // tls: connection policies
if cpVals, ok := sblock.pile["tls.connection_policy"]; ok { if cpVals, ok := sblock.pile["tls.connection_policy"]; ok {
// tls connection policies // tls connection policies
@@ -785,21 +695,15 @@ func (st *ServerType) serversFromPairings(
cp.FallbackSNI = fallbackSNI cp.FallbackSNI = fallbackSNI
} }
// only append this policy if it actually changes something, // only append this policy if it actually changes something
// or if the configuration explicitly automates certs for if !cp.SettingsEmpty() {
// these names (this is necessary to hoist a connection policy
// above one that may manually load a wildcard cert that would
// otherwise clobber the automated one; the code that appends
// policies that manually load certs comes later, so they're
// lower in the list)
if !cp.SettingsEmpty() || mapContains(forceAutomatedNames, hosts) {
srv.TLSConnPolicies = append(srv.TLSConnPolicies, cp) srv.TLSConnPolicies = append(srv.TLSConnPolicies, cp)
hasCatchAllTLSConnPolicy = len(hosts) == 0 hasCatchAllTLSConnPolicy = len(hosts) == 0
} }
} }
} }
for _, addr := range sblock.parsedKeys { for _, addr := range sblock.keys {
// if server only uses HTTP port, auto-HTTPS will not apply // if server only uses HTTP port, auto-HTTPS will not apply
if listenersUseAnyPortOtherThan(srv.Listen, httpPort) { if listenersUseAnyPortOtherThan(srv.Listen, httpPort) {
// exclude any hosts that were defined explicitly with "http://" // exclude any hosts that were defined explicitly with "http://"
@@ -808,7 +712,7 @@ func (st *ServerType) serversFromPairings(
if srv.AutoHTTPS == nil { if srv.AutoHTTPS == nil {
srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig) srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
} }
if !slices.Contains(srv.AutoHTTPS.Skip, addr.Host) { if !sliceContains(srv.AutoHTTPS.Skip, addr.Host) {
srv.AutoHTTPS.Skip = append(srv.AutoHTTPS.Skip, addr.Host) srv.AutoHTTPS.Skip = append(srv.AutoHTTPS.Skip, addr.Host)
} }
} }
@@ -822,7 +726,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 // 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"] createdTLSConnPolicies, ok := sblock.pile["tls.connection_policy"]
hasTLSEnabled := (ok && len(createdTLSConnPolicies) > 0) || hasTLSEnabled := (ok && len(createdTLSConnPolicies) > 0) ||
(addr.Host != "" && srv.AutoHTTPS != nil && !slices.Contains(srv.AutoHTTPS.Skip, addr.Host)) (addr.Host != "" && srv.AutoHTTPS != nil && !sliceContains(srv.AutoHTTPS.Skip, addr.Host))
// we'll need to remember if the address qualifies for auto-HTTPS, so we // we'll need to remember if the address qualifies for auto-HTTPS, so we
// can add a TLS conn policy if necessary // can add a TLS conn policy if necessary
@@ -830,7 +734,6 @@ func (st *ServerType) serversFromPairings(
(addr.Scheme != "http" && addr.Port != httpPort && hasTLSEnabled) { (addr.Scheme != "http" && addr.Port != httpPort && hasTLSEnabled) {
addressQualifiesForTLS = true addressQualifiesForTLS = true
} }
// predict whether auto-HTTPS will add the conn policy for us; if so, we // predict whether auto-HTTPS will add the conn policy for us; if so, we
// may not need to add one for this server // may not need to add one for this server
autoHTTPSWillAddConnPolicy = autoHTTPSWillAddConnPolicy && autoHTTPSWillAddConnPolicy = autoHTTPSWillAddConnPolicy &&
@@ -866,19 +769,10 @@ func (st *ServerType) serversFromPairings(
if srv.Errors == nil { if srv.Errors == nil {
srv.Errors = new(caddyhttp.HTTPErrorConfig) srv.Errors = new(caddyhttp.HTTPErrorConfig)
} }
sort.SliceStable(errorSubrouteVals, func(i, j int) bool {
sri, srj := errorSubrouteVals[i].Value.(*caddyhttp.Subroute), errorSubrouteVals[j].Value.(*caddyhttp.Subroute)
if len(sri.Routes[0].MatcherSetsRaw) == 0 && len(srj.Routes[0].MatcherSetsRaw) != 0 {
return false
}
return true
})
errorsSubroute := &caddyhttp.Subroute{}
for _, val := range errorSubrouteVals { for _, val := range errorSubrouteVals {
sr := val.Value.(*caddyhttp.Subroute) sr := val.Value.(*caddyhttp.Subroute)
errorsSubroute.Routes = append(errorsSubroute.Routes, sr.Routes...) srv.Errors.Routes = appendSubrouteToRouteList(srv.Errors.Routes, sr, matcherSetsEnc, p, warnings)
} }
srv.Errors.Routes = appendSubrouteToRouteList(srv.Errors.Routes, errorsSubroute, matcherSetsEnc, p, warnings)
} }
// add log associations // add log associations
@@ -886,15 +780,6 @@ func (st *ServerType) serversFromPairings(
sblockLogHosts := sblock.hostsFromKeys(true) sblockLogHosts := sblock.hostsFromKeys(true)
for _, cval := range sblock.pile["custom_log"] { for _, cval := range sblock.pile["custom_log"] {
ncl := cval.Value.(namedCustomLog) ncl := cval.Value.(namedCustomLog)
// if `no_hostname` is set, then this logger will not
// be associated with any of the site block's hostnames,
// and only be usable via the `log_name` directive
// or the `access_logger_names` variable
if ncl.noHostname {
continue
}
if sblock.hasHostCatchAllKey() && len(ncl.hostnames) == 0 { if sblock.hasHostCatchAllKey() && len(ncl.hostnames) == 0 {
// all requests for hosts not able to be listed should use // all requests for hosts not able to be listed should use
// this log because it's a catch-all-hosts server block // this log because it's a catch-all-hosts server block
@@ -903,22 +788,22 @@ func (st *ServerType) serversFromPairings(
// if the logger overrides the hostnames, map that to the logger name // if the logger overrides the hostnames, map that to the logger name
for _, h := range ncl.hostnames { for _, h := range ncl.hostnames {
if srv.Logs.LoggerNames == nil { if srv.Logs.LoggerNames == nil {
srv.Logs.LoggerNames = make(map[string]caddyhttp.StringArray) srv.Logs.LoggerNames = make(map[string]string)
} }
srv.Logs.LoggerNames[h] = append(srv.Logs.LoggerNames[h], ncl.name) srv.Logs.LoggerNames[h] = ncl.name
} }
} else { } else {
// otherwise, map each host to the logger name // otherwise, map each host to the logger name
for _, h := range sblockLogHosts { for _, h := range sblockLogHosts {
if srv.Logs.LoggerNames == nil {
srv.Logs.LoggerNames = make(map[string]string)
}
// strip the port from the host, if any // strip the port from the host, if any
host, _, err := net.SplitHostPort(h) host, _, err := net.SplitHostPort(h)
if err != nil { if err != nil {
host = h host = h
} }
if srv.Logs.LoggerNames == nil { srv.Logs.LoggerNames[host] = ncl.name
srv.Logs.LoggerNames = make(map[string]caddyhttp.StringArray)
}
srv.Logs.LoggerNames[host] = append(srv.Logs.LoggerNames[host], ncl.name)
} }
} }
} }
@@ -935,11 +820,6 @@ func (st *ServerType) serversFromPairings(
} }
} }
// sort for deterministic JSON output
if srv.Logs != nil {
slices.Sort(srv.Logs.SkipHosts)
}
// a server cannot (natively) serve both HTTP and HTTPS at the // a server cannot (natively) serve both HTTP and HTTPS at the
// same time, so make sure the configuration isn't in conflict // same time, so make sure the configuration isn't in conflict
err := detectConflictingSchemes(srv, p.serverBlocks, options) err := detectConflictingSchemes(srv, p.serverBlocks, options)
@@ -962,10 +842,7 @@ func (st *ServerType) serversFromPairings(
if addressQualifiesForTLS && if addressQualifiesForTLS &&
!hasCatchAllTLSConnPolicy && !hasCatchAllTLSConnPolicy &&
(len(srv.TLSConnPolicies) > 0 || !autoHTTPSWillAddConnPolicy || defaultSNI != "" || fallbackSNI != "") { (len(srv.TLSConnPolicies) > 0 || !autoHTTPSWillAddConnPolicy || defaultSNI != "" || fallbackSNI != "") {
srv.TLSConnPolicies = append(srv.TLSConnPolicies, &caddytls.ConnectionPolicy{ srv.TLSConnPolicies = append(srv.TLSConnPolicies, &caddytls.ConnectionPolicy{DefaultSNI: defaultSNI, FallbackSNI: fallbackSNI})
DefaultSNI: defaultSNI,
FallbackSNI: fallbackSNI,
})
} }
// tidy things up a bit // tidy things up a bit
@@ -978,7 +855,8 @@ func (st *ServerType) serversFromPairings(
servers[fmt.Sprintf("srv%d", i)] = srv servers[fmt.Sprintf("srv%d", i)] = srv
} }
if err := applyServerOptions(servers, options, warnings); err != nil { err := applyServerOptions(servers, options, warnings)
if err != nil {
return nil, fmt.Errorf("applying global server options: %v", err) return nil, fmt.Errorf("applying global server options: %v", err)
} }
@@ -1023,7 +901,7 @@ func detectConflictingSchemes(srv *caddyhttp.Server, serverBlocks []serverBlock,
} }
for _, sblock := range serverBlocks { for _, sblock := range serverBlocks {
for _, addr := range sblock.parsedKeys { for _, addr := range sblock.keys {
if addr.Scheme == "http" || addr.Port == httpPort { if addr.Scheme == "http" || addr.Port == httpPort {
if err := checkAndSetHTTP(addr); err != nil { if err := checkAndSetHTTP(addr); err != nil {
return err return err
@@ -1061,40 +939,11 @@ func consolidateConnPolicies(cps caddytls.ConnectionPolicies) (caddytls.Connecti
// if they're exactly equal in every way, just keep one of them // if they're exactly equal in every way, just keep one of them
if reflect.DeepEqual(cps[i], cps[j]) { if reflect.DeepEqual(cps[i], cps[j]) {
cps = slices.Delete(cps, j, j+1) cps = append(cps[:j], cps[j+1:]...)
i-- i--
break break
} }
// as a special case, if there are adjacent TLS conn policies that are identical except
// by their matchers, and the matchers are specifically just ServerName ("sni") matchers
// (by far the most common), we can combine them into a single policy
if i == j-1 && len(cps[i].MatchersRaw) == 1 && len(cps[j].MatchersRaw) == 1 {
if iSNIMatcherJSON, ok := cps[i].MatchersRaw["sni"]; ok {
if jSNIMatcherJSON, ok := cps[j].MatchersRaw["sni"]; ok {
// position of policies and the matcher criteria check out; if settings are
// the same, then we can combine the policies; we have to unmarshal and
// remarshal the matchers though
if cps[i].SettingsEqual(*cps[j]) {
var iSNIMatcher caddytls.MatchServerName
if err := json.Unmarshal(iSNIMatcherJSON, &iSNIMatcher); err == nil {
var jSNIMatcher caddytls.MatchServerName
if err := json.Unmarshal(jSNIMatcherJSON, &jSNIMatcher); err == nil {
iSNIMatcher = append(iSNIMatcher, jSNIMatcher...)
cps[i].MatchersRaw["sni"], err = json.Marshal(iSNIMatcher)
if err != nil {
return nil, fmt.Errorf("recombining SNI matchers: %v", err)
}
cps = slices.Delete(cps, j, j+1)
i--
break
}
}
}
}
}
}
// if they have the same matcher, try to reconcile each field: either they must // if they have the same matcher, try to reconcile each field: either they must
// be identical, or we have to be able to combine them safely // be identical, or we have to be able to combine them safely
if reflect.DeepEqual(cps[i].MatchersRaw, cps[j].MatchersRaw) { if reflect.DeepEqual(cps[i].MatchersRaw, cps[j].MatchersRaw) {
@@ -1128,12 +977,6 @@ func consolidateConnPolicies(cps caddytls.ConnectionPolicies) (caddytls.Connecti
return nil, fmt.Errorf("two policies with same match criteria have conflicting default SNI: %s vs. %s", return nil, fmt.Errorf("two policies with same match criteria have conflicting default SNI: %s vs. %s",
cps[i].DefaultSNI, cps[j].DefaultSNI) cps[i].DefaultSNI, cps[j].DefaultSNI)
} }
if cps[i].FallbackSNI != "" &&
cps[j].FallbackSNI != "" &&
cps[i].FallbackSNI != cps[j].FallbackSNI {
return nil, fmt.Errorf("two policies with same match criteria have conflicting fallback SNI: %s vs. %s",
cps[i].FallbackSNI, cps[j].FallbackSNI)
}
if cps[i].ProtocolMin != "" && if cps[i].ProtocolMin != "" &&
cps[j].ProtocolMin != "" && cps[j].ProtocolMin != "" &&
cps[i].ProtocolMin != cps[j].ProtocolMin { cps[i].ProtocolMin != cps[j].ProtocolMin {
@@ -1174,9 +1017,6 @@ func consolidateConnPolicies(cps caddytls.ConnectionPolicies) (caddytls.Connecti
if cps[i].DefaultSNI == "" && cps[j].DefaultSNI != "" { if cps[i].DefaultSNI == "" && cps[j].DefaultSNI != "" {
cps[i].DefaultSNI = cps[j].DefaultSNI cps[i].DefaultSNI = cps[j].DefaultSNI
} }
if cps[i].FallbackSNI == "" && cps[j].FallbackSNI != "" {
cps[i].FallbackSNI = cps[j].FallbackSNI
}
if cps[i].ProtocolMin == "" && cps[j].ProtocolMin != "" { if cps[i].ProtocolMin == "" && cps[j].ProtocolMin != "" {
cps[i].ProtocolMin = cps[j].ProtocolMin cps[i].ProtocolMin = cps[j].ProtocolMin
} }
@@ -1190,19 +1030,18 @@ func consolidateConnPolicies(cps caddytls.ConnectionPolicies) (caddytls.Connecti
} else if cps[i].CertSelection != nil && cps[j].CertSelection != nil { } else if cps[i].CertSelection != nil && cps[j].CertSelection != nil {
// if both have one, then combine AnyTag // if both have one, then combine AnyTag
for _, tag := range cps[j].CertSelection.AnyTag { for _, tag := range cps[j].CertSelection.AnyTag {
if !slices.Contains(cps[i].CertSelection.AnyTag, tag) { if !sliceContains(cps[i].CertSelection.AnyTag, tag) {
cps[i].CertSelection.AnyTag = append(cps[i].CertSelection.AnyTag, tag) cps[i].CertSelection.AnyTag = append(cps[i].CertSelection.AnyTag, tag)
} }
} }
} }
cps = slices.Delete(cps, j, j+1) cps = append(cps[:j], cps[j+1:]...)
i-- i--
break break
} }
} }
} }
return cps, nil return cps, nil
} }
@@ -1274,7 +1113,7 @@ func appendSubrouteToRouteList(routeList caddyhttp.RouteList,
func buildSubroute(routes []ConfigValue, groupCounter counter, needsSorting bool) (*caddyhttp.Subroute, error) { func buildSubroute(routes []ConfigValue, groupCounter counter, needsSorting bool) (*caddyhttp.Subroute, error) {
if needsSorting { if needsSorting {
for _, val := range routes { for _, val := range routes {
if !slices.Contains(directiveOrder, val.directive) { if !directiveIsOrdered(val.directive) {
return nil, fmt.Errorf("directive '%s' is not an ordered HTTP handler, so it cannot be used here - try placing within a route block or using the order global option", val.directive) return nil, fmt.Errorf("directive '%s' is not an ordered HTTP handler, so it cannot be used here - try placing within a route block or using the order global option", val.directive)
} }
} }
@@ -1421,24 +1260,19 @@ func matcherSetFromMatcherToken(
if tkn.Text == "*" { if tkn.Text == "*" {
// match all requests == no matchers, so nothing to do // match all requests == no matchers, so nothing to do
return nil, true, nil return nil, true, nil
} } else if strings.HasPrefix(tkn.Text, "/") {
// convenient way to specify a single path match
// convenient way to specify a single path match
if strings.HasPrefix(tkn.Text, "/") {
return caddy.ModuleMap{ return caddy.ModuleMap{
"path": caddyconfig.JSON(caddyhttp.MatchPath{tkn.Text}, warnings), "path": caddyconfig.JSON(caddyhttp.MatchPath{tkn.Text}, warnings),
}, true, nil }, true, nil
} } else if strings.HasPrefix(tkn.Text, matcherPrefix) {
// pre-defined matcher
// pre-defined matcher
if strings.HasPrefix(tkn.Text, matcherPrefix) {
m, ok := matcherDefs[tkn.Text] m, ok := matcherDefs[tkn.Text]
if !ok { if !ok {
return nil, false, fmt.Errorf("unrecognized matcher name: %+v", tkn.Text) return nil, false, fmt.Errorf("unrecognized matcher name: %+v", tkn.Text)
} }
return m, true, nil return m, true, nil
} }
return nil, false, nil return nil, false, nil
} }
@@ -1452,7 +1286,7 @@ func (st *ServerType) compileEncodedMatcherSets(sblock serverBlock) ([]caddy.Mod
var matcherPairs []*hostPathPair var matcherPairs []*hostPathPair
var catchAllHosts bool var catchAllHosts bool
for _, addr := range sblock.parsedKeys { for _, addr := range sblock.keys {
// choose a matcher pair that should be shared by this // choose a matcher pair that should be shared by this
// server block; if none exists yet, create one // server block; if none exists yet, create one
var chosenMatcherPair *hostPathPair var chosenMatcherPair *hostPathPair
@@ -1484,16 +1318,25 @@ func (st *ServerType) compileEncodedMatcherSets(sblock serverBlock) ([]caddy.Mod
// add this server block's keys to the matcher // add this server block's keys to the matcher
// pair if it doesn't already exist // pair if it doesn't already exist
if addr.Host != "" && !slices.Contains(chosenMatcherPair.hostm, addr.Host) { if addr.Host != "" {
chosenMatcherPair.hostm = append(chosenMatcherPair.hostm, addr.Host) var found bool
for _, h := range chosenMatcherPair.hostm {
if h == addr.Host {
found = true
break
}
}
if !found {
chosenMatcherPair.hostm = append(chosenMatcherPair.hostm, addr.Host)
}
} }
} }
// iterate each pairing of host and path matchers and // iterate each pairing of host and path matchers and
// put them into a map for JSON encoding // put them into a map for JSON encoding
var matcherSets []map[string]caddyhttp.RequestMatcherWithError var matcherSets []map[string]caddyhttp.RequestMatcher
for _, mp := range matcherPairs { for _, mp := range matcherPairs {
matcherSet := make(map[string]caddyhttp.RequestMatcherWithError) matcherSet := make(map[string]caddyhttp.RequestMatcher)
if len(mp.hostm) > 0 { if len(mp.hostm) > 0 {
matcherSet["host"] = mp.hostm matcherSet["host"] = mp.hostm
} }
@@ -1519,94 +1362,74 @@ func (st *ServerType) compileEncodedMatcherSets(sblock serverBlock) ([]caddy.Mod
} }
func parseMatcherDefinitions(d *caddyfile.Dispenser, matchers map[string]caddy.ModuleMap) error { func parseMatcherDefinitions(d *caddyfile.Dispenser, matchers map[string]caddy.ModuleMap) error {
d.Next() // advance to the first token for d.Next() {
// this is the "name" for "named matchers"
definitionName := d.Val()
// this is the "name" for "named matchers" if _, ok := matchers[definitionName]; ok {
definitionName := d.Val() return fmt.Errorf("matcher is defined more than once: %s", definitionName)
if _, ok := matchers[definitionName]; ok {
return fmt.Errorf("matcher is defined more than once: %s", definitionName)
}
matchers[definitionName] = make(caddy.ModuleMap)
// given a matcher name and the tokens following it, parse
// the tokens as a matcher module and record it
makeMatcher := func(matcherName string, tokens []caddyfile.Token) error {
// create a new dispenser from the tokens
dispenser := caddyfile.NewDispenser(tokens)
// set the matcher name (without @) in the dispenser context so
// that matcher modules can access it to use it as their name
// (e.g. regexp matchers which use the name for capture groups)
dispenser.SetContext(caddyfile.MatcherNameCtxKey, definitionName[1:])
mod, err := caddy.GetModule("http.matchers." + matcherName)
if err != nil {
return fmt.Errorf("getting matcher module '%s': %v", matcherName, err)
}
unm, ok := mod.New().(caddyfile.Unmarshaler)
if !ok {
return fmt.Errorf("matcher module '%s' is not a Caddyfile unmarshaler", matcherName)
}
err = unm.UnmarshalCaddyfile(dispenser)
if err != nil {
return err
} }
matchers[definitionName] = make(caddy.ModuleMap)
if rm, ok := unm.(caddyhttp.RequestMatcherWithError); ok { // given a matcher name and the tokens following it, parse
matchers[definitionName][matcherName] = caddyconfig.JSON(rm, nil) // the tokens as a matcher module and record it
return nil makeMatcher := func(matcherName string, tokens []caddyfile.Token) error {
} mod, err := caddy.GetModule("http.matchers." + matcherName)
// nolint:staticcheck if err != nil {
if rm, ok := unm.(caddyhttp.RequestMatcher); ok { return fmt.Errorf("getting matcher module '%s': %v", matcherName, err)
matchers[definitionName][matcherName] = caddyconfig.JSON(rm, nil) }
return nil unm, ok := mod.New().(caddyfile.Unmarshaler)
} if !ok {
return fmt.Errorf("matcher module '%s' is not a request matcher", matcherName) return fmt.Errorf("matcher module '%s' is not a Caddyfile unmarshaler", matcherName)
} }
err = unm.UnmarshalCaddyfile(caddyfile.NewDispenser(tokens))
// if the next token is quoted, we can assume it's not a matcher name
// and that it's probably an 'expression' matcher
if d.NextArg() {
if d.Token().Quoted() {
// since it was missing the matcher name, we insert a token
// in front of the expression token itself; we use Clone() to
// make the new token to keep the same the import location as
// the next token, if this is within a snippet or imported file.
// see https://github.com/caddyserver/caddy/issues/6287
expressionToken := d.Token().Clone()
expressionToken.Text = "expression"
err := makeMatcher("expression", []caddyfile.Token{expressionToken, d.Token()})
if err != nil { if err != nil {
return err return err
} }
rm, ok := unm.(caddyhttp.RequestMatcher)
if !ok {
return fmt.Errorf("matcher module '%s' is not a request matcher", matcherName)
}
matchers[definitionName][matcherName] = caddyconfig.JSON(rm, nil)
return nil return nil
} }
// if it wasn't quoted, then we need to rewind after calling // if the next token is quoted, we can assume it's not a matcher name
// d.NextArg() so the below properly grabs the matcher name // and that it's probably an 'expression' matcher
d.Prev() if d.NextArg() {
} if d.Token().Quoted() {
err := makeMatcher("expression", []caddyfile.Token{d.Token()})
if err != nil {
return err
}
continue
}
// in case there are multiple instances of the same matcher, concatenate // if it wasn't quoted, then we need to rewind after calling
// their tokens (we expect that UnmarshalCaddyfile should be able to // d.NextArg() so the below properly grabs the matcher name
// handle more than one segment); otherwise, we'd overwrite other d.Prev()
// instances of the matcher in this set }
tokensByMatcherName := make(map[string][]caddyfile.Token)
for nesting := d.Nesting(); d.NextArg() || d.NextBlock(nesting); { // in case there are multiple instances of the same matcher, concatenate
matcherName := d.Val() // their tokens (we expect that UnmarshalCaddyfile should be able to
tokensByMatcherName[matcherName] = append(tokensByMatcherName[matcherName], d.NextSegment()...) // handle more than one segment); otherwise, we'd overwrite other
} // instances of the matcher in this set
for matcherName, tokens := range tokensByMatcherName { tokensByMatcherName := make(map[string][]caddyfile.Token)
err := makeMatcher(matcherName, tokens) for nesting := d.Nesting(); d.NextArg() || d.NextBlock(nesting); {
if err != nil { matcherName := d.Val()
return err tokensByMatcherName[matcherName] = append(tokensByMatcherName[matcherName], d.NextSegment()...)
}
for matcherName, tokens := range tokensByMatcherName {
err := makeMatcher(matcherName, tokens)
if err != nil {
return err
}
} }
} }
return nil return nil
} }
func encodeMatcherSet(matchers map[string]caddyhttp.RequestMatcherWithError) (caddy.ModuleMap, error) { func encodeMatcherSet(matchers map[string]caddyhttp.RequestMatcher) (caddy.ModuleMap, error) {
msEncoded := make(caddy.ModuleMap) msEncoded := make(caddy.ModuleMap)
for matcherName, val := range matchers { for matcherName, val := range matchers {
jsonBytes, err := json.Marshal(val) jsonBytes, err := json.Marshal(val)
@@ -1666,6 +1489,16 @@ func tryDuration(val any, warnings *[]caddyconfig.Warning) caddy.Duration {
return durationVal return durationVal
} }
// sliceContains returns true if needle is in haystack.
func sliceContains(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
// listenersUseAnyPortOtherThan returns true if there are any // listenersUseAnyPortOtherThan returns true if there are any
// listeners in addresses that use a port which is not otherPort. // listeners in addresses that use a port which is not otherPort.
// Mostly borrowed from unexported method in caddyhttp package. // Mostly borrowed from unexported method in caddyhttp package.
@@ -1686,18 +1519,6 @@ func listenersUseAnyPortOtherThan(addresses []string, otherPort string) bool {
return false return false
} }
func mapContains[K comparable, V any](m map[K]V, keys []K) bool {
if len(m) == 0 || len(keys) == 0 {
return false
}
for _, key := range keys {
if _, ok := m[key]; ok {
return true
}
}
return false
}
// specificity returns len(s) minus any wildcards (*) and // specificity returns len(s) minus any wildcards (*) and
// placeholders ({...}). Basically, it's a length count // placeholders ({...}). Basically, it's a length count
// that penalizes the use of wildcards and placeholders. // that penalizes the use of wildcards and placeholders.
@@ -1735,25 +1556,17 @@ func (c counter) nextGroup() string {
} }
type namedCustomLog struct { type namedCustomLog struct {
name string name string
hostnames []string hostnames []string
log *caddy.CustomLog log *caddy.CustomLog
noHostname bool
}
// addressWithProtocols associates a listen address with
// the protocols to serve it with
type addressWithProtocols struct {
address string
protocols []string
} }
// sbAddrAssociation is a mapping from a list of // sbAddrAssociation is a mapping from a list of
// addresses with protocols, and a list of server // addresses to a list of server blocks that are
// blocks that are served on those addresses. // served on those addresses.
type sbAddrAssociation struct { type sbAddrAssociation struct {
addressesWithProtocols []addressWithProtocols addresses []string
serverBlocks []serverBlock serverBlocks []serverBlock
} }
const ( const (
+220 -355
View File
@@ -15,17 +15,14 @@
package httpcaddyfile package httpcaddyfile
import ( import (
"slices"
"strconv" "strconv"
"github.com/caddyserver/certmagic" "github.com/caddyserver/certmagic"
"github.com/libdns/libdns" "github.com/mholt/acmez/acme"
"github.com/mholt/acmez/v3/acme"
"github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig" "github.com/caddyserver/caddy/v2/caddyconfig"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/caddyserver/caddy/v2/modules/caddytls" "github.com/caddyserver/caddy/v2/modules/caddytls"
) )
@@ -33,20 +30,19 @@ func init() {
RegisterGlobalOption("debug", parseOptTrue) RegisterGlobalOption("debug", parseOptTrue)
RegisterGlobalOption("http_port", parseOptHTTPPort) RegisterGlobalOption("http_port", parseOptHTTPPort)
RegisterGlobalOption("https_port", parseOptHTTPSPort) RegisterGlobalOption("https_port", parseOptHTTPSPort)
RegisterGlobalOption("default_bind", parseOptDefaultBind) RegisterGlobalOption("default_bind", parseOptStringList)
RegisterGlobalOption("grace_period", parseOptDuration) RegisterGlobalOption("grace_period", parseOptDuration)
RegisterGlobalOption("shutdown_delay", parseOptDuration) RegisterGlobalOption("shutdown_delay", parseOptDuration)
RegisterGlobalOption("default_sni", parseOptSingleString) RegisterGlobalOption("default_sni", parseOptSingleString)
RegisterGlobalOption("fallback_sni", parseOptSingleString) RegisterGlobalOption("fallback_sni", parseOptSingleString)
RegisterGlobalOption("order", parseOptOrder) RegisterGlobalOption("order", parseOptOrder)
RegisterGlobalOption("storage", parseOptStorage) RegisterGlobalOption("storage", parseOptStorage)
RegisterGlobalOption("storage_check", parseStorageCheck) RegisterGlobalOption("storage_clean_interval", parseOptDuration)
RegisterGlobalOption("storage_clean_interval", parseStorageCleanInterval)
RegisterGlobalOption("renew_interval", parseOptDuration) RegisterGlobalOption("renew_interval", parseOptDuration)
RegisterGlobalOption("ocsp_interval", parseOptDuration) RegisterGlobalOption("ocsp_interval", parseOptDuration)
RegisterGlobalOption("acme_ca", parseOptSingleString) RegisterGlobalOption("acme_ca", parseOptSingleString)
RegisterGlobalOption("acme_ca_root", parseOptSingleString) RegisterGlobalOption("acme_ca_root", parseOptSingleString)
RegisterGlobalOption("acme_dns", parseOptDNS) RegisterGlobalOption("acme_dns", parseOptACMEDNS)
RegisterGlobalOption("acme_eab", parseOptACMEEAB) RegisterGlobalOption("acme_eab", parseOptACMEEAB)
RegisterGlobalOption("cert_issuer", parseOptCertIssuer) RegisterGlobalOption("cert_issuer", parseOptCertIssuer)
RegisterGlobalOption("skip_install_trust", parseOptTrue) RegisterGlobalOption("skip_install_trust", parseOptTrue)
@@ -56,119 +52,118 @@ func init() {
RegisterGlobalOption("local_certs", parseOptTrue) RegisterGlobalOption("local_certs", parseOptTrue)
RegisterGlobalOption("key_type", parseOptSingleString) RegisterGlobalOption("key_type", parseOptSingleString)
RegisterGlobalOption("auto_https", parseOptAutoHTTPS) RegisterGlobalOption("auto_https", parseOptAutoHTTPS)
RegisterGlobalOption("metrics", parseMetricsOptions)
RegisterGlobalOption("servers", parseServerOptions) RegisterGlobalOption("servers", parseServerOptions)
RegisterGlobalOption("ocsp_stapling", parseOCSPStaplingOptions) RegisterGlobalOption("ocsp_stapling", parseOCSPStaplingOptions)
RegisterGlobalOption("cert_lifetime", parseOptDuration)
RegisterGlobalOption("log", parseLogOptions) RegisterGlobalOption("log", parseLogOptions)
RegisterGlobalOption("preferred_chains", parseOptPreferredChains) RegisterGlobalOption("preferred_chains", parseOptPreferredChains)
RegisterGlobalOption("persist_config", parseOptPersistConfig) RegisterGlobalOption("persist_config", parseOptPersistConfig)
RegisterGlobalOption("dns", parseOptDNS)
RegisterGlobalOption("ech", parseOptECH)
} }
func parseOptTrue(d *caddyfile.Dispenser, _ any) (any, error) { return true, nil } func parseOptTrue(d *caddyfile.Dispenser, _ any) (any, error) { return true, nil }
func parseOptHTTPPort(d *caddyfile.Dispenser, _ any) (any, error) { func parseOptHTTPPort(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() // consume option name
var httpPort int var httpPort int
var httpPortStr string for d.Next() {
if !d.AllArgs(&httpPortStr) { var httpPortStr string
return 0, d.ArgErr() if !d.AllArgs(&httpPortStr) {
} return 0, d.ArgErr()
var err error }
httpPort, err = strconv.Atoi(httpPortStr) var err error
if err != nil { httpPort, err = strconv.Atoi(httpPortStr)
return 0, d.Errf("converting port '%s' to integer value: %v", httpPortStr, err) if err != nil {
return 0, d.Errf("converting port '%s' to integer value: %v", httpPortStr, err)
}
} }
return httpPort, nil return httpPort, nil
} }
func parseOptHTTPSPort(d *caddyfile.Dispenser, _ any) (any, error) { func parseOptHTTPSPort(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() // consume option name
var httpsPort int var httpsPort int
var httpsPortStr string for d.Next() {
if !d.AllArgs(&httpsPortStr) { var httpsPortStr string
return 0, d.ArgErr() if !d.AllArgs(&httpsPortStr) {
} return 0, d.ArgErr()
var err error }
httpsPort, err = strconv.Atoi(httpsPortStr) var err error
if err != nil { httpsPort, err = strconv.Atoi(httpsPortStr)
return 0, d.Errf("converting port '%s' to integer value: %v", httpsPortStr, err) if err != nil {
return 0, d.Errf("converting port '%s' to integer value: %v", httpsPortStr, err)
}
} }
return httpsPort, nil return httpsPort, nil
} }
func parseOptOrder(d *caddyfile.Dispenser, _ any) (any, error) { func parseOptOrder(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() // consume option name newOrder := directiveOrder
// get directive name for d.Next() {
if !d.Next() { // get directive name
return nil, d.ArgErr() if !d.Next() {
} return nil, d.ArgErr()
dirName := d.Val() }
if _, ok := registeredDirectives[dirName]; !ok { dirName := d.Val()
return nil, d.Errf("%s is not a registered directive", dirName) if _, ok := registeredDirectives[dirName]; !ok {
} return nil, d.Errf("%s is not a registered directive", dirName)
}
// get positional token // get positional token
if !d.Next() { if !d.Next() {
return nil, d.ArgErr() return nil, d.ArgErr()
} }
pos := Positional(d.Val()) pos := d.Val()
// if directive already had an order, drop it // if directive exists, first remove it
newOrder := slices.DeleteFunc(directiveOrder, func(d string) bool { for i, d := range newOrder {
return d == dirName if d == dirName {
}) newOrder = append(newOrder[:i], newOrder[i+1:]...)
break
}
}
// act on the positional; if it's First or Last, we're done right away // act on the positional
switch pos { switch pos {
case First: case "first":
newOrder = append([]string{dirName}, newOrder...) newOrder = append([]string{dirName}, newOrder...)
if d.NextArg() {
return nil, d.ArgErr()
}
directiveOrder = newOrder
return newOrder, nil
case "last":
newOrder = append(newOrder, dirName)
if d.NextArg() {
return nil, d.ArgErr()
}
directiveOrder = newOrder
return newOrder, nil
case "before":
case "after":
default:
return nil, d.Errf("unknown positional '%s'", pos)
}
// get name of other directive
if !d.NextArg() {
return nil, d.ArgErr()
}
otherDir := d.Val()
if d.NextArg() { if d.NextArg() {
return nil, d.ArgErr() return nil, d.ArgErr()
} }
directiveOrder = newOrder
return newOrder, nil
case Last: // insert directive into proper position
newOrder = append(newOrder, dirName) for i, d := range newOrder {
if d.NextArg() { if d == otherDir {
return nil, d.ArgErr() if pos == "before" {
newOrder = append(newOrder[:i], append([]string{dirName}, newOrder[i:]...)...)
} else if pos == "after" {
newOrder = append(newOrder[:i+1], append([]string{dirName}, newOrder[i+1:]...)...)
}
break
}
} }
directiveOrder = newOrder
return newOrder, nil
// if it's Before or After, continue
case Before:
case After:
default:
return nil, d.Errf("unknown positional '%s'", pos)
} }
// get name of other directive
if !d.NextArg() {
return nil, d.ArgErr()
}
otherDir := d.Val()
if d.NextArg() {
return nil, d.ArgErr()
}
// get the position of the target directive
targetIndex := slices.Index(newOrder, otherDir)
if targetIndex == -1 {
return nil, d.Errf("directive '%s' not found", otherDir)
}
// if we're inserting after, we need to increment the index to go after
if pos == After {
targetIndex++
}
// insert the directive into the new order
newOrder = slices.Insert(newOrder, targetIndex, dirName)
directiveOrder = newOrder directiveOrder = newOrder
return newOrder, nil return newOrder, nil
@@ -193,40 +188,6 @@ func parseOptStorage(d *caddyfile.Dispenser, _ any) (any, error) {
return storage, nil return storage, nil
} }
func parseStorageCheck(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() // consume option name
if !d.Next() {
return "", d.ArgErr()
}
val := d.Val()
if d.Next() {
return "", d.ArgErr()
}
if val != "off" {
return "", d.Errf("storage_check must be 'off'")
}
return val, nil
}
func parseStorageCleanInterval(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() // consume option name
if !d.Next() {
return "", d.ArgErr()
}
val := d.Val()
if d.Next() {
return "", d.ArgErr()
}
if val == "off" {
return false, nil
}
dur, err := caddy.ParseDuration(d.Val())
if err != nil {
return nil, d.Errf("failed to parse storage_clean_interval, must be a duration or 'off' %w", err)
}
return caddy.Duration(dur), nil
}
func parseOptDuration(d *caddyfile.Dispenser, _ any) (any, error) { func parseOptDuration(d *caddyfile.Dispenser, _ any) (any, error) {
if !d.Next() { // consume option name if !d.Next() { // consume option name
return nil, d.ArgErr() return nil, d.ArgErr()
@@ -241,60 +202,78 @@ func parseOptDuration(d *caddyfile.Dispenser, _ any) (any, error) {
return caddy.Duration(dur), nil return caddy.Duration(dur), nil
} }
func parseOptACMEEAB(d *caddyfile.Dispenser, _ any) (any, error) { func parseOptACMEDNS(d *caddyfile.Dispenser, _ any) (any, error) {
eab := new(acme.EAB) if !d.Next() { // consume option name
d.Next() // consume option name
if d.NextArg() {
return nil, d.ArgErr() return nil, d.ArgErr()
} }
for d.NextBlock(0) { if !d.Next() { // get DNS module name
switch d.Val() { return nil, d.ArgErr()
case "key_id": }
if !d.NextArg() { modID := "dns.providers." + d.Val()
return nil, d.ArgErr() unm, err := caddyfile.UnmarshalModule(d, modID)
} if err != nil {
eab.KeyID = d.Val() return nil, err
}
prov, ok := unm.(certmagic.ACMEDNSProvider)
if !ok {
return nil, d.Errf("module %s (%T) is not a certmagic.ACMEDNSProvider", modID, unm)
}
return prov, nil
}
case "mac_key": func parseOptACMEEAB(d *caddyfile.Dispenser, _ any) (any, error) {
if !d.NextArg() { eab := new(acme.EAB)
return nil, d.ArgErr() for d.Next() {
} if d.NextArg() {
eab.MACKey = d.Val() return nil, d.ArgErr()
}
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "key_id":
if !d.NextArg() {
return nil, d.ArgErr()
}
eab.KeyID = d.Val()
default: case "mac_key":
return nil, d.Errf("unrecognized parameter '%s'", d.Val()) if !d.NextArg() {
return nil, d.ArgErr()
}
eab.MACKey = d.Val()
default:
return nil, d.Errf("unrecognized parameter '%s'", d.Val())
}
} }
} }
return eab, nil return eab, nil
} }
func parseOptCertIssuer(d *caddyfile.Dispenser, existing any) (any, error) { func parseOptCertIssuer(d *caddyfile.Dispenser, existing any) (any, error) {
d.Next() // consume option name
var issuers []certmagic.Issuer var issuers []certmagic.Issuer
if existing != nil { if existing != nil {
issuers = existing.([]certmagic.Issuer) issuers = existing.([]certmagic.Issuer)
} }
for d.Next() { // consume option name
// get issuer module name if !d.Next() { // get issuer module name
if !d.Next() { return nil, d.ArgErr()
return nil, d.ArgErr() }
modID := "tls.issuance." + d.Val()
unm, err := caddyfile.UnmarshalModule(d, modID)
if err != nil {
return nil, err
}
iss, ok := unm.(certmagic.Issuer)
if !ok {
return nil, d.Errf("module %s (%T) is not a certmagic.Issuer", modID, unm)
}
issuers = append(issuers, iss)
} }
modID := "tls.issuance." + d.Val()
unm, err := caddyfile.UnmarshalModule(d, modID)
if err != nil {
return nil, err
}
iss, ok := unm.(certmagic.Issuer)
if !ok {
return nil, d.Errf("module %s (%T) is not a certmagic.Issuer", modID, unm)
}
issuers = append(issuers, iss)
return issuers, nil return issuers, nil
} }
func parseOptSingleString(d *caddyfile.Dispenser, _ any) (any, error) { func parseOptSingleString(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() // consume option name d.Next() // consume parameter name
if !d.Next() { if !d.Next() {
return "", d.ArgErr() return "", d.ArgErr()
} }
@@ -305,62 +284,43 @@ func parseOptSingleString(d *caddyfile.Dispenser, _ any) (any, error) {
return val, nil return val, nil
} }
func parseOptDefaultBind(d *caddyfile.Dispenser, _ any) (any, error) { func parseOptStringList(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() // consume option name d.Next() // consume parameter name
val := d.RemainingArgs()
var addresses, protocols []string if len(val) == 0 {
addresses = d.RemainingArgs() return "", d.ArgErr()
if len(addresses) == 0 {
addresses = append(addresses, "")
} }
return val, nil
for d.NextBlock(0) {
switch d.Val() {
case "protocols":
protocols = d.RemainingArgs()
if len(protocols) == 0 {
return nil, d.Errf("protocols requires one or more arguments")
}
default:
return nil, d.Errf("unknown subdirective: %s", d.Val())
}
}
return []ConfigValue{{Class: "bind", Value: addressesWithProtocols{
addresses: addresses,
protocols: protocols,
}}}, nil
} }
func parseOptAdmin(d *caddyfile.Dispenser, _ any) (any, error) { func parseOptAdmin(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() // consume option name
adminCfg := new(caddy.AdminConfig) adminCfg := new(caddy.AdminConfig)
if d.NextArg() { for d.Next() {
listenAddress := d.Val() if d.NextArg() {
if listenAddress == "off" { listenAddress := d.Val()
adminCfg.Disabled = true if listenAddress == "off" {
if d.Next() { // Do not accept any remaining options including block adminCfg.Disabled = true
return nil, d.Err("No more option is allowed after turning off admin config") if d.Next() { // Do not accept any remaining options including block
} return nil, d.Err("No more option is allowed after turning off admin config")
} else { }
adminCfg.Listen = listenAddress } else {
if d.NextArg() { // At most 1 arg is allowed adminCfg.Listen = listenAddress
return nil, d.ArgErr() if d.NextArg() { // At most 1 arg is allowed
return nil, d.ArgErr()
}
} }
} }
} for nesting := d.Nesting(); d.NextBlock(nesting); {
for d.NextBlock(0) { switch d.Val() {
switch d.Val() { case "enforce_origin":
case "enforce_origin": adminCfg.EnforceOrigin = true
adminCfg.EnforceOrigin = true
case "origins": case "origins":
adminCfg.Origins = d.RemainingArgs() adminCfg.Origins = d.RemainingArgs()
default: default:
return nil, d.Errf("unrecognized parameter '%s'", d.Val()) return nil, d.Errf("unrecognized parameter '%s'", d.Val())
}
} }
} }
if adminCfg.Listen == "" && !adminCfg.Disabled { if adminCfg.Listen == "" && !adminCfg.Disabled {
@@ -370,58 +330,57 @@ func parseOptAdmin(d *caddyfile.Dispenser, _ any) (any, error) {
} }
func parseOptOnDemand(d *caddyfile.Dispenser, _ any) (any, error) { func parseOptOnDemand(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() // consume option name
if d.NextArg() {
return nil, d.ArgErr()
}
var ond *caddytls.OnDemandConfig var ond *caddytls.OnDemandConfig
for d.Next() {
if d.NextArg() {
return nil, d.ArgErr()
}
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "ask":
if !d.NextArg() {
return nil, d.ArgErr()
}
if ond == nil {
ond = new(caddytls.OnDemandConfig)
}
ond.Ask = d.Val()
for nesting := d.Nesting(); d.NextBlock(nesting); { case "interval":
switch d.Val() { if !d.NextArg() {
case "ask": return nil, d.ArgErr()
if !d.NextArg() { }
return nil, d.ArgErr() dur, err := caddy.ParseDuration(d.Val())
} if err != nil {
if ond == nil { return nil, err
ond = new(caddytls.OnDemandConfig) }
} if ond == nil {
if ond.PermissionRaw != nil { ond = new(caddytls.OnDemandConfig)
return nil, d.Err("on-demand TLS permission module (or 'ask') already specified") }
} if ond.RateLimit == nil {
perm := caddytls.PermissionByHTTP{Endpoint: d.Val()} ond.RateLimit = new(caddytls.RateLimit)
ond.PermissionRaw = caddyconfig.JSONModuleObject(perm, "module", "http", nil) }
ond.RateLimit.Interval = caddy.Duration(dur)
case "permission": case "burst":
if !d.NextArg() { if !d.NextArg() {
return nil, d.ArgErr() return nil, d.ArgErr()
} }
if ond == nil { burst, err := strconv.Atoi(d.Val())
ond = new(caddytls.OnDemandConfig) if err != nil {
} return nil, err
if ond.PermissionRaw != nil { }
return nil, d.Err("on-demand TLS permission module (or 'ask') already specified") if ond == nil {
} ond = new(caddytls.OnDemandConfig)
modName := d.Val() }
modID := "tls.permission." + modName if ond.RateLimit == nil {
unm, err := caddyfile.UnmarshalModule(d, modID) ond.RateLimit = new(caddytls.RateLimit)
if err != nil { }
return nil, err ond.RateLimit.Burst = burst
}
perm, ok := unm.(caddytls.OnDemandPermission)
if !ok {
return nil, d.Errf("module %s (%T) is not an on-demand TLS permission module", modID, unm)
}
ond.PermissionRaw = caddyconfig.JSONModuleObject(perm, "module", modName, nil)
case "interval": default:
return nil, d.Errf("the on_demand_tls 'interval' option is no longer supported, remove it from your config") return nil, d.Errf("unrecognized parameter '%s'", d.Val())
}
case "burst":
return nil, d.Errf("the on_demand_tls 'burst' option is no longer supported, remove it from your config")
default:
return nil, d.Errf("unrecognized parameter '%s'", d.Val())
} }
} }
if ond == nil { if ond == nil {
@@ -431,7 +390,7 @@ func parseOptOnDemand(d *caddyfile.Dispenser, _ any) (any, error) {
} }
func parseOptPersistConfig(d *caddyfile.Dispenser, _ any) (any, error) { func parseOptPersistConfig(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() // consume option name d.Next() // consume parameter name
if !d.Next() { if !d.Next() {
return "", d.ArgErr() return "", d.ArgErr()
} }
@@ -446,43 +405,20 @@ func parseOptPersistConfig(d *caddyfile.Dispenser, _ any) (any, error) {
} }
func parseOptAutoHTTPS(d *caddyfile.Dispenser, _ any) (any, error) { func parseOptAutoHTTPS(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() // consume option name d.Next() // consume parameter name
val := d.RemainingArgs() if !d.Next() {
if len(val) == 0 {
return "", d.ArgErr() return "", d.ArgErr()
} }
for _, v := range val { val := d.Val()
switch v { if d.Next() {
case "off": return "", d.ArgErr()
case "disable_redirects": }
case "disable_certs": if val != "off" && val != "disable_redirects" && val != "disable_certs" && val != "ignore_loaded_certs" {
case "ignore_loaded_certs": return "", d.Errf("auto_https must be one of 'off', 'disable_redirects', 'disable_certs', or 'ignore_loaded_certs'")
case "prefer_wildcard":
default:
return "", d.Errf("auto_https must be one of 'off', 'disable_redirects', 'disable_certs', 'ignore_loaded_certs', or 'prefer_wildcard'")
}
} }
return val, nil return val, nil
} }
func unmarshalCaddyfileMetricsOptions(d *caddyfile.Dispenser) (any, error) {
d.Next() // consume option name
metrics := new(caddyhttp.Metrics)
for d.NextBlock(0) {
switch d.Val() {
case "per_host":
metrics.PerHost = true
default:
return nil, d.Errf("unrecognized servers option '%s'", d.Val())
}
}
return metrics, nil
}
func parseMetricsOptions(d *caddyfile.Dispenser, _ any) (any, error) {
return unmarshalCaddyfileMetricsOptions(d)
}
func parseServerOptions(d *caddyfile.Dispenser, _ any) (any, error) { func parseServerOptions(d *caddyfile.Dispenser, _ any) (any, error) {
return unmarshalCaddyfileServerOptions(d) return unmarshalCaddyfileServerOptions(d)
} }
@@ -552,74 +488,3 @@ func parseOptPreferredChains(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() d.Next()
return caddytls.ParseCaddyfilePreferredChainsOptions(d) return caddytls.ParseCaddyfilePreferredChainsOptions(d)
} }
func parseOptDNS(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() // consume option name
optName := d.Val()
// get DNS module name
if !d.Next() {
// this is allowed if this is the "acme_dns" option since it may refer to the globally-configured "dns" option's value
if optName == "acme_dns" {
return nil, nil
}
return nil, d.ArgErr()
}
modID := "dns.providers." + d.Val()
unm, err := caddyfile.UnmarshalModule(d, modID)
if err != nil {
return nil, err
}
switch unm.(type) {
case libdns.RecordGetter,
libdns.RecordSetter,
libdns.RecordAppender,
libdns.RecordDeleter:
default:
return nil, d.Errf("module %s (%T) is not a libdns provider", modID, unm)
}
return unm, nil
}
func parseOptECH(d *caddyfile.Dispenser, _ any) (any, error) {
d.Next() // consume option name
ech := new(caddytls.ECH)
publicNames := d.RemainingArgs()
for _, publicName := range publicNames {
ech.Configs = append(ech.Configs, caddytls.ECHConfiguration{
PublicName: publicName,
})
}
if len(ech.Configs) == 0 {
return nil, d.ArgErr()
}
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "dns":
if !d.Next() {
return nil, d.ArgErr()
}
providerName := d.Val()
modID := "dns.providers." + providerName
unm, err := caddyfile.UnmarshalModule(d, modID)
if err != nil {
return nil, err
}
ech.Publication = append(ech.Publication, &caddytls.ECHPublication{
Configs: publicNames,
PublishersRaw: caddy.ModuleMap{
"dns": caddyconfig.JSON(caddytls.ECHDNSPublisher{
ProviderRaw: caddyconfig.JSONModuleObject(unm, "name", providerName, nil),
}, nil),
},
})
default:
return nil, d.Errf("ech: unrecognized subdirective '%s'", d.Val())
}
}
return ech, nil
}
+112 -124
View File
@@ -15,8 +15,6 @@
package httpcaddyfile package httpcaddyfile
import ( import (
"slices"
"github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig" "github.com/caddyserver/caddy/v2/caddyconfig"
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
@@ -50,124 +48,124 @@ func init() {
// //
// When the CA ID is unspecified, 'local' is assumed. // When the CA ID is unspecified, 'local' is assumed.
func parsePKIApp(d *caddyfile.Dispenser, existingVal any) (any, error) { func parsePKIApp(d *caddyfile.Dispenser, existingVal any) (any, error) {
d.Next() // consume app name pki := &caddypki.PKI{CAs: make(map[string]*caddypki.CA)}
pki := &caddypki.PKI{ for d.Next() {
CAs: make(map[string]*caddypki.CA), for nesting := d.Nesting(); d.NextBlock(nesting); {
} switch d.Val() {
for d.NextBlock(0) { case "ca":
switch d.Val() { pkiCa := new(caddypki.CA)
case "ca":
pkiCa := new(caddypki.CA)
if d.NextArg() {
pkiCa.ID = d.Val()
if d.NextArg() { if d.NextArg() {
return nil, d.ArgErr() pkiCa.ID = d.Val()
if d.NextArg() {
return nil, d.ArgErr()
}
} }
} if pkiCa.ID == "" {
if pkiCa.ID == "" { pkiCa.ID = caddypki.DefaultCAID
pkiCa.ID = caddypki.DefaultCAID
}
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "name":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.Name = d.Val()
case "root_cn":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.RootCommonName = d.Val()
case "intermediate_cn":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.IntermediateCommonName = d.Val()
case "intermediate_lifetime":
if !d.NextArg() {
return nil, d.ArgErr()
}
dur, err := caddy.ParseDuration(d.Val())
if err != nil {
return nil, err
}
pkiCa.IntermediateLifetime = caddy.Duration(dur)
case "root":
if pkiCa.Root == nil {
pkiCa.Root = new(caddypki.KeyPair)
}
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "cert":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.Root.Certificate = d.Val()
case "key":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.Root.PrivateKey = d.Val()
case "format":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.Root.Format = d.Val()
default:
return nil, d.Errf("unrecognized pki ca root option '%s'", d.Val())
}
}
case "intermediate":
if pkiCa.Intermediate == nil {
pkiCa.Intermediate = new(caddypki.KeyPair)
}
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "cert":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.Intermediate.Certificate = d.Val()
case "key":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.Intermediate.PrivateKey = d.Val()
case "format":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.Intermediate.Format = d.Val()
default:
return nil, d.Errf("unrecognized pki ca intermediate option '%s'", d.Val())
}
}
default:
return nil, d.Errf("unrecognized pki ca option '%s'", d.Val())
} }
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "name":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.Name = d.Val()
case "root_cn":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.RootCommonName = d.Val()
case "intermediate_cn":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.IntermediateCommonName = d.Val()
case "intermediate_lifetime":
if !d.NextArg() {
return nil, d.ArgErr()
}
dur, err := caddy.ParseDuration(d.Val())
if err != nil {
return nil, err
}
pkiCa.IntermediateLifetime = caddy.Duration(dur)
case "root":
if pkiCa.Root == nil {
pkiCa.Root = new(caddypki.KeyPair)
}
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "cert":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.Root.Certificate = d.Val()
case "key":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.Root.PrivateKey = d.Val()
case "format":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.Root.Format = d.Val()
default:
return nil, d.Errf("unrecognized pki ca root option '%s'", d.Val())
}
}
case "intermediate":
if pkiCa.Intermediate == nil {
pkiCa.Intermediate = new(caddypki.KeyPair)
}
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "cert":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.Intermediate.Certificate = d.Val()
case "key":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.Intermediate.PrivateKey = d.Val()
case "format":
if !d.NextArg() {
return nil, d.ArgErr()
}
pkiCa.Intermediate.Format = d.Val()
default:
return nil, d.Errf("unrecognized pki ca intermediate option '%s'", d.Val())
}
}
default:
return nil, d.Errf("unrecognized pki ca option '%s'", d.Val())
}
}
pki.CAs[pkiCa.ID] = pkiCa
default:
return nil, d.Errf("unrecognized pki option '%s'", d.Val())
} }
pki.CAs[pkiCa.ID] = pkiCa
default:
return nil, d.Errf("unrecognized pki option '%s'", d.Val())
} }
} }
return pki, nil return pki, nil
} }
@@ -180,15 +178,6 @@ func (st ServerType) buildPKIApp(
if _, ok := options["skip_install_trust"]; ok { if _, ok := options["skip_install_trust"]; ok {
skipInstallTrust = true 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 falseBool := false
// Load the PKI app configured via global options // Load the PKI app configured via global options
@@ -229,8 +218,7 @@ func (st ServerType) buildPKIApp(
// if there was no CAs defined in any of the servers, // if there was no CAs defined in any of the servers,
// and we were requested to not install trust, then // and we were requested to not install trust, then
// add one for the default/local CA to do so // add one for the default/local CA to do so
// only if auto_https is not completely disabled if len(pkiApp.CAs) == 0 && skipInstallTrust {
if len(pkiApp.CAs) == 0 && skipInstallTrust && !autoHTTPSOff {
ca := new(caddypki.CA) ca := new(caddypki.CA)
ca.ID = caddypki.DefaultCAID ca.ID = caddypki.DefaultCAID
ca.InstallTrust = &falseBool ca.InstallTrust = &falseBool
+216 -233
View File
@@ -17,8 +17,6 @@ package httpcaddyfile
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"slices"
"strconv"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
@@ -43,253 +41,240 @@ type serverOptions struct {
WriteTimeout caddy.Duration WriteTimeout caddy.Duration
IdleTimeout caddy.Duration IdleTimeout caddy.Duration
KeepAliveInterval caddy.Duration KeepAliveInterval caddy.Duration
KeepAliveIdle caddy.Duration
KeepAliveCount int
MaxHeaderBytes int MaxHeaderBytes int
EnableFullDuplex bool EnableFullDuplex bool
Protocols []string Protocols []string
StrictSNIHost *bool StrictSNIHost *bool
TrustedProxiesRaw json.RawMessage TrustedProxiesRaw json.RawMessage
TrustedProxiesStrict int
TrustedProxiesUnix bool
ClientIPHeaders []string ClientIPHeaders []string
ShouldLogCredentials bool ShouldLogCredentials bool
Metrics *caddyhttp.Metrics Metrics *caddyhttp.Metrics
Trace bool // TODO: EXPERIMENTAL
} }
func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) { func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
d.Next() // consume option name
serverOpts := serverOptions{} serverOpts := serverOptions{}
if d.NextArg() { for d.Next() {
serverOpts.ListenerAddress = d.Val()
if d.NextArg() { if d.NextArg() {
return nil, d.ArgErr() serverOpts.ListenerAddress = d.Val()
} if d.NextArg() {
}
for d.NextBlock(0) {
switch d.Val() {
case "name":
if serverOpts.ListenerAddress == "" {
return nil, d.Errf("cannot set a name for a server without a listener address")
}
if !d.NextArg() {
return nil, d.ArgErr() return nil, d.ArgErr()
} }
serverOpts.Name = d.Val() }
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "name":
if serverOpts.ListenerAddress == "" {
return nil, d.Errf("cannot set a name for a server without a listener address")
}
if !d.NextArg() {
return nil, d.ArgErr()
}
serverOpts.Name = d.Val()
case "listener_wrappers": case "listener_wrappers":
for nesting := d.Nesting(); d.NextBlock(nesting); { for nesting := d.Nesting(); d.NextBlock(nesting); {
modID := "caddy.listeners." + d.Val() modID := "caddy.listeners." + d.Val()
unm, err := caddyfile.UnmarshalModule(d, modID)
if err != nil {
return nil, err
}
listenerWrapper, ok := unm.(caddy.ListenerWrapper)
if !ok {
return nil, fmt.Errorf("module %s (%T) is not a listener wrapper", modID, unm)
}
jsonListenerWrapper := caddyconfig.JSONModuleObject(
listenerWrapper,
"wrapper",
listenerWrapper.(caddy.Module).CaddyModule().ID.Name(),
nil,
)
serverOpts.ListenerWrappersRaw = append(serverOpts.ListenerWrappersRaw, jsonListenerWrapper)
}
case "timeouts":
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "read_body":
if !d.NextArg() {
return nil, d.ArgErr()
}
dur, err := caddy.ParseDuration(d.Val())
if err != nil {
return nil, d.Errf("parsing read_body timeout duration: %v", err)
}
serverOpts.ReadTimeout = caddy.Duration(dur)
case "read_header":
if !d.NextArg() {
return nil, d.ArgErr()
}
dur, err := caddy.ParseDuration(d.Val())
if err != nil {
return nil, d.Errf("parsing read_header timeout duration: %v", err)
}
serverOpts.ReadHeaderTimeout = caddy.Duration(dur)
case "write":
if !d.NextArg() {
return nil, d.ArgErr()
}
dur, err := caddy.ParseDuration(d.Val())
if err != nil {
return nil, d.Errf("parsing write timeout duration: %v", err)
}
serverOpts.WriteTimeout = caddy.Duration(dur)
case "idle":
if !d.NextArg() {
return nil, d.ArgErr()
}
dur, err := caddy.ParseDuration(d.Val())
if err != nil {
return nil, d.Errf("parsing idle timeout duration: %v", err)
}
serverOpts.IdleTimeout = caddy.Duration(dur)
default:
return nil, d.Errf("unrecognized timeouts option '%s'", d.Val())
}
}
case "keepalive_interval":
if !d.NextArg() {
return nil, d.ArgErr()
}
dur, err := caddy.ParseDuration(d.Val())
if err != nil {
return nil, d.Errf("parsing keepalive interval duration: %v", err)
}
serverOpts.KeepAliveInterval = caddy.Duration(dur)
case "max_header_size":
var sizeStr string
if !d.AllArgs(&sizeStr) {
return nil, d.ArgErr()
}
size, err := humanize.ParseBytes(sizeStr)
if err != nil {
return nil, d.Errf("parsing max_header_size: %v", err)
}
serverOpts.MaxHeaderBytes = int(size)
case "enable_full_duplex":
if d.NextArg() {
return nil, d.ArgErr()
}
serverOpts.EnableFullDuplex = true
case "log_credentials":
if d.NextArg() {
return nil, d.ArgErr()
}
serverOpts.ShouldLogCredentials = true
case "protocols":
protos := d.RemainingArgs()
for _, proto := range protos {
if proto != "h1" && proto != "h2" && proto != "h2c" && proto != "h3" {
return nil, d.Errf("unknown protocol '%s': expected h1, h2, h2c, or h3", proto)
}
if sliceContains(serverOpts.Protocols, proto) {
return nil, d.Errf("protocol %s specified more than once", proto)
}
serverOpts.Protocols = append(serverOpts.Protocols, proto)
}
if nesting := d.Nesting(); d.NextBlock(nesting) {
return nil, d.ArgErr()
}
case "strict_sni_host":
if d.NextArg() && d.Val() != "insecure_off" && d.Val() != "on" {
return nil, d.Errf("strict_sni_host only supports 'on' or 'insecure_off', got '%s'", d.Val())
}
boolVal := true
if d.Val() == "insecure_off" {
boolVal = false
}
serverOpts.StrictSNIHost = &boolVal
case "trusted_proxies":
if !d.NextArg() {
return nil, d.Err("trusted_proxies expects an IP range source module name as its first argument")
}
modID := "http.ip_sources." + d.Val()
unm, err := caddyfile.UnmarshalModule(d, modID) unm, err := caddyfile.UnmarshalModule(d, modID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
listenerWrapper, ok := unm.(caddy.ListenerWrapper) source, ok := unm.(caddyhttp.IPRangeSource)
if !ok { if !ok {
return nil, fmt.Errorf("module %s (%T) is not a listener wrapper", modID, unm) return nil, fmt.Errorf("module %s (%T) is not an IP range source", modID, unm)
} }
jsonListenerWrapper := caddyconfig.JSONModuleObject( jsonSource := caddyconfig.JSONModuleObject(
listenerWrapper, source,
"wrapper", "source",
listenerWrapper.(caddy.Module).CaddyModule().ID.Name(), source.(caddy.Module).CaddyModule().ID.Name(),
nil, nil,
) )
serverOpts.ListenerWrappersRaw = append(serverOpts.ListenerWrappersRaw, jsonListenerWrapper) serverOpts.TrustedProxiesRaw = jsonSource
}
case "timeouts": case "client_ip_headers":
for nesting := d.Nesting(); d.NextBlock(nesting); { headers := d.RemainingArgs()
switch d.Val() { for _, header := range headers {
case "read_body": if sliceContains(serverOpts.ClientIPHeaders, header) {
if !d.NextArg() { return nil, d.Errf("client IP header %s specified more than once", header)
return nil, d.ArgErr()
} }
dur, err := caddy.ParseDuration(d.Val()) serverOpts.ClientIPHeaders = append(serverOpts.ClientIPHeaders, header)
if err != nil {
return nil, d.Errf("parsing read_body timeout duration: %v", err)
}
serverOpts.ReadTimeout = caddy.Duration(dur)
case "read_header":
if !d.NextArg() {
return nil, d.ArgErr()
}
dur, err := caddy.ParseDuration(d.Val())
if err != nil {
return nil, d.Errf("parsing read_header timeout duration: %v", err)
}
serverOpts.ReadHeaderTimeout = caddy.Duration(dur)
case "write":
if !d.NextArg() {
return nil, d.ArgErr()
}
dur, err := caddy.ParseDuration(d.Val())
if err != nil {
return nil, d.Errf("parsing write timeout duration: %v", err)
}
serverOpts.WriteTimeout = caddy.Duration(dur)
case "idle":
if !d.NextArg() {
return nil, d.ArgErr()
}
dur, err := caddy.ParseDuration(d.Val())
if err != nil {
return nil, d.Errf("parsing idle timeout duration: %v", err)
}
serverOpts.IdleTimeout = caddy.Duration(dur)
default:
return nil, d.Errf("unrecognized timeouts option '%s'", d.Val())
} }
} if nesting := d.Nesting(); d.NextBlock(nesting) {
return nil, d.ArgErr()
case "keepalive_interval":
if !d.NextArg() {
return nil, d.ArgErr()
}
dur, err := caddy.ParseDuration(d.Val())
if err != nil {
return nil, d.Errf("parsing keepalive interval duration: %v", err)
}
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) {
return nil, d.ArgErr()
}
size, err := humanize.ParseBytes(sizeStr)
if err != nil {
return nil, d.Errf("parsing max_header_size: %v", err)
}
serverOpts.MaxHeaderBytes = int(size)
case "enable_full_duplex":
if d.NextArg() {
return nil, d.ArgErr()
}
serverOpts.EnableFullDuplex = true
case "log_credentials":
if d.NextArg() {
return nil, d.ArgErr()
}
serverOpts.ShouldLogCredentials = true
case "protocols":
protos := d.RemainingArgs()
for _, proto := range protos {
if proto != "h1" && proto != "h2" && proto != "h2c" && proto != "h3" {
return nil, d.Errf("unknown protocol '%s': expected h1, h2, h2c, or h3", proto)
} }
if slices.Contains(serverOpts.Protocols, proto) {
return nil, d.Errf("protocol %s specified more than once", proto) case "metrics":
if d.NextArg() {
return nil, d.ArgErr()
} }
serverOpts.Protocols = append(serverOpts.Protocols, proto) if nesting := d.Nesting(); d.NextBlock(nesting) {
} return nil, d.ArgErr()
if nesting := d.Nesting(); d.NextBlock(nesting) {
return nil, d.ArgErr()
}
case "strict_sni_host":
if d.NextArg() && d.Val() != "insecure_off" && d.Val() != "on" {
return nil, d.Errf("strict_sni_host only supports 'on' or 'insecure_off', got '%s'", d.Val())
}
boolVal := true
if d.Val() == "insecure_off" {
boolVal = false
}
serverOpts.StrictSNIHost = &boolVal
case "trusted_proxies":
if !d.NextArg() {
return nil, d.Err("trusted_proxies expects an IP range source module name as its first argument")
}
modID := "http.ip_sources." + d.Val()
unm, err := caddyfile.UnmarshalModule(d, modID)
if err != nil {
return nil, err
}
source, ok := unm.(caddyhttp.IPRangeSource)
if !ok {
return nil, fmt.Errorf("module %s (%T) is not an IP range source", modID, unm)
}
jsonSource := caddyconfig.JSONModuleObject(
source,
"source",
source.(caddy.Module).CaddyModule().ID.Name(),
nil,
)
serverOpts.TrustedProxiesRaw = jsonSource
case "trusted_proxies_strict":
if d.NextArg() {
return nil, d.ArgErr()
}
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 {
if slices.Contains(serverOpts.ClientIPHeaders, header) {
return nil, d.Errf("client IP header %s specified more than once", header)
} }
serverOpts.ClientIPHeaders = append(serverOpts.ClientIPHeaders, header) serverOpts.Metrics = new(caddyhttp.Metrics)
}
if nesting := d.Nesting(); d.NextBlock(nesting) {
return nil, d.ArgErr()
}
case "metrics": // TODO: DEPRECATED. (August 2022)
caddy.Log().Warn("The nested 'metrics' option inside `servers` is deprecated and will be removed in the next major version. Use the global 'metrics' option instead.") case "protocol":
serverOpts.Metrics = new(caddyhttp.Metrics) caddy.Log().Named("caddyfile").Warn("DEPRECATED: protocol sub-option will be removed soon")
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() { for nesting := d.Nesting(); d.NextBlock(nesting); {
case "per_host": switch d.Val() {
serverOpts.Metrics.PerHost = true case "allow_h2c":
default: caddy.Log().Named("caddyfile").Warn("DEPRECATED: allow_h2c will be removed soon; use protocols option instead")
return nil, d.Errf("unrecognized metrics option '%s'", d.Val())
if d.NextArg() {
return nil, d.ArgErr()
}
if sliceContains(serverOpts.Protocols, "h2c") {
return nil, d.Errf("protocol h2c already specified")
}
serverOpts.Protocols = append(serverOpts.Protocols, "h2c")
case "strict_sni_host":
caddy.Log().Named("caddyfile").Warn("DEPRECATED: protocol > strict_sni_host in this position will be removed soon; move up to the servers block instead")
if d.NextArg() && d.Val() != "insecure_off" && d.Val() != "on" {
return nil, d.Errf("strict_sni_host only supports 'on' or 'insecure_off', got '%s'", d.Val())
}
boolVal := true
if d.Val() == "insecure_off" {
boolVal = false
}
serverOpts.StrictSNIHost = &boolVal
default:
return nil, d.Errf("unrecognized protocol option '%s'", d.Val())
}
} }
}
case "trace": default:
if d.NextArg() { return nil, d.Errf("unrecognized servers option '%s'", d.Val())
return nil, d.ArgErr()
} }
serverOpts.Trace = true
default:
return nil, d.Errf("unrecognized servers option '%s'", d.Val())
} }
} }
return serverOpts, nil return serverOpts, nil
@@ -299,7 +284,7 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
func applyServerOptions( func applyServerOptions(
servers map[string]*caddyhttp.Server, servers map[string]*caddyhttp.Server,
options map[string]any, options map[string]any,
_ *[]caddyconfig.Warning, warnings *[]caddyconfig.Warning,
) error { ) error {
serverOpts, ok := options["servers"].([]serverOptions) serverOpts, ok := options["servers"].([]serverOptions)
if !ok { if !ok {
@@ -323,15 +308,24 @@ func applyServerOptions(
for key, server := range servers { for key, server := range servers {
// find the options that apply to this server // find the options that apply to this server
optsIndex := slices.IndexFunc(serverOpts, func(s serverOptions) bool { opts := func() *serverOptions {
return s.ListenerAddress == "" || slices.Contains(server.Listen, s.ListenerAddress) for _, entry := range serverOpts {
}) if entry.ListenerAddress == "" {
return &entry
}
for _, listener := range server.Listen {
if entry.ListenerAddress == listener {
return &entry
}
}
}
return nil
}()
// if none apply, then move to the next server // if none apply, then move to the next server
if optsIndex == -1 { if opts == nil {
continue continue
} }
opts := serverOpts[optsIndex]
// set all the options // set all the options
server.ListenerWrappersRaw = opts.ListenerWrappersRaw server.ListenerWrappersRaw = opts.ListenerWrappersRaw
@@ -340,30 +334,19 @@ func applyServerOptions(
server.WriteTimeout = opts.WriteTimeout server.WriteTimeout = opts.WriteTimeout
server.IdleTimeout = opts.IdleTimeout server.IdleTimeout = opts.IdleTimeout
server.KeepAliveInterval = opts.KeepAliveInterval server.KeepAliveInterval = opts.KeepAliveInterval
server.KeepAliveIdle = opts.KeepAliveIdle
server.KeepAliveCount = opts.KeepAliveCount
server.MaxHeaderBytes = opts.MaxHeaderBytes server.MaxHeaderBytes = opts.MaxHeaderBytes
server.EnableFullDuplex = opts.EnableFullDuplex server.EnableFullDuplex = opts.EnableFullDuplex
server.Protocols = opts.Protocols server.Protocols = opts.Protocols
server.StrictSNIHost = opts.StrictSNIHost server.StrictSNIHost = opts.StrictSNIHost
server.TrustedProxiesRaw = opts.TrustedProxiesRaw server.TrustedProxiesRaw = opts.TrustedProxiesRaw
server.ClientIPHeaders = opts.ClientIPHeaders server.ClientIPHeaders = opts.ClientIPHeaders
server.TrustedProxiesStrict = opts.TrustedProxiesStrict
server.TrustedProxiesUnix = opts.TrustedProxiesUnix
server.Metrics = opts.Metrics server.Metrics = opts.Metrics
if opts.ShouldLogCredentials { if opts.ShouldLogCredentials {
if server.Logs == nil { if server.Logs == nil {
server.Logs = new(caddyhttp.ServerLogConfig) server.Logs = &caddyhttp.ServerLogConfig{}
} }
server.Logs.ShouldLogCredentials = opts.ShouldLogCredentials server.Logs.ShouldLogCredentials = opts.ShouldLogCredentials
} }
if opts.Trace {
// TODO: THIS IS EXPERIMENTAL (MAY 2024)
if server.Logs == nil {
server.Logs = new(caddyhttp.ServerLogConfig)
}
server.Logs.Trace = opts.Trace
}
if opts.Name != "" { if opts.Name != "" {
nameReplacements[key] = opts.Name nameReplacements[key] = opts.Name
+4 -16
View File
@@ -33,10 +33,9 @@ func NewShorthandReplacer() ShorthandReplacer {
{regexp.MustCompile(`{path\.([\w-]*)}`), "{http.request.uri.path.$1}"}, {regexp.MustCompile(`{path\.([\w-]*)}`), "{http.request.uri.path.$1}"},
{regexp.MustCompile(`{file\.([\w-]*)}`), "{http.request.uri.path.file.$1}"}, {regexp.MustCompile(`{file\.([\w-]*)}`), "{http.request.uri.path.file.$1}"},
{regexp.MustCompile(`{query\.([\w-]*)}`), "{http.request.uri.query.$1}"}, {regexp.MustCompile(`{query\.([\w-]*)}`), "{http.request.uri.query.$1}"},
{regexp.MustCompile(`{re\.([\w-\.]*)}`), "{http.regexp.$1}"}, {regexp.MustCompile(`{re\.([\w-]*)\.([\w-]*)}`), "{http.regexp.$1.$2}"},
{regexp.MustCompile(`{vars\.([\w-]*)}`), "{http.vars.$1}"}, {regexp.MustCompile(`{vars\.([\w-]*)}`), "{http.vars.$1}"},
{regexp.MustCompile(`{rp\.([\w-\.]*)}`), "{http.reverse_proxy.$1}"}, {regexp.MustCompile(`{rp\.([\w-\.]*)}`), "{http.reverse_proxy.$1}"},
{regexp.MustCompile(`{resp\.([\w-\.]*)}`), "{http.intercept.$1}"},
{regexp.MustCompile(`{err\.([\w-\.]*)}`), "{http.error.$1}"}, {regexp.MustCompile(`{err\.([\w-\.]*)}`), "{http.error.$1}"},
{regexp.MustCompile(`{file_match\.([\w-]*)}`), "{http.matchers.file.$1}"}, {regexp.MustCompile(`{file_match\.([\w-]*)}`), "{http.matchers.file.$1}"},
} }
@@ -52,30 +51,19 @@ func NewShorthandReplacer() ShorthandReplacer {
// be used in the Caddyfile, and the right is the replacement. // be used in the Caddyfile, and the right is the replacement.
func placeholderShorthands() []string { func placeholderShorthands() []string {
return []string{ return []string{
"{dir}", "{http.request.uri.path.dir}",
"{file}", "{http.request.uri.path.file}",
"{host}", "{http.request.host}", "{host}", "{http.request.host}",
"{hostport}", "{http.request.hostport}", "{hostport}", "{http.request.hostport}",
"{port}", "{http.request.port}", "{port}", "{http.request.port}",
"{orig_method}", "{http.request.orig_method}",
"{orig_uri}", "{http.request.orig_uri}",
"{orig_path}", "{http.request.orig_uri.path}",
"{orig_dir}", "{http.request.orig_uri.path.dir}",
"{orig_file}", "{http.request.orig_uri.path.file}",
"{orig_query}", "{http.request.orig_uri.query}",
"{orig_?query}", "{http.request.orig_uri.prefixed_query}",
"{method}", "{http.request.method}", "{method}", "{http.request.method}",
"{uri}", "{http.request.uri}",
"{%uri}", "{http.request.uri_escaped}",
"{path}", "{http.request.uri.path}", "{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}",
"{%query}", "{http.request.uri.query_escaped}",
"{?query}", "{http.request.uri.prefixed_query}",
"{remote}", "{http.request.remote}", "{remote}", "{http.request.remote}",
"{remote_host}", "{http.request.remote.host}", "{remote_host}", "{http.request.remote.host}",
"{remote_port}", "{http.request.remote.port}", "{remote_port}", "{http.request.remote.port}",
"{scheme}", "{http.request.scheme}", "{scheme}", "{http.request.scheme}",
"{uri}", "{http.request.uri}",
"{uuid}", "{http.request.uuid}", "{uuid}", "{http.request.uuid}",
"{tls_cipher}", "{http.request.tls.cipher_suite}", "{tls_cipher}", "{http.request.tls.cipher_suite}",
"{tls_version}", "{http.request.tls.version}", "{tls_version}", "{http.request.tls.version}",
+59 -251
View File
@@ -19,13 +19,12 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"reflect" "reflect"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"github.com/caddyserver/certmagic" "github.com/caddyserver/certmagic"
"github.com/mholt/acmez/v3/acme" "github.com/mholt/acmez/acme"
"github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddyconfig" "github.com/caddyserver/caddy/v2/caddyconfig"
@@ -45,8 +44,8 @@ func (st ServerType) buildTLSApp(
if hp, ok := options["http_port"].(int); ok { if hp, ok := options["http_port"].(int); ok {
httpPort = strconv.Itoa(hp) httpPort = strconv.Itoa(hp)
} }
autoHTTPS := []string{} autoHTTPS := "on"
if ah, ok := options["auto_https"].([]string); ok { if ah, ok := options["auto_https"].(string); ok {
autoHTTPS = ah autoHTTPS = ah
} }
@@ -54,25 +53,23 @@ func (st ServerType) buildTLSApp(
// key, so that they don't get forgotten/omitted by auto-HTTPS // key, so that they don't get forgotten/omitted by auto-HTTPS
// (since they won't appear in route matchers) // (since they won't appear in route matchers)
httpsHostsSharedWithHostlessKey := make(map[string]struct{}) httpsHostsSharedWithHostlessKey := make(map[string]struct{})
if !slices.Contains(autoHTTPS, "off") { if autoHTTPS != "off" {
for _, pair := range pairings { for _, pair := range pairings {
for _, sb := range pair.serverBlocks { for _, sb := range pair.serverBlocks {
for _, addr := range sb.parsedKeys { for _, addr := range sb.keys {
if addr.Host != "" { if addr.Host == "" {
continue // this server block has a hostless key, now
} // go through and add all the hosts to the set
for _, otherAddr := range sb.keys {
// this server block has a hostless key, now if otherAddr.Original == addr.Original {
// go through and add all the hosts to the set continue
for _, otherAddr := range sb.parsedKeys { }
if otherAddr.Original == addr.Original { if otherAddr.Host != "" && otherAddr.Scheme != "http" && otherAddr.Port != httpPort {
continue httpsHostsSharedWithHostlessKey[otherAddr.Host] = struct{}{}
} }
if otherAddr.Host != "" && otherAddr.Scheme != "http" && otherAddr.Port != httpPort {
httpsHostsSharedWithHostlessKey[otherAddr.Host] = struct{}{}
} }
break
} }
break
} }
} }
} }
@@ -92,33 +89,9 @@ func (st ServerType) buildTLSApp(
tlsApp.Automation.Policies = append(tlsApp.Automation.Policies, catchAllAP) 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 { for _, p := range pairings {
// avoid setting up TLS automation policies for a server that is HTTP-only // avoid setting up TLS automation policies for a server that is HTTP-only
var addresses []string if !listenersUseAnyPortOtherThan(p.addresses, httpPort) {
for _, addressWithProtocols := range p.addressesWithProtocols {
addresses = append(addresses, addressWithProtocols.address)
}
if !listenersUseAnyPortOtherThan(addresses, httpPort) {
continue continue
} }
@@ -135,12 +108,6 @@ func (st ServerType) buildTLSApp(
return nil, warnings, err 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) sblockHosts := sblock.hostsFromKeys(false)
if len(sblockHosts) == 0 && catchAllAP != nil { if len(sblockHosts) == 0 && catchAllAP != nil {
ap = catchAllAP ap = catchAllAP
@@ -151,18 +118,6 @@ func (st ServerType) buildTLSApp(
ap.OnDemand = true ap.OnDemand = true
} }
// collect hosts that are forced to have certs automated for their specific name
if _, ok := sblock.pile["tls.force_automate"]; ok {
for _, host := range sblockHosts {
forcedAutomatedNames[host] = struct{}{}
}
}
// reuse private keys tls
if _, ok := sblock.pile["tls.reuse_private_keys"]; ok {
ap.ReusePrivateKeys = true
}
if keyTypeVals, ok := sblock.pile["tls.key_type"]; ok { if keyTypeVals, ok := sblock.pile["tls.key_type"]; ok {
ap.KeyType = keyTypeVals[0].Value.(string) ap.KeyType = keyTypeVals[0].Value.(string)
} }
@@ -221,8 +176,8 @@ func (st ServerType) buildTLSApp(
if acmeIssuer.Challenges.BindHost == "" { if acmeIssuer.Challenges.BindHost == "" {
// only binding to one host is supported // only binding to one host is supported
var bindHost string var bindHost string
if asserted, ok := cfgVal.Value.(addressesWithProtocols); ok && len(asserted.addresses) > 0 { if bindHosts, ok := cfgVal.Value.([]string); ok && len(bindHosts) > 0 {
bindHost = asserted.addresses[0] bindHost = bindHosts[0]
} }
acmeIssuer.Challenges.BindHost = bindHost acmeIssuer.Challenges.BindHost = bindHost
} }
@@ -250,21 +205,9 @@ func (st ServerType) buildTLSApp(
catchAllAP = ap catchAllAP = ap
} }
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 // associate our new automation policy with this server block's hosts
ap.SubjectsRaw = hostsNotHTTP ap.SubjectsRaw = sblock.hostsFromKeysNotHTTP(httpPort)
sort.Strings(ap.SubjectsRaw) // solely for deterministic test results
// if a combination of public and internal names were given // if a combination of public and internal names were given
// for this same server block and no issuer was specified, we // for this same server block and no issuer was specified, we
@@ -276,7 +219,7 @@ func (st ServerType) buildTLSApp(
var internal, external []string var internal, external []string
for _, s := range ap.SubjectsRaw { for _, s := range ap.SubjectsRaw {
// do not create Issuers for Tailscale domains; they will be given a Manager instead // do not create Issuers for Tailscale domains; they will be given a Manager instead
if isTailscaleDomain(s) { if strings.HasSuffix(strings.ToLower(s), ".ts.net") {
continue continue
} }
if !certmagic.SubjectQualifiesForCert(s) { if !certmagic.SubjectQualifiesForCert(s) {
@@ -303,7 +246,6 @@ func (st ServerType) buildTLSApp(
ap2.IssuersRaw = []json.RawMessage{caddyconfig.JSONModuleObject(caddytls.InternalIssuer{}, "module", "internal", &warnings)} ap2.IssuersRaw = []json.RawMessage{caddyconfig.JSONModuleObject(caddytls.InternalIssuer{}, "module", "internal", &warnings)}
} }
} }
if tlsApp.Automation == nil { if tlsApp.Automation == nil {
tlsApp.Automation = new(caddytls.AutomationConfig) tlsApp.Automation = new(caddytls.AutomationConfig)
} }
@@ -338,7 +280,7 @@ func (st ServerType) buildTLSApp(
combined = reflect.New(reflect.TypeOf(cl)).Elem() combined = reflect.New(reflect.TypeOf(cl)).Elem()
} }
clVal := reflect.ValueOf(cl) clVal := reflect.ValueOf(cl)
for i := range clVal.Len() { for i := 0; i < clVal.Len(); i++ {
combined = reflect.Append(combined, clVal.Index(i)) combined = reflect.Append(combined, clVal.Index(i))
} }
loadersByName[name] = combined.Interface().(caddytls.CertificateLoader) loadersByName[name] = combined.Interface().(caddytls.CertificateLoader)
@@ -357,42 +299,6 @@ func (st ServerType) buildTLSApp(
tlsApp.Automation.OnDemand = onDemand tlsApp.Automation.OnDemand = onDemand
} }
// set up "global" (to the TLS app) DNS provider config
if globalDNS, ok := options["dns"]; ok && globalDNS != nil {
tlsApp.DNSRaw = caddyconfig.JSONModuleObject(globalDNS, "name", globalDNS.(caddy.Module).CaddyModule().ID.Name(), nil)
}
// set up ECH from Caddyfile options
if ech, ok := options["ech"].(*caddytls.ECH); ok {
tlsApp.EncryptedClientHello = ech
// outer server names will need certificates, so make sure they're included
// in an automation policy for them that applies any global options
ap, err := newBaseAutomationPolicy(options, warnings, true)
if err != nil {
return nil, warnings, err
}
for _, cfg := range ech.Configs {
if cfg.PublicName != "" {
ap.SubjectsRaw = append(ap.SubjectsRaw, cfg.PublicName)
}
}
if tlsApp.Automation == nil {
tlsApp.Automation = new(caddytls.AutomationConfig)
}
tlsApp.Automation.Policies = append(tlsApp.Automation.Policies, ap)
}
// if the storage clean interval is a boolean, then it's "off" to disable cleaning
if sc, ok := options["storage_check"].(string); ok && sc == "off" {
tlsApp.DisableStorageCheck = true
}
// if the storage clean interval is a boolean, then it's "off" to disable cleaning
if sci, ok := options["storage_clean_interval"].(bool); ok && !sci {
tlsApp.DisableStorageClean = true
}
// set the storage clean interval if configured // set the storage clean interval if configured
if storageCleanInterval, ok := options["storage_clean_interval"].(caddy.Duration); ok { if storageCleanInterval, ok := options["storage_clean_interval"].(caddy.Duration); ok {
if tlsApp.Automation == nil { if tlsApp.Automation == nil {
@@ -433,7 +339,7 @@ func (st ServerType) buildTLSApp(
internalAP := &caddytls.AutomationPolicy{ internalAP := &caddytls.AutomationPolicy{
IssuersRaw: []json.RawMessage{json.RawMessage(`{"module":"internal"}`)}, IssuersRaw: []json.RawMessage{json.RawMessage(`{"module":"internal"}`)},
} }
if !slices.Contains(autoHTTPS, "off") && !slices.Contains(autoHTTPS, "disable_certs") { if autoHTTPS != "off" {
for h := range httpsHostsSharedWithHostlessKey { for h := range httpsHostsSharedWithHostlessKey {
al = append(al, h) al = append(al, h)
if !certmagic.SubjectQualifiesForPublicCert(h) { if !certmagic.SubjectQualifiesForPublicCert(h) {
@@ -441,13 +347,6 @@ func (st ServerType) buildTLSApp(
} }
} }
} }
for name := range forcedAutomatedNames {
if slices.Contains(al, name) {
continue
}
al = append(al, name)
}
slices.Sort(al) // to stabilize the adapt output
if len(al) > 0 { if len(al) > 0 {
tlsApp.CertificatesRaw["automate"] = caddyconfig.JSON(al, &warnings) tlsApp.CertificatesRaw["automate"] = caddyconfig.JSON(al, &warnings)
} }
@@ -464,22 +363,25 @@ func (st ServerType) buildTLSApp(
globalEmail := options["email"] globalEmail := options["email"]
globalACMECA := options["acme_ca"] globalACMECA := options["acme_ca"]
globalACMECARoot := options["acme_ca_root"] globalACMECARoot := options["acme_ca_root"]
_, globalACMEDNS := options["acme_dns"] // can be set to nil (to use globally-defined "dns" value instead), but it is still set globalACMEDNS := options["acme_dns"]
globalACMEEAB := options["acme_eab"] globalACMEEAB := options["acme_eab"]
globalPreferredChains := options["preferred_chains"] globalPreferredChains := options["preferred_chains"]
hasGlobalACMEDefaults := globalEmail != nil || globalACMECA != nil || globalACMECARoot != nil || globalACMEDNS || globalACMEEAB != nil || globalPreferredChains != nil hasGlobalACMEDefaults := globalEmail != nil || globalACMECA != nil || globalACMECARoot != nil || globalACMEDNS != nil || globalACMEEAB != nil || globalPreferredChains != nil
if hasGlobalACMEDefaults { if hasGlobalACMEDefaults {
for i := range tlsApp.Automation.Policies { for i := 0; i < len(tlsApp.Automation.Policies); i++ {
ap := tlsApp.Automation.Policies[i] ap := tlsApp.Automation.Policies[i]
if len(ap.Issuers) == 0 && automationPolicyHasAllPublicNames(ap) { if len(ap.Issuers) == 0 && automationPolicyHasAllPublicNames(ap) {
// for public names, create default issuers which will later be filled in with configured global defaults // for public names, create default issuers which will later be filled in with configured global defaults
// (internal names will implicitly use the internal issuer at auto-https time) // (internal names will implicitly use the internal issuer at auto-https time)
emailStr, _ := globalEmail.(string) ap.Issuers = caddytls.DefaultIssuers()
ap.Issuers = caddytls.DefaultIssuers(emailStr)
// if a specific endpoint is configured, can't use multiple default issuers // if a specific endpoint is configured, can't use multiple default issuers
if globalACMECA != nil { if globalACMECA != nil {
ap.Issuers = []certmagic.Issuer{new(caddytls.ACMEIssuer)} if strings.Contains(globalACMECA.(string), "zerossl") {
ap.Issuers = []certmagic.Issuer{&caddytls.ZeroSSLIssuer{ACMEIssuer: new(caddytls.ACMEIssuer)}}
} else {
ap.Issuers = []certmagic.Issuer{new(caddytls.ACMEIssuer)}
}
} }
} }
} }
@@ -549,12 +451,9 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
globalEmail := options["email"] globalEmail := options["email"]
globalACMECA := options["acme_ca"] globalACMECA := options["acme_ca"]
globalACMECARoot := options["acme_ca_root"] globalACMECARoot := options["acme_ca_root"]
globalACMEDNS, globalACMEDNSok := options["acme_dns"] // can be set to nil (to use globally-defined "dns" value instead), but it is still set globalACMEDNS := options["acme_dns"]
globalACMEEAB := options["acme_eab"] globalACMEEAB := options["acme_eab"]
globalPreferredChains := options["preferred_chains"] 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 == "" { if globalEmail != nil && acmeIssuer.Email == "" {
acmeIssuer.Email = globalEmail.(string) acmeIssuer.Email = globalEmail.(string)
@@ -562,24 +461,14 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
if globalACMECA != nil && acmeIssuer.CA == "" { if globalACMECA != nil && acmeIssuer.CA == "" {
acmeIssuer.CA = globalACMECA.(string) acmeIssuer.CA = globalACMECA.(string)
} }
if globalACMECARoot != nil && !slices.Contains(acmeIssuer.TrustedRootsPEMFiles, globalACMECARoot.(string)) { if globalACMECARoot != nil && !sliceContains(acmeIssuer.TrustedRootsPEMFiles, globalACMECARoot.(string)) {
acmeIssuer.TrustedRootsPEMFiles = append(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) { if globalACMEDNS != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.DNS == nil) {
globalDNS := options["dns"] acmeIssuer.Challenges = &caddytls.ChallengesConfig{
if globalDNS == nil && globalACMEDNS == nil { DNS: &caddytls.DNSChallengeConfig{
return fmt.Errorf("acme_dns specified without DNS provider config, but no provider specified with 'dns' global option") ProviderRaw: caddyconfig.JSONModuleObject(globalACMEDNS, "name", globalACMEDNS.(caddy.Module).CaddyModule().ID.Name(), nil),
} },
if acmeIssuer.Challenges == nil {
acmeIssuer.Challenges = new(caddytls.ChallengesConfig)
}
if acmeIssuer.Challenges.DNS == nil {
acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
}
// If global `dns` is set, do NOT set provider in issuer, just set empty dns config
if globalDNS == nil && acmeIssuer.Challenges.DNS.ProviderRaw == nil {
// Set a global DNS provider if `acme_dns` is set and `dns` is NOT set
acmeIssuer.Challenges.DNS.ProviderRaw = caddyconfig.JSONModuleObject(globalACMEDNS, "name", globalACMEDNS.(caddy.Module).CaddyModule().ID.Name(), nil)
} }
} }
if globalACMEEAB != nil && acmeIssuer.ExternalAccount == nil { if globalACMEEAB != nil && acmeIssuer.ExternalAccount == nil {
@@ -588,42 +477,6 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
if globalPreferredChains != nil && acmeIssuer.PreferredChains == nil { if globalPreferredChains != nil && acmeIssuer.PreferredChains == nil {
acmeIssuer.PreferredChains = globalPreferredChains.(*caddytls.ChainPreference) acmeIssuer.PreferredChains = globalPreferredChains.(*caddytls.ChainPreference)
} }
// only configure alt HTTP and TLS-ALPN ports if the DNS challenge is not enabled (wouldn't hurt, but isn't necessary since the DNS challenge is exclusive of others)
if globalHTTPPort != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.DNS == nil) && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.HTTP == nil || acmeIssuer.Challenges.HTTP.AlternatePort == 0) {
if acmeIssuer.Challenges == nil {
acmeIssuer.Challenges = new(caddytls.ChallengesConfig)
}
if acmeIssuer.Challenges.HTTP == nil {
acmeIssuer.Challenges.HTTP = new(caddytls.HTTPChallengeConfig)
}
acmeIssuer.Challenges.HTTP.AlternatePort = globalHTTPPort.(int)
}
if globalHTTPSPort != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.DNS == nil) && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.TLSALPN == nil || acmeIssuer.Challenges.TLSALPN.AlternatePort == 0) {
if acmeIssuer.Challenges == nil {
acmeIssuer.Challenges = new(caddytls.ChallengesConfig)
}
if acmeIssuer.Challenges.TLSALPN == nil {
acmeIssuer.Challenges.TLSALPN = new(caddytls.TLSALPNChallengeConfig)
}
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)
}
return nil return nil
} }
@@ -632,27 +485,17 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
// for any other automation policies. A nil policy (and no error) will be // for any other automation policies. A nil policy (and no error) will be
// returned if there are no default/global options. However, if always is // returned if there are no default/global options. However, if always is
// true, a non-nil value will always be returned (unless there is an error). // true, a non-nil value will always be returned (unless there is an error).
func newBaseAutomationPolicy( func newBaseAutomationPolicy(options map[string]any, warnings []caddyconfig.Warning, always bool) (*caddytls.AutomationPolicy, error) {
options map[string]any,
_ []caddyconfig.Warning,
always bool,
) (*caddytls.AutomationPolicy, error) {
issuers, hasIssuers := options["cert_issuer"] issuers, hasIssuers := options["cert_issuer"]
_, hasLocalCerts := options["local_certs"] _, hasLocalCerts := options["local_certs"]
keyType, hasKeyType := options["key_type"] keyType, hasKeyType := options["key_type"]
ocspStapling, hasOCSPStapling := options["ocsp_stapling"] ocspStapling, hasOCSPStapling := options["ocsp_stapling"]
hasGlobalAutomationOpts := hasIssuers || hasLocalCerts || hasKeyType || hasOCSPStapling
globalACMECA := options["acme_ca"] hasGlobalAutomationOpts := hasIssuers || hasLocalCerts || hasKeyType || hasOCSPStapling
globalACMECARoot := options["acme_ca_root"]
_, globalACMEDNS := options["acme_dns"] // can be set to nil (to use globally-defined "dns" value instead), but it is still set
globalACMEEAB := options["acme_eab"]
globalPreferredChains := options["preferred_chains"]
hasGlobalACMEDefaults := globalACMECA != nil || globalACMECARoot != nil || globalACMEDNS || globalACMEEAB != nil || globalPreferredChains != nil
// if there are no global options related to automation policies // if there are no global options related to automation policies
// set, then we can just return right away // set, then we can just return right away
if !hasGlobalAutomationOpts && !hasGlobalACMEDefaults { if !hasGlobalAutomationOpts {
if always { if always {
return new(caddytls.AutomationPolicy), nil return new(caddytls.AutomationPolicy), nil
} }
@@ -674,14 +517,6 @@ func newBaseAutomationPolicy(
ap.Issuers = []certmagic.Issuer{new(caddytls.InternalIssuer)} ap.Issuers = []certmagic.Issuer{new(caddytls.InternalIssuer)}
} }
if hasGlobalACMEDefaults {
for i := range ap.Issuers {
if err := fillInGlobalACMEDefaults(ap.Issuers[i], options); err != nil {
return nil, fmt.Errorf("filling in global issuer defaults for issuer %d: %v", i, err)
}
}
}
if hasOCSPStapling { if hasOCSPStapling {
ocspConfig := ocspStapling.(certmagic.OCSPConfig) ocspConfig := ocspStapling.(certmagic.OCSPConfig)
ap.DisableOCSPStapling = ocspConfig.DisableStapling ap.DisableOCSPStapling = ocspConfig.DisableStapling
@@ -716,7 +551,7 @@ func consolidateAutomationPolicies(aps []*caddytls.AutomationPolicy) []*caddytls
if !automationPolicyHasAllPublicNames(aps[i]) { if !automationPolicyHasAllPublicNames(aps[i]) {
// if this automation policy has internal names, we might as well remove it // if this automation policy has internal names, we might as well remove it
// so auto-https can implicitly use the internal issuer // so auto-https can implicitly use the internal issuer
aps = slices.Delete(aps, i, i+1) aps = append(aps[:i], aps[i+1:]...)
i-- i--
} }
} }
@@ -733,7 +568,7 @@ outer:
for j := i + 1; j < len(aps); j++ { for j := i + 1; j < len(aps); j++ {
// if they're exactly equal in every way, just keep one of them // if they're exactly equal in every way, just keep one of them
if reflect.DeepEqual(aps[i], aps[j]) { if reflect.DeepEqual(aps[i], aps[j]) {
aps = slices.Delete(aps, j, j+1) aps = append(aps[:j], aps[j+1:]...)
// must re-evaluate current i against next j; can't skip it! // must re-evaluate current i against next j; can't skip it!
// even if i decrements to -1, will be incremented to 0 immediately // even if i decrements to -1, will be incremented to 0 immediately
i-- i--
@@ -752,7 +587,6 @@ outer:
aps[i].MustStaple == aps[j].MustStaple && aps[i].MustStaple == aps[j].MustStaple &&
aps[i].KeyType == aps[j].KeyType && aps[i].KeyType == aps[j].KeyType &&
aps[i].OnDemand == aps[j].OnDemand && aps[i].OnDemand == aps[j].OnDemand &&
aps[i].ReusePrivateKeys == aps[j].ReusePrivateKeys &&
aps[i].RenewalWindowRatio == aps[j].RenewalWindowRatio { aps[i].RenewalWindowRatio == aps[j].RenewalWindowRatio {
if len(aps[i].SubjectsRaw) > 0 && len(aps[j].SubjectsRaw) == 0 { if len(aps[i].SubjectsRaw) > 0 && len(aps[j].SubjectsRaw) == 0 {
// later policy (at j) has no subjects ("catch-all"), so we can // later policy (at j) has no subjects ("catch-all"), so we can
@@ -763,18 +597,18 @@ outer:
// cause example.com to be served by the less specific policy for // cause example.com to be served by the less specific policy for
// '*.com', which might be different (yes we've seen this happen) // '*.com', which might be different (yes we've seen this happen)
if automationPolicyShadows(i, aps) >= j { if automationPolicyShadows(i, aps) >= j {
aps = slices.Delete(aps, i, i+1) aps = append(aps[:i], aps[i+1:]...)
i-- i--
continue outer continue outer
} }
} else { } else {
// avoid repeated subjects // avoid repeated subjects
for _, subj := range aps[j].SubjectsRaw { for _, subj := range aps[j].SubjectsRaw {
if !slices.Contains(aps[i].SubjectsRaw, subj) { if !sliceContains(aps[i].SubjectsRaw, subj) {
aps[i].SubjectsRaw = append(aps[i].SubjectsRaw, subj) aps[i].SubjectsRaw = append(aps[i].SubjectsRaw, subj)
} }
} }
aps = slices.Delete(aps, j, j+1) aps = append(aps[:j], aps[j+1:]...)
j-- j--
} }
} }
@@ -794,9 +628,13 @@ func automationPolicyIsSubset(a, b *caddytls.AutomationPolicy) bool {
return false return false
} }
for _, aSubj := range a.SubjectsRaw { for _, aSubj := range a.SubjectsRaw {
inSuperset := slices.ContainsFunc(b.SubjectsRaw, func(bSubj string) bool { var inSuperset bool
return certmagic.MatchWildcard(aSubj, bSubj) for _, bSubj := range b.SubjectsRaw {
}) if certmagic.MatchWildcard(aSubj, bSubj) {
inSuperset = true
break
}
}
if !inSuperset { if !inSuperset {
return false return false
} }
@@ -822,47 +660,17 @@ func automationPolicyShadows(i int, aps []*caddytls.AutomationPolicy) int {
// subjectQualifiesForPublicCert is like certmagic.SubjectQualifiesForPublicCert() except // subjectQualifiesForPublicCert is like certmagic.SubjectQualifiesForPublicCert() except
// that this allows domains with multiple wildcard levels like '*.*.example.com' to qualify // that this allows domains with multiple wildcard levels like '*.*.example.com' to qualify
// if the automation policy has OnDemand enabled (i.e. this function is more lenient). // if the automation policy has OnDemand enabled (i.e. this function is more lenient).
//
// IP subjects are considered as non-qualifying for public certs. Technically, there are
// now public ACME CAs as well as non-ACME CAs that issue IP certificates. But this function
// is used solely for implicit automation (defaults), where it gets really complicated to
// keep track of which issuers support IP certificates in which circumstances. Currently,
// issuers that support IP certificates are very few, and all require some sort of config
// from the user anyway (such as an account credential). Since we cannot implicitly and
// automatically get public IP certs without configuration from the user, we treat IPs as
// not qualifying for public certificates. Users should expressly configure an issuer
// that supports IP certs for that purpose.
func subjectQualifiesForPublicCert(ap *caddytls.AutomationPolicy, subj string) bool { func subjectQualifiesForPublicCert(ap *caddytls.AutomationPolicy, subj string) bool {
return !certmagic.SubjectIsIP(subj) && return !certmagic.SubjectIsIP(subj) &&
!certmagic.SubjectIsInternal(subj) && !certmagic.SubjectIsInternal(subj) &&
(strings.Count(subj, "*.") < 2 || ap.OnDemand) (strings.Count(subj, "*.") < 2 || ap.OnDemand)
} }
// automationPolicyHasAllPublicNames returns true if all the names on the policy
// do NOT qualify for public certs OR are tailscale domains.
func automationPolicyHasAllPublicNames(ap *caddytls.AutomationPolicy) bool { func automationPolicyHasAllPublicNames(ap *caddytls.AutomationPolicy) bool {
return !slices.ContainsFunc(ap.SubjectsRaw, func(i string) bool { for _, subj := range ap.SubjectsRaw {
return !subjectQualifiesForPublicCert(ap, i) || isTailscaleDomain(i) if !subjectQualifiesForPublicCert(ap, subj) {
}) return false
}
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 return true
} }
+9 -6
View File
@@ -35,7 +35,7 @@ func init() {
// If the response is not a JSON config, a config adapter must be specified // If the response is not a JSON config, a config adapter must be specified
// either in the loader config (`adapter`), or in the Content-Type HTTP header // either in the loader config (`adapter`), or in the Content-Type HTTP header
// returned in the HTTP response from the server. The Content-Type header is // returned in the HTTP response from the server. The Content-Type header is
// read just like the admin API's `/load` endpoint. If you don't have control // read just like the admin API's `/load` endpoint. Uf you don't have control
// over the HTTP server (but can still trust its response), you can override // over the HTTP server (but can still trust its response), you can override
// the Content-Type header by setting the `adapter` property in this config. // the Content-Type header by setting the `adapter` property in this config.
type HTTPLoader struct { type HTTPLoader struct {
@@ -181,16 +181,19 @@ func (hl HTTPLoader) makeClient(ctx caddy.Context) (*http.Client, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("getting server identity credentials: %v", err) return nil, fmt.Errorf("getting server identity credentials: %v", err)
} }
// See https://github.com/securego/gosec/issues/1054#issuecomment-2072235199 if tlsConfig == nil {
//nolint:gosec tlsConfig = new(tls.Config)
tlsConfig = &tls.Config{Certificates: certs} }
tlsConfig.Certificates = certs
} else if hl.TLS.ClientCertificateFile != "" && hl.TLS.ClientCertificateKeyFile != "" { } else if hl.TLS.ClientCertificateFile != "" && hl.TLS.ClientCertificateKeyFile != "" {
cert, err := tls.LoadX509KeyPair(hl.TLS.ClientCertificateFile, hl.TLS.ClientCertificateKeyFile) cert, err := tls.LoadX509KeyPair(hl.TLS.ClientCertificateFile, hl.TLS.ClientCertificateKeyFile)
if err != nil { if err != nil {
return nil, err return nil, err
} }
//nolint:gosec if tlsConfig == nil {
tlsConfig = &tls.Config{Certificates: []tls.Certificate{cert}} tlsConfig = new(tls.Config)
}
tlsConfig.Certificates = []tls.Certificate{cert}
} }
// trusted server certs // trusted server certs
-7
View File
@@ -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") caddy.Log().Named("admin.api").Info("load complete")
return nil return nil
+30 -65
View File
@@ -8,7 +8,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"log" "log"
"net" "net"
"net/http" "net/http"
@@ -31,12 +30,12 @@ import (
_ "github.com/caddyserver/caddy/v2/modules/standard" _ "github.com/caddyserver/caddy/v2/modules/standard"
) )
// Config store any configuration required to make the tests run // Defaults store any configuration required to make the tests run
type Config struct { type Defaults struct {
// Port we expect caddy to listening on // Port we expect caddy to listening on
AdminPort int AdminPort int
// Certificates we expect to be loaded before attempting to run the tests // Certificates we expect to be loaded before attempting to run the tests
Certificates []string Certifcates []string
// TestRequestTimeout is the time to wait for a http request to // TestRequestTimeout is the time to wait for a http request to
TestRequestTimeout time.Duration TestRequestTimeout time.Duration
// LoadRequestTimeout is the time to wait for the config to be loaded against the caddy server // LoadRequestTimeout is the time to wait for the config to be loaded against the caddy server
@@ -44,9 +43,9 @@ type Config struct {
} }
// Default testing values // Default testing values
var Default = Config{ var Default = Defaults{
AdminPort: 2999, // different from what a real server also running on a developer's machine might be AdminPort: 2999, // different from what a real server also running on a developer's machine might be
Certificates: []string{"/caddy.localhost.crt", "/caddy.localhost.key"}, Certifcates: []string{"/caddy.localhost.crt", "/caddy.localhost.key"},
TestRequestTimeout: 5 * time.Second, TestRequestTimeout: 5 * time.Second,
LoadRequestTimeout: 5 * time.Second, LoadRequestTimeout: 5 * time.Second,
} }
@@ -60,12 +59,11 @@ var (
type Tester struct { type Tester struct {
Client *http.Client Client *http.Client
configLoaded bool configLoaded bool
t testing.TB t *testing.T
config Config
} }
// NewTester will create a new testing client with an attached cookie jar // NewTester will create a new testing client with an attached cookie jar
func NewTester(t testing.TB) *Tester { func NewTester(t *testing.T) *Tester {
jar, err := cookiejar.New(nil) jar, err := cookiejar.New(nil)
if err != nil { if err != nil {
t.Fatalf("failed to create cookiejar: %s", err) t.Fatalf("failed to create cookiejar: %s", err)
@@ -79,29 +77,9 @@ func NewTester(t testing.TB) *Tester {
}, },
configLoaded: false, configLoaded: false,
t: t, t: t,
config: Default,
} }
} }
// WithDefaultOverrides this will override the default test configuration with the provided values.
func (tc *Tester) WithDefaultOverrides(overrides Config) *Tester {
if overrides.AdminPort != 0 {
tc.config.AdminPort = overrides.AdminPort
}
if len(overrides.Certificates) > 0 {
tc.config.Certificates = overrides.Certificates
}
if overrides.TestRequestTimeout != 0 {
tc.config.TestRequestTimeout = overrides.TestRequestTimeout
tc.Client.Timeout = overrides.TestRequestTimeout
}
if overrides.LoadRequestTimeout != 0 {
tc.config.LoadRequestTimeout = overrides.LoadRequestTimeout
}
return tc
}
type configLoadError struct { type configLoadError struct {
Response string Response string
} }
@@ -134,7 +112,7 @@ func (tc *Tester) initServer(rawConfig string, configType string) error {
return nil return nil
} }
err := validateTestPrerequisites(tc) err := validateTestPrerequisites(tc.t)
if err != nil { if err != nil {
tc.t.Skipf("skipping tests as failed integration prerequisites. %s", err) tc.t.Skipf("skipping tests as failed integration prerequisites. %s", err)
return nil return nil
@@ -142,7 +120,8 @@ func (tc *Tester) initServer(rawConfig string, configType string) error {
tc.t.Cleanup(func() { tc.t.Cleanup(func() {
if tc.t.Failed() && tc.configLoaded { if tc.t.Failed() && tc.configLoaded {
res, err := http.Get(fmt.Sprintf("http://localhost:%d/config/", tc.config.AdminPort))
res, err := http.Get(fmt.Sprintf("http://localhost:%d/config/", Default.AdminPort))
if err != nil { if err != nil {
tc.t.Log("unable to read the current config") tc.t.Log("unable to read the current config")
return return
@@ -157,25 +136,11 @@ func (tc *Tester) initServer(rawConfig string, configType string) error {
}) })
rawConfig = prependCaddyFilePath(rawConfig) rawConfig = prependCaddyFilePath(rawConfig)
// normalize JSON config
if configType == "json" {
tc.t.Logf("Before: %s", rawConfig)
var conf any
if err := json.Unmarshal([]byte(rawConfig), &conf); err != nil {
return err
}
c, err := json.Marshal(conf)
if err != nil {
return err
}
rawConfig = string(c)
tc.t.Logf("After: %s", rawConfig)
}
client := &http.Client{ client := &http.Client{
Timeout: tc.config.LoadRequestTimeout, Timeout: Default.LoadRequestTimeout,
} }
start := time.Now() start := time.Now()
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/load", tc.config.AdminPort), strings.NewReader(rawConfig)) req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/load", Default.AdminPort), strings.NewReader(rawConfig))
if err != nil { if err != nil {
tc.t.Errorf("failed to create request. %s", err) tc.t.Errorf("failed to create request. %s", err)
return err return err
@@ -226,11 +191,11 @@ func (tc *Tester) ensureConfigRunning(rawConfig string, configType string) error
} }
client := &http.Client{ client := &http.Client{
Timeout: tc.config.LoadRequestTimeout, Timeout: Default.LoadRequestTimeout,
} }
fetchConfig := func(client *http.Client) any { fetchConfig := func(client *http.Client) any {
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", tc.config.AdminPort)) resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", Default.AdminPort))
if err != nil { if err != nil {
return nil return nil
} }
@@ -258,30 +223,30 @@ func (tc *Tester) ensureConfigRunning(rawConfig string, configType string) error
} }
const initConfig = `{ const initConfig = `{
admin localhost:%d admin localhost:2999
} }
` `
// validateTestPrerequisites ensures the certificates are available in the // validateTestPrerequisites ensures the certificates are available in the
// designated path and Caddy sub-process is running. // designated path and Caddy sub-process is running.
func validateTestPrerequisites(tc *Tester) error { func validateTestPrerequisites(t *testing.T) error {
// check certificates are found // check certificates are found
for _, certName := range tc.config.Certificates { for _, certName := range Default.Certifcates {
if _, err := os.Stat(getIntegrationDir() + certName); errors.Is(err, fs.ErrNotExist) { if _, err := os.Stat(getIntegrationDir() + certName); os.IsNotExist(err) {
return fmt.Errorf("caddy integration test certificates (%s) not found", certName) return fmt.Errorf("caddy integration test certificates (%s) not found", certName)
} }
} }
if isCaddyAdminRunning(tc) != nil { if isCaddyAdminRunning() != nil {
// setup the init config file, and set the cleanup afterwards // setup the init config file, and set the cleanup afterwards
f, err := os.CreateTemp("", "") f, err := os.CreateTemp("", "")
if err != nil { if err != nil {
return err return err
} }
tc.t.Cleanup(func() { t.Cleanup(func() {
os.Remove(f.Name()) os.Remove(f.Name())
}) })
if _, err := fmt.Fprintf(f, initConfig, tc.config.AdminPort); err != nil { if _, err := f.WriteString(initConfig); err != nil {
return err return err
} }
@@ -292,23 +257,23 @@ func validateTestPrerequisites(tc *Tester) error {
}() }()
// wait for caddy to start serving the initial config // wait for caddy to start serving the initial config
for retries := 10; retries > 0 && isCaddyAdminRunning(tc) != nil; retries-- { for retries := 10; retries > 0 && isCaddyAdminRunning() != nil; retries-- {
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
} }
} }
// one more time to return the error // one more time to return the error
return isCaddyAdminRunning(tc) return isCaddyAdminRunning()
} }
func isCaddyAdminRunning(tc *Tester) error { func isCaddyAdminRunning() error {
// assert that caddy is running // assert that caddy is running
client := &http.Client{ client := &http.Client{
Timeout: tc.config.LoadRequestTimeout, Timeout: Default.LoadRequestTimeout,
} }
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", tc.config.AdminPort)) resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", Default.AdminPort))
if err != nil { if err != nil {
return fmt.Errorf("caddy integration test caddy server not running. Expected to be listening on localhost:%d", tc.config.AdminPort) return fmt.Errorf("caddy integration test caddy server not running. Expected to be listening on localhost:%d", Default.AdminPort)
} }
resp.Body.Close() resp.Body.Close()
@@ -408,7 +373,7 @@ func (tc *Tester) AssertRedirect(requestURI string, expectedToLocation string, e
} }
// CompareAdapt adapts a config and then compares it against an expected result // 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 { func CompareAdapt(t *testing.T, filename, rawConfig string, adapterName string, expectedResponse string) bool {
cfgAdapter := caddyconfig.GetAdapter(adapterName) cfgAdapter := caddyconfig.GetAdapter(adapterName)
if cfgAdapter == nil { if cfgAdapter == nil {
t.Logf("unrecognized config adapter '%s'", adapterName) t.Logf("unrecognized config adapter '%s'", adapterName)
@@ -467,7 +432,7 @@ func CompareAdapt(t testing.TB, filename, rawConfig string, adapterName string,
} }
// AssertAdapt adapts a config and then tests it against an expected result // AssertAdapt adapts a config and then tests it against an expected result
func AssertAdapt(t testing.TB, rawConfig string, adapterName string, expectedResponse string) { func AssertAdapt(t *testing.T, rawConfig string, adapterName string, expectedResponse string) {
ok := CompareAdapt(t, "Caddyfile", rawConfig, adapterName, expectedResponse) ok := CompareAdapt(t, "Caddyfile", rawConfig, adapterName, expectedResponse)
if !ok { if !ok {
t.Fail() t.Fail()
@@ -476,7 +441,7 @@ func AssertAdapt(t testing.TB, rawConfig string, adapterName string, expectedRes
// Generic request functions // Generic request functions
func applyHeaders(t testing.TB, req *http.Request, requestHeaders []string) { func applyHeaders(t *testing.T, req *http.Request, requestHeaders []string) {
requestContentType := "" requestContentType := ""
for _, requestHeader := range requestHeaders { for _, requestHeader := range requestHeaders {
arr := strings.SplitAfterN(requestHeader, ":", 2) arr := strings.SplitAfterN(requestHeader, ":", 2)
-95
View File
@@ -1,7 +1,6 @@
package caddytest package caddytest
import ( import (
"net/http"
"strings" "strings"
"testing" "testing"
) )
@@ -32,97 +31,3 @@ func TestReplaceCertificatePaths(t *testing.T) {
t.Error("expected redirect uri to be unchanged") t.Error("expected redirect uri to be unchanged")
} }
} }
func TestLoadUnorderedJSON(t *testing.T) {
tester := NewTester(t)
tester.InitServer(`
{
"logging": {
"logs": {
"default": {
"level": "DEBUG",
"writer": {
"output": "stdout"
}
},
"sStdOutLogs": {
"level": "DEBUG",
"writer": {
"output": "stdout"
},
"include": [
"http.*",
"admin.*"
]
},
"sFileLogs": {
"level": "DEBUG",
"writer": {
"output": "stdout"
},
"include": [
"http.*",
"admin.*"
]
}
}
},
"admin": {
"listen": "localhost:2999"
},
"apps": {
"pki": {
"certificate_authorities" : {
"local" : {
"install_trust": false
}
}
},
"http": {
"http_port": 9080,
"https_port": 9443,
"servers": {
"s_server": {
"listen": [
":9080"
],
"routes": [
{
"handle": [
{
"handler": "static_response",
"body": "Hello"
}
]
},
{
"match": [
{
"host": [
"localhost",
"127.0.0.1"
]
}
]
}
],
"logs": {
"default_logger_name": "sStdOutLogs",
"logger_names": {
"localhost": "sStdOutLogs",
"127.0.0.1": "sFileLogs"
}
}
}
}
}
}
}
`, "json")
req, err := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil)
if err != nil {
t.Fail()
return
}
tester.AssertResponseCode(req, 200)
}
-209
View File
@@ -1,209 +0,0 @@
package integration
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"fmt"
"log/slog"
"net"
"net/http"
"strings"
"testing"
"github.com/mholt/acmez/v3"
"github.com/mholt/acmez/v3/acme"
smallstepacme "github.com/smallstep/certificates/acme"
"go.uber.org/zap"
"go.uber.org/zap/exp/zapslog"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddytest"
)
const acmeChallengePort = 9081
// Test the basic functionality of Caddy's ACME server
func TestACMEServerWithDefaults(t *testing.T) {
ctx := context.Background()
logger, err := zap.NewDevelopment()
if err != nil {
t.Error(err)
return
}
tester := caddytest.NewTester(t)
tester.InitServer(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
local_certs
}
acme.localhost {
acme_server
}
`, "caddyfile")
client := acmez.Client{
Client: &acme.Client{
Directory: "https://acme.localhost:9443/acme/local/directory",
HTTPClient: tester.Client,
Logger: slog.New(zapslog.NewHandler(logger.Core())),
},
ChallengeSolvers: map[string]acmez.Solver{
acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},
},
}
accountPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Errorf("generating account key: %v", err)
}
account := acme.Account{
Contact: []string{"mailto:you@example.com"},
TermsOfServiceAgreed: true,
PrivateKey: accountPrivateKey,
}
account, err = client.NewAccount(ctx, account)
if err != nil {
t.Errorf("new account: %v", err)
return
}
// Every certificate needs a key.
certPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Errorf("generating certificate key: %v", err)
return
}
certs, err := client.ObtainCertificateForSANs(ctx, account, certPrivateKey, []string{"localhost"})
if err != nil {
t.Errorf("obtaining certificate: %v", err)
return
}
// ACME servers should usually give you the entire certificate chain
// in PEM format, and sometimes even alternate chains! It's up to you
// which one(s) to store and use, but whatever you do, be sure to
// store the certificate and key somewhere safe and secure, i.e. don't
// lose them!
for _, cert := range certs {
t.Logf("Certificate %q:\n%s\n\n", cert.URL, cert.ChainPEM)
}
}
func TestACMEServerWithMismatchedChallenges(t *testing.T) {
ctx := context.Background()
logger := caddy.Log().Named("acmez")
tester := caddytest.NewTester(t)
tester.InitServer(`
{
skip_install_trust
admin localhost:2999
http_port 9080
https_port 9443
local_certs
}
acme.localhost {
acme_server {
challenges tls-alpn-01
}
}
`, "caddyfile")
client := acmez.Client{
Client: &acme.Client{
Directory: "https://acme.localhost:9443/acme/local/directory",
HTTPClient: tester.Client,
Logger: slog.New(zapslog.NewHandler(logger.Core())),
},
ChallengeSolvers: map[string]acmez.Solver{
acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},
},
}
accountPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Errorf("generating account key: %v", err)
}
account := acme.Account{
Contact: []string{"mailto:you@example.com"},
TermsOfServiceAgreed: true,
PrivateKey: accountPrivateKey,
}
account, err = client.NewAccount(ctx, account)
if err != nil {
t.Errorf("new account: %v", err)
return
}
// Every certificate needs a key.
certPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Errorf("generating certificate key: %v", err)
return
}
certs, err := client.ObtainCertificateForSANs(ctx, account, certPrivateKey, []string{"localhost"})
if len(certs) > 0 {
t.Errorf("expected '0' certificates, but received '%d'", len(certs))
}
if err == nil {
t.Error("expected errors, but received none")
}
const expectedErrMsg = "no solvers available for remaining challenges (configured=[http-01] offered=[tls-alpn-01] remaining=[tls-alpn-01])"
if !strings.Contains(err.Error(), expectedErrMsg) {
t.Errorf(`received error message does not match expectation: expected="%s" received="%s"`, expectedErrMsg, err.Error())
}
}
// naiveHTTPSolver is a no-op acmez.Solver for example purposes only.
type naiveHTTPSolver struct {
srv *http.Server
logger *zap.Logger
}
func (s *naiveHTTPSolver) Present(ctx context.Context, challenge acme.Challenge) error {
smallstepacme.InsecurePortHTTP01 = acmeChallengePort
s.srv = &http.Server{
Addr: fmt.Sprintf(":%d", acmeChallengePort),
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
host = r.Host
}
s.logger.Info("received request on challenge server", zap.String("path", r.URL.Path))
if r.Method == "GET" && r.URL.Path == challenge.HTTP01ResourcePath() && strings.EqualFold(host, challenge.Identifier.Value) {
w.Header().Add("Content-Type", "text/plain")
w.Write([]byte(challenge.KeyAuthorization))
r.Close = true
s.logger.Info("served key authentication",
zap.String("identifier", challenge.Identifier.Value),
zap.String("challenge", "http-01"),
zap.String("remote", r.RemoteAddr),
)
}
}),
}
l, err := net.Listen("tcp", fmt.Sprintf(":%d", acmeChallengePort))
if err != nil {
return err
}
s.logger.Info("present challenge", zap.Any("challenge", challenge))
go s.srv.Serve(l)
return nil
}
func (s naiveHTTPSolver) CleanUp(ctx context.Context, challenge acme.Challenge) error {
smallstepacme.InsecurePortHTTP01 = 0
s.logger.Info("cleanup", zap.Any("challenge", challenge))
if s.srv != nil {
s.srv.Close()
}
return nil
}
-174
View File
@@ -1,19 +1,8 @@
package integration package integration
import ( import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"log/slog"
"strings"
"testing" "testing"
"github.com/mholt/acmez/v3"
"github.com/mholt/acmez/v3/acme"
"go.uber.org/zap"
"go.uber.org/zap/exp/zapslog"
"github.com/caddyserver/caddy/v2/caddytest" "github.com/caddyserver/caddy/v2/caddytest"
) )
@@ -42,166 +31,3 @@ func TestACMEServerDirectory(t *testing.T) {
`{"newNonce":"https://acme.localhost:9443/acme/local/new-nonce","newAccount":"https://acme.localhost:9443/acme/local/new-account","newOrder":"https://acme.localhost:9443/acme/local/new-order","revokeCert":"https://acme.localhost:9443/acme/local/revoke-cert","keyChange":"https://acme.localhost:9443/acme/local/key-change"} `{"newNonce":"https://acme.localhost:9443/acme/local/new-nonce","newAccount":"https://acme.localhost:9443/acme/local/new-account","newOrder":"https://acme.localhost:9443/acme/local/new-order","revokeCert":"https://acme.localhost:9443/acme/local/revoke-cert","keyChange":"https://acme.localhost:9443/acme/local/key-change"}
`) `)
} }
func TestACMEServerAllowPolicy(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
{
skip_install_trust
local_certs
admin localhost:2999
http_port 9080
https_port 9443
pki {
ca local {
name "Caddy Local Authority"
}
}
}
acme.localhost {
acme_server {
challenges http-01
allow {
domains localhost
}
}
}
`, "caddyfile")
ctx := context.Background()
logger, err := zap.NewDevelopment()
if err != nil {
t.Error(err)
return
}
client := acmez.Client{
Client: &acme.Client{
Directory: "https://acme.localhost:9443/acme/local/directory",
HTTPClient: tester.Client,
Logger: slog.New(zapslog.NewHandler(logger.Core())),
},
ChallengeSolvers: map[string]acmez.Solver{
acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},
},
}
accountPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Errorf("generating account key: %v", err)
}
account := acme.Account{
Contact: []string{"mailto:you@example.com"},
TermsOfServiceAgreed: true,
PrivateKey: accountPrivateKey,
}
account, err = client.NewAccount(ctx, account)
if err != nil {
t.Errorf("new account: %v", err)
return
}
// Every certificate needs a key.
certPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Errorf("generating certificate key: %v", err)
return
}
{
certs, err := client.ObtainCertificateForSANs(ctx, account, certPrivateKey, []string{"localhost"})
if err != nil {
t.Errorf("obtaining certificate for allowed domain: %v", err)
return
}
// ACME servers should usually give you the entire certificate chain
// in PEM format, and sometimes even alternate chains! It's up to you
// which one(s) to store and use, but whatever you do, be sure to
// store the certificate and key somewhere safe and secure, i.e. don't
// lose them!
for _, cert := range certs {
t.Logf("Certificate %q:\n%s\n\n", cert.URL, cert.ChainPEM)
}
}
{
_, err := client.ObtainCertificateForSANs(ctx, account, certPrivateKey, []string{"not-matching.localhost"})
if err == nil {
t.Errorf("obtaining certificate for 'not-matching.localhost' domain")
} else if err != nil && !strings.Contains(err.Error(), "urn:ietf:params:acme:error:rejectedIdentifier") {
t.Logf("unexpected error: %v", err)
}
}
}
func TestACMEServerDenyPolicy(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
{
skip_install_trust
local_certs
admin localhost:2999
http_port 9080
https_port 9443
pki {
ca local {
name "Caddy Local Authority"
}
}
}
acme.localhost {
acme_server {
deny {
domains deny.localhost
}
}
}
`, "caddyfile")
ctx := context.Background()
logger, err := zap.NewDevelopment()
if err != nil {
t.Error(err)
return
}
client := acmez.Client{
Client: &acme.Client{
Directory: "https://acme.localhost:9443/acme/local/directory",
HTTPClient: tester.Client,
Logger: slog.New(zapslog.NewHandler(logger.Core())),
},
ChallengeSolvers: map[string]acmez.Solver{
acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},
},
}
accountPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Errorf("generating account key: %v", err)
}
account := acme.Account{
Contact: []string{"mailto:you@example.com"},
TermsOfServiceAgreed: true,
PrivateKey: accountPrivateKey,
}
account, err = client.NewAccount(ctx, account)
if err != nil {
t.Errorf("new account: %v", err)
return
}
// Every certificate needs a key.
certPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Errorf("generating certificate key: %v", err)
return
}
{
_, err := client.ObtainCertificateForSANs(ctx, account, certPrivateKey, []string{"deny.localhost"})
if err == nil {
t.Errorf("obtaining certificate for 'deny.localhost' domain")
} else if err != nil && !strings.Contains(err.Error(), "urn:ietf:params:acme:error:rejectedIdentifier") {
t.Logf("unexpected error: %v", err)
}
}
}
@@ -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,53 +0,0 @@
{
dns mock
acme_dns
}
example.com {
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"example.com"
]
}
],
"terminal": true
}
]
}
}
},
"tls": {
"automation": {
"policies": [
{
"issuers": [
{
"challenges": {
"dns": {}
},
"module": "acme"
}
]
}
]
},
"dns": {
"name": "mock"
}
}
}
}
@@ -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
@@ -1,65 +0,0 @@
{
pki {
ca custom-ca {
name "Custom CA"
}
}
}
acme.example.com {
acme_server {
ca custom-ca
challenges dns-01
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"acme.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"ca": "custom-ca",
"challenges": [
"dns-01"
],
"handler": "acme_server"
}
]
}
]
}
],
"terminal": true
}
]
}
}
},
"pki": {
"certificate_authorities": {
"custom-ca": {
"name": "Custom CA"
}
}
}
}
}
@@ -1,62 +0,0 @@
{
pki {
ca custom-ca {
name "Custom CA"
}
}
}
acme.example.com {
acme_server {
ca custom-ca
challenges
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"acme.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"ca": "custom-ca",
"handler": "acme_server"
}
]
}
]
}
],
"terminal": true
}
]
}
}
},
"pki": {
"certificate_authorities": {
"custom-ca": {
"name": "Custom CA"
}
}
}
}
}
@@ -1,66 +0,0 @@
{
pki {
ca custom-ca {
name "Custom CA"
}
}
}
acme.example.com {
acme_server {
ca custom-ca
challenges dns-01 http-01
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"acme.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"ca": "custom-ca",
"challenges": [
"dns-01",
"http-01"
],
"handler": "acme_server"
}
]
}
]
}
],
"terminal": true
}
]
}
}
},
"pki": {
"certificate_authorities": {
"custom-ca": {
"name": "Custom CA"
}
}
}
}
}
@@ -1,72 +0,0 @@
{
pki {
ca custom-ca {
name "Custom CA"
}
}
}
acme.example.com {
acme_server {
ca custom-ca
allow {
domains host-1.internal.example.com host-2.internal.example.com
}
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"acme.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"ca": "custom-ca",
"handler": "acme_server",
"policy": {
"allow": {
"domains": [
"host-1.internal.example.com",
"host-2.internal.example.com"
]
}
}
}
]
}
]
}
],
"terminal": true
}
]
}
}
},
"pki": {
"certificate_authorities": {
"custom-ca": {
"name": "Custom CA"
}
}
}
}
}
@@ -1,80 +0,0 @@
{
pki {
ca custom-ca {
name "Custom CA"
}
}
}
acme.example.com {
acme_server {
ca custom-ca
allow {
domains host-1.internal.example.com host-2.internal.example.com
}
deny {
domains dc.internal.example.com
}
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"acme.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"ca": "custom-ca",
"handler": "acme_server",
"policy": {
"allow": {
"domains": [
"host-1.internal.example.com",
"host-2.internal.example.com"
]
},
"deny": {
"domains": [
"dc.internal.example.com"
]
}
}
}
]
}
]
}
],
"terminal": true
}
]
}
}
},
"pki": {
"certificate_authorities": {
"custom-ca": {
"name": "Custom CA"
}
}
}
}
}
@@ -1,71 +0,0 @@
{
pki {
ca custom-ca {
name "Custom CA"
}
}
}
acme.example.com {
acme_server {
ca custom-ca
deny {
domains dc.internal.example.com
}
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"acme.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"ca": "custom-ca",
"handler": "acme_server",
"policy": {
"deny": {
"domains": [
"dc.internal.example.com"
]
}
}
}
]
}
]
}
],
"terminal": true
}
]
}
}
},
"pki": {
"certificate_authorities": {
"custom-ca": {
"name": "Custom CA"
}
}
}
}
}
@@ -1,67 +0,0 @@
{
pki {
ca internal {
name "Internal"
root_cn "Internal Root Cert"
intermediate_cn "Internal Intermediate Cert"
}
}
}
acme.example.com {
acme_server {
ca internal
sign_with_root
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"acme.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"ca": "internal",
"handler": "acme_server",
"sign_with_root": true
}
]
}
]
}
],
"terminal": true
}
]
}
}
},
"pki": {
"certificate_authorities": {
"internal": {
"name": "Internal",
"root_common_name": "Internal Root Cert",
"intermediate_common_name": "Internal Intermediate Cert"
}
}
}
}
}
@@ -1,12 +0,0 @@
example.com
handle {
respond "one"
}
example.com
handle {
respond "two"
}
----------
Caddyfile:6: unrecognized directive: example.com
Did you mean to define a second site? If so, you must use curly braces around each site to separate their configurations.
@@ -1,9 +0,0 @@
:8080 {
respond "one"
}
:8080 {
respond "two"
}
----------
ambiguous site definition: :8080
@@ -1,142 +0,0 @@
{
auto_https disable_redirects
admin off
}
http://localhost {
bind fd/{env.CADDY_HTTP_FD} {
protocols h1
}
log
respond "Hello, HTTP!"
}
https://localhost {
bind fd/{env.CADDY_HTTPS_FD} {
protocols h1 h2
}
bind fdgram/{env.CADDY_HTTP3_FD} {
protocols h3
}
log
respond "Hello, HTTPS!"
}
----------
{
"admin": {
"disabled": true
},
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
"fd/{env.CADDY_HTTPS_FD}",
"fdgram/{env.CADDY_HTTP3_FD}"
],
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "Hello, HTTPS!",
"handler": "static_response"
}
]
}
]
}
],
"terminal": true
}
],
"automatic_https": {
"disable_redirects": true
},
"logs": {
"logger_names": {
"localhost": [
""
]
}
},
"listen_protocols": [
[
"h1",
"h2"
],
[
"h3"
]
]
},
"srv1": {
"automatic_https": {
"disable_redirects": true
}
},
"srv2": {
"listen": [
"fd/{env.CADDY_HTTP_FD}"
],
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "Hello, HTTP!",
"handler": "static_response"
}
]
}
]
}
],
"terminal": true
}
],
"automatic_https": {
"disable_redirects": true,
"skip": [
"localhost"
]
},
"logs": {
"logger_names": {
"localhost": [
""
]
}
},
"listen_protocols": [
[
"h1"
]
]
}
}
}
}
}
@@ -1,5 +0,0 @@
handle
respond "should not work"
----------
Caddyfile:1: parsed 'handle' as a site address, but it is a known directive; directives must appear in a site block
@@ -1,12 +0,0 @@
{
servers {
srv0 {
listen :8080
}
srv1 {
listen :8080
}
}
}
----------
parsing caddyfile tokens for 'servers': unrecognized servers option 'srv0', at Caddyfile:3
@@ -21,8 +21,6 @@ encode {
zstd zstd
gzip 5 gzip 5
} }
encode
---------- ----------
{ {
"apps": { "apps": {
@@ -78,17 +76,6 @@ encode
"zstd", "zstd",
"gzip" "gzip"
] ]
},
{
"encodings": {
"gzip": {},
"zstd": {}
},
"handler": "encode",
"prefer": [
"zstd",
"gzip"
]
} }
] ]
} }
@@ -106,29 +106,20 @@ example.com {
"handler": "subroute", "handler": "subroute",
"routes": [ "routes": [
{ {
"group": "group0",
"handle": [ "handle": [
{ {
"handler": "subroute", "handler": "rewrite",
"routes": [ "uri": "/{http.error.status_code}.html"
{ }
"group": "group0", ]
"handle": [ },
{ {
"handler": "rewrite", "handle": [
"uri": "/{http.error.status_code}.html" {
} "handler": "file_server",
] "hide": [
}, "./Caddyfile"
{
"handle": [
{
"handler": "file_server",
"hide": [
"./Caddyfile"
]
}
]
}
] ]
} }
] ]
@@ -1,281 +0,0 @@
foo.localhost {
root * /srv
error /private* "Unauthorized" 410
error /fivehundred* "Internal Server Error" 500
handle_errors 5xx {
respond "Error In range [500 .. 599]"
}
handle_errors 410 {
respond "404 or 410 error"
}
}
bar.localhost {
root * /srv
error /private* "Unauthorized" 410
error /fivehundred* "Internal Server Error" 500
handle_errors 5xx {
respond "Error In range [500 .. 599] from second site"
}
handle_errors 410 {
respond "404 or 410 error from second site"
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"foo.localhost"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "vars",
"root": "/srv"
}
]
},
{
"handle": [
{
"error": "Internal Server Error",
"handler": "error",
"status_code": 500
}
],
"match": [
{
"path": [
"/fivehundred*"
]
}
]
},
{
"handle": [
{
"error": "Unauthorized",
"handler": "error",
"status_code": 410
}
],
"match": [
{
"path": [
"/private*"
]
}
]
}
]
}
],
"terminal": true
},
{
"match": [
{
"host": [
"bar.localhost"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "vars",
"root": "/srv"
}
]
},
{
"handle": [
{
"error": "Internal Server Error",
"handler": "error",
"status_code": 500
}
],
"match": [
{
"path": [
"/fivehundred*"
]
}
]
},
{
"handle": [
{
"error": "Unauthorized",
"handler": "error",
"status_code": 410
}
],
"match": [
{
"path": [
"/private*"
]
}
]
}
]
}
],
"terminal": true
}
],
"errors": {
"routes": [
{
"match": [
{
"host": [
"foo.localhost"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "404 or 410 error",
"handler": "static_response"
}
]
}
]
}
],
"match": [
{
"expression": "{http.error.status_code} in [410]"
}
]
},
{
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "Error In range [500 .. 599]",
"handler": "static_response"
}
]
}
]
}
],
"match": [
{
"expression": "{http.error.status_code} \u003e= 500 \u0026\u0026 {http.error.status_code} \u003c= 599"
}
]
}
]
}
],
"terminal": true
},
{
"match": [
{
"host": [
"bar.localhost"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "404 or 410 error from second site",
"handler": "static_response"
}
]
}
]
}
],
"match": [
{
"expression": "{http.error.status_code} in [410]"
}
]
},
{
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "Error In range [500 .. 599] from second site",
"handler": "static_response"
}
]
}
]
}
],
"match": [
{
"expression": "{http.error.status_code} \u003e= 500 \u0026\u0026 {http.error.status_code} \u003c= 599"
}
]
}
]
}
],
"terminal": true
}
]
}
}
}
}
}
}
@@ -1,129 +0,0 @@
{
http_port 3010
}
localhost:3010 {
root * /srv
error /private* "Unauthorized" 410
error /hidden* "Not found" 404
handle_errors 4xx {
respond "Error in the [400 .. 499] range"
}
}
----------
{
"apps": {
"http": {
"http_port": 3010,
"servers": {
"srv0": {
"listen": [
":3010"
],
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "vars",
"root": "/srv"
}
]
},
{
"handle": [
{
"error": "Unauthorized",
"handler": "error",
"status_code": 410
}
],
"match": [
{
"path": [
"/private*"
]
}
]
},
{
"handle": [
{
"error": "Not found",
"handler": "error",
"status_code": 404
}
],
"match": [
{
"path": [
"/hidden*"
]
}
]
}
]
}
],
"terminal": true
}
],
"errors": {
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "Error in the [400 .. 499] range",
"handler": "static_response"
}
]
}
]
}
],
"match": [
{
"expression": "{http.error.status_code} \u003e= 400 \u0026\u0026 {http.error.status_code} \u003c= 499"
}
]
}
]
}
],
"terminal": true
}
]
}
}
}
}
}
}
@@ -1,171 +0,0 @@
{
http_port 2099
}
localhost:2099 {
root * /srv
error /private* "Unauthorized" 410
error /threehundred* "Moved Permanently" 301
error /internalerr* "Internal Server Error" 500
handle_errors 500 3xx {
respond "Error code is equal to 500 or in the [300..399] range"
}
handle_errors 4xx {
respond "Error in the [400 .. 499] range"
}
}
----------
{
"apps": {
"http": {
"http_port": 2099,
"servers": {
"srv0": {
"listen": [
":2099"
],
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "vars",
"root": "/srv"
}
]
},
{
"handle": [
{
"error": "Moved Permanently",
"handler": "error",
"status_code": 301
}
],
"match": [
{
"path": [
"/threehundred*"
]
}
]
},
{
"handle": [
{
"error": "Internal Server Error",
"handler": "error",
"status_code": 500
}
],
"match": [
{
"path": [
"/internalerr*"
]
}
]
},
{
"handle": [
{
"error": "Unauthorized",
"handler": "error",
"status_code": 410
}
],
"match": [
{
"path": [
"/private*"
]
}
]
}
]
}
],
"terminal": true
}
],
"errors": {
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "Error in the [400 .. 499] range",
"handler": "static_response"
}
]
}
]
}
],
"match": [
{
"expression": "{http.error.status_code} \u003e= 400 \u0026\u0026 {http.error.status_code} \u003c= 499"
}
]
},
{
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "Error code is equal to 500 or in the [300..399] range",
"handler": "static_response"
}
]
}
]
}
],
"match": [
{
"expression": "{http.error.status_code} \u003e= 300 \u0026\u0026 {http.error.status_code} \u003c= 399 || {http.error.status_code} in [500]"
}
]
}
]
}
],
"terminal": true
}
]
}
}
}
}
}
}
@@ -1,129 +0,0 @@
{
http_port 3010
}
localhost:3010 {
root * /srv
error /private* "Unauthorized" 410
error /hidden* "Not found" 404
handle_errors 404 410 {
respond "404 or 410 error"
}
}
----------
{
"apps": {
"http": {
"http_port": 3010,
"servers": {
"srv0": {
"listen": [
":3010"
],
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "vars",
"root": "/srv"
}
]
},
{
"handle": [
{
"error": "Unauthorized",
"handler": "error",
"status_code": 410
}
],
"match": [
{
"path": [
"/private*"
]
}
]
},
{
"handle": [
{
"error": "Not found",
"handler": "error",
"status_code": 404
}
],
"match": [
{
"path": [
"/hidden*"
]
}
]
}
]
}
],
"terminal": true
}
],
"errors": {
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "404 or 410 error",
"handler": "static_response"
}
]
}
]
}
],
"match": [
{
"expression": "{http.error.status_code} in [404, 410]"
}
]
}
]
}
],
"terminal": true
}
]
}
}
}
}
}
}
@@ -1,166 +0,0 @@
{
http_port 2099
}
localhost:2099 {
root * /srv
error /private* "Unauthorized" 410
error /hidden* "Not found" 404
error /internalerr* "Internal Server Error" 500
handle_errors {
respond "Fallback route: code outside the [400..499] range"
}
handle_errors 4xx {
respond "Error in the [400 .. 499] range"
}
}
----------
{
"apps": {
"http": {
"http_port": 2099,
"servers": {
"srv0": {
"listen": [
":2099"
],
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "vars",
"root": "/srv"
}
]
},
{
"handle": [
{
"error": "Internal Server Error",
"handler": "error",
"status_code": 500
}
],
"match": [
{
"path": [
"/internalerr*"
]
}
]
},
{
"handle": [
{
"error": "Unauthorized",
"handler": "error",
"status_code": 410
}
],
"match": [
{
"path": [
"/private*"
]
}
]
},
{
"handle": [
{
"error": "Not found",
"handler": "error",
"status_code": 404
}
],
"match": [
{
"path": [
"/hidden*"
]
}
]
}
]
}
],
"terminal": true
}
],
"errors": {
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "Error in the [400 .. 499] range",
"handler": "static_response"
}
]
}
]
}
],
"match": [
{
"expression": "{http.error.status_code} \u003e= 400 \u0026\u0026 {http.error.status_code} \u003c= 499"
}
]
},
{
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "Fallback route: code outside the [400..499] range",
"handler": "static_response"
}
]
}
]
}
]
}
]
}
],
"terminal": true
}
]
}
}
}
}
}
}
@@ -1,260 +0,0 @@
{
http_port 2099
}
localhost:2099 {
root * /var/www/
file_server
handle_errors 404 {
handle /en/* {
respond "not found" 404
}
handle /es/* {
respond "no encontrado"
}
handle {
respond "default not found"
}
}
handle_errors {
handle /en/* {
respond "English error"
}
handle /es/* {
respond "Spanish error"
}
handle {
respond "Default error"
}
}
}
----------
{
"apps": {
"http": {
"http_port": 2099,
"servers": {
"srv0": {
"listen": [
":2099"
],
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "vars",
"root": "/var/www/"
},
{
"handler": "file_server",
"hide": [
"./Caddyfile"
]
}
]
}
]
}
],
"terminal": true
}
],
"errors": {
"routes": [
{
"match": [
{
"host": [
"localhost"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "subroute",
"routes": [
{
"group": "group3",
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "not found",
"handler": "static_response",
"status_code": 404
}
]
}
]
}
],
"match": [
{
"path": [
"/en/*"
]
}
]
},
{
"group": "group3",
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "no encontrado",
"handler": "static_response"
}
]
}
]
}
],
"match": [
{
"path": [
"/es/*"
]
}
]
},
{
"group": "group3",
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "default not found",
"handler": "static_response"
}
]
}
]
}
]
}
]
}
],
"match": [
{
"expression": "{http.error.status_code} in [404]"
}
]
},
{
"handle": [
{
"handler": "subroute",
"routes": [
{
"group": "group8",
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "English error",
"handler": "static_response"
}
]
}
]
}
],
"match": [
{
"path": [
"/en/*"
]
}
]
},
{
"group": "group8",
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "Spanish error",
"handler": "static_response"
}
]
}
]
}
],
"match": [
{
"path": [
"/es/*"
]
}
]
},
{
"group": "group8",
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": "Default error",
"handler": "static_response"
}
]
}
]
}
]
}
]
}
]
}
]
}
],
"terminal": true
}
]
}
}
}
}
}
}
@@ -1,7 +1,3 @@
(snippet) {
@g `{http.error.status_code} == 404`
}
example.com example.com
@a expression {http.error.status_code} == 400 @a expression {http.error.status_code} == 400
@@ -18,12 +14,6 @@ abort @d
@e expression `{http.error.status_code} == 404` @e expression `{http.error.status_code} == 404`
abort @e abort @e
@f `{http.error.status_code} == 404`
abort @f
import snippet
abort @g
---------- ----------
{ {
"apps": { "apps": {
@@ -94,10 +84,7 @@ abort @g
], ],
"match": [ "match": [
{ {
"expression": { "expression": "{http.error.status_code} == 403"
"expr": "{http.error.status_code} == 403",
"name": "d"
}
} }
] ]
}, },
@@ -110,42 +97,7 @@ abort @g
], ],
"match": [ "match": [
{ {
"expression": { "expression": "{http.error.status_code} == 404"
"expr": "{http.error.status_code} == 404",
"name": "e"
}
}
]
},
{
"handle": [
{
"abort": true,
"handler": "static_response"
}
],
"match": [
{
"expression": {
"expr": "{http.error.status_code} == 404",
"name": "f"
}
}
]
},
{
"handle": [
{
"abort": true,
"handler": "static_response"
}
],
"match": [
{
"expression": {
"expr": "{http.error.status_code} == 404",
"name": "g"
}
} }
] ]
} }
@@ -1,40 +0,0 @@
:8080 {
root * ./
file_server {
etag_file_extensions .b3sum .sha256
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":8080"
],
"routes": [
{
"handle": [
{
"handler": "vars",
"root": "./"
},
{
"etag_file_extensions": [
".b3sum",
".sha256"
],
"handler": "file_server",
"hide": [
"./Caddyfile"
]
}
]
}
]
}
}
}
}
}
@@ -1,36 +0,0 @@
:80
file_server {
browse {
file_limit 4000
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":80"
],
"routes": [
{
"handle": [
{
"browse": {
"file_limit": 4000
},
"handler": "file_server",
"hide": [
"./Caddyfile"
]
}
]
}
]
}
}
}
}
}
@@ -3,10 +3,6 @@
file_server { file_server {
precompressed zstd br gzip precompressed zstd br gzip
} }
file_server {
precompressed
}
---------- ----------
{ {
"apps": { "apps": {
@@ -34,22 +30,6 @@ file_server {
"br", "br",
"gzip" "gzip"
] ]
},
{
"handler": "file_server",
"hide": [
"./Caddyfile"
],
"precompressed": {
"br": {},
"gzip": {},
"zstd": {}
},
"precompressed_order": [
"br",
"zstd",
"gzip"
]
} }
] ]
} }
@@ -1,39 +0,0 @@
:80
file_server {
browse {
sort size desc
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":80"
],
"routes": [
{
"handle": [
{
"browse": {
"sort": [
"size",
"desc"
]
},
"handler": "file_server",
"hide": [
"./Caddyfile"
]
}
]
}
]
}
}
}
}
}
@@ -1,203 +0,0 @@
app.example.com {
forward_auth authelia:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
}
reverse_proxy backend:8080
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"app.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handle_response": [
{
"match": {
"status_code": [
2
]
},
"routes": [
{
"handle": [
{
"handler": "vars"
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"Remote-Email": [
"{http.reverse_proxy.header.Remote-Email}"
]
}
}
}
],
"match": [
{
"not": [
{
"vars": {
"{http.reverse_proxy.header.Remote-Email}": [
""
]
}
}
]
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"Remote-Groups": [
"{http.reverse_proxy.header.Remote-Groups}"
]
}
}
}
],
"match": [
{
"not": [
{
"vars": {
"{http.reverse_proxy.header.Remote-Groups}": [
""
]
}
}
]
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"Remote-Name": [
"{http.reverse_proxy.header.Remote-Name}"
]
}
}
}
],
"match": [
{
"not": [
{
"vars": {
"{http.reverse_proxy.header.Remote-Name}": [
""
]
}
}
]
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"Remote-User": [
"{http.reverse_proxy.header.Remote-User}"
]
}
}
}
],
"match": [
{
"not": [
{
"vars": {
"{http.reverse_proxy.header.Remote-User}": [
""
]
}
}
]
}
]
}
]
}
],
"handler": "reverse_proxy",
"headers": {
"request": {
"set": {
"X-Forwarded-Method": [
"{http.request.method}"
],
"X-Forwarded-Uri": [
"{http.request.uri}"
]
}
}
},
"rewrite": {
"method": "GET",
"uri": "/api/authz/forward-auth"
},
"upstreams": [
{
"dial": "authelia:9091"
}
]
},
{
"handler": "reverse_proxy",
"upstreams": [
{
"dial": "backend:8080"
}
]
}
]
}
]
}
],
"terminal": true
}
]
}
}
}
}
}
@@ -0,0 +1,111 @@
app.example.com {
forward_auth authelia:9091 {
uri /api/verify?rd=https://authelia.example.com
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
}
reverse_proxy backend:8080
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"app.example.com"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handle_response": [
{
"match": {
"status_code": [
2
]
},
"routes": [
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"Remote-Email": [
"{http.reverse_proxy.header.Remote-Email}"
],
"Remote-Groups": [
"{http.reverse_proxy.header.Remote-Groups}"
],
"Remote-Name": [
"{http.reverse_proxy.header.Remote-Name}"
],
"Remote-User": [
"{http.reverse_proxy.header.Remote-User}"
]
}
}
}
]
}
]
}
],
"handler": "reverse_proxy",
"headers": {
"request": {
"set": {
"X-Forwarded-Method": [
"{http.request.method}"
],
"X-Forwarded-Uri": [
"{http.request.uri}"
]
}
}
},
"rewrite": {
"method": "GET",
"uri": "/api/verify?rd=https://authelia.example.com"
},
"upstreams": [
{
"dial": "authelia:9091"
}
]
},
{
"handler": "reverse_proxy",
"upstreams": [
{
"dial": "backend:8080"
}
]
}
]
}
]
}
],
"terminal": true
}
]
}
}
}
}
}
@@ -1,206 +0,0 @@
:8881
forward_auth localhost:9000 {
uri /auth
copy_headers A>1 B C>3 {
D
E>5
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":8881"
],
"routes": [
{
"handle": [
{
"handle_response": [
{
"match": {
"status_code": [
2
]
},
"routes": [
{
"handle": [
{
"handler": "vars"
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"1": [
"{http.reverse_proxy.header.A}"
]
}
}
}
],
"match": [
{
"not": [
{
"vars": {
"{http.reverse_proxy.header.A}": [
""
]
}
}
]
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"B": [
"{http.reverse_proxy.header.B}"
]
}
}
}
],
"match": [
{
"not": [
{
"vars": {
"{http.reverse_proxy.header.B}": [
""
]
}
}
]
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"3": [
"{http.reverse_proxy.header.C}"
]
}
}
}
],
"match": [
{
"not": [
{
"vars": {
"{http.reverse_proxy.header.C}": [
""
]
}
}
]
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"D": [
"{http.reverse_proxy.header.D}"
]
}
}
}
],
"match": [
{
"not": [
{
"vars": {
"{http.reverse_proxy.header.D}": [
""
]
}
}
]
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"5": [
"{http.reverse_proxy.header.E}"
]
}
}
}
],
"match": [
{
"not": [
{
"vars": {
"{http.reverse_proxy.header.E}": [
""
]
}
}
]
}
]
}
]
}
],
"handler": "reverse_proxy",
"headers": {
"request": {
"set": {
"X-Forwarded-Method": [
"{http.request.method}"
],
"X-Forwarded-Uri": [
"{http.request.uri}"
]
}
}
},
"rewrite": {
"method": "GET",
"uri": "/auth"
},
"upstreams": [
{
"dial": "localhost:9000"
}
]
}
]
}
]
}
}
}
}
}
@@ -0,0 +1,90 @@
:8881
forward_auth localhost:9000 {
uri /auth
copy_headers A>1 B C>3 {
D
E>5
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":8881"
],
"routes": [
{
"handle": [
{
"handle_response": [
{
"match": {
"status_code": [
2
]
},
"routes": [
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"1": [
"{http.reverse_proxy.header.A}"
],
"3": [
"{http.reverse_proxy.header.C}"
],
"5": [
"{http.reverse_proxy.header.E}"
],
"B": [
"{http.reverse_proxy.header.B}"
],
"D": [
"{http.reverse_proxy.header.D}"
]
}
}
}
]
}
]
}
],
"handler": "reverse_proxy",
"headers": {
"request": {
"set": {
"X-Forwarded-Method": [
"{http.request.method}"
],
"X-Forwarded-Uri": [
"{http.request.uri}"
]
}
}
},
"rewrite": {
"method": "GET",
"uri": "/auth"
},
"upstreams": [
{
"dial": "localhost:9000"
}
]
}
]
}
]
}
}
}
}
}
@@ -9,8 +9,6 @@
storage file_system { storage file_system {
root /data root /data
} }
storage_check off
storage_clean_interval off
acme_ca https://example.com acme_ca https://example.com
acme_ca_root /path/to/ca.crt acme_ca_root /path/to/ca.crt
ocsp_stapling off ocsp_stapling off
@@ -19,6 +17,8 @@
admin off admin off
on_demand_tls { on_demand_tls {
ask https://example.com ask https://example.com
interval 30s
burst 20
} }
local_certs local_certs
key_type ed25519 key_type ed25519
@@ -69,15 +69,14 @@
} }
], ],
"on_demand": { "on_demand": {
"permission": { "ask": "https://example.com",
"endpoint": "https://example.com", "rate_limit": {
"module": "http" "interval": 30000000000,
"burst": 20
} }
} }
}, },
"disable_ocsp_stapling": true, "disable_ocsp_stapling": true
"disable_storage_check": true,
"disable_storage_clean": true
} }
} }
} }
@@ -17,6 +17,8 @@
admin off admin off
on_demand_tls { on_demand_tls {
ask https://example.com ask https://example.com
interval 30s
burst 20
} }
storage_clean_interval 7d storage_clean_interval 7d
renew_interval 1d renew_interval 1d
@@ -61,14 +63,6 @@
"issuers": [ "issuers": [
{ {
"ca": "https://example.com", "ca": "https://example.com",
"challenges": {
"http": {
"alternate_port": 8080
},
"tls-alpn": {
"alternate_port": 8443
}
},
"email": "test@example.com", "email": "test@example.com",
"external_account": { "external_account": {
"key_id": "4K2scIVbBpNd-78scadB2g", "key_id": "4K2scIVbBpNd-78scadB2g",
@@ -84,9 +78,10 @@
} }
], ],
"on_demand": { "on_demand": {
"permission": { "ask": "https://example.com",
"endpoint": "https://example.com", "rate_limit": {
"module": "http" "interval": 30000000000,
"burst": 20
} }
}, },
"ocsp_interval": 172800000000000, "ocsp_interval": 172800000000000,
@@ -16,6 +16,8 @@
} }
on_demand_tls { on_demand_tls {
ask https://example.com ask https://example.com
interval 30s
burst 20
} }
local_certs local_certs
key_type ed25519 key_type ed25519
@@ -69,9 +71,10 @@
} }
], ],
"on_demand": { "on_demand": {
"permission": { "ask": "https://example.com",
"endpoint": "https://example.com", "rate_limit": {
"module": "http" "interval": 30000000000,
"burst": 20
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show More