Merge branch 'master' into pr-6417

Signed-off-by: Mohammed Al Sahaf <msaa1990@gmail.com>
This commit is contained in:
Mohammed Al Sahaf 2025-08-29 18:11:09 +03:00
commit 91778c5f94
No known key found for this signature in database
236 changed files with 10240 additions and 2633 deletions

31
.github/ISSUE_TEMPLATE/ISSUE.yml vendored Normal file
View File

@ -0,0 +1,31 @@
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
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,5 @@
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

14
.github/SECURITY.md vendored
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.x | ✔️ | | 2.latest | ✔️ |
| 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 email Matt Holt (the author) directly: matt at dyanim dot com. When you are ready, please submit a [new private vulnerability report](https://github.com/caddyserver/caddy/security/advisories/new).
Please don't encrypt the email body. It only makes the process more complicated. Please don't encrypt the message. 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.

View File

@ -3,5 +3,20 @@ 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
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,29 @@
## 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
.github/workflows/ai.yml vendored Normal file
View File

@ -0,0 +1,30 @@
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

View File

@ -12,6 +12,14 @@ 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:
@ -23,17 +31,13 @@ jobs:
- mac - mac
- windows - windows
go: go:
- '1.22' - '1.25'
- '1.23'
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.22' - go: '1.25'
GO_SEMVER: '~1.22.3' GO_SEMVER: '~1.25.0'
- go: '1.23'
GO_SEMVER: '~1.23.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) # 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)
@ -55,13 +59,21 @@ jobs:
SUCCESS: 'True' SUCCESS: 'True'
runs-on: ${{ matrix.OS_LABEL }} runs-on: ${{ matrix.OS_LABEL }}
permissions:
contents: read
pull-requests: read
actions: write # to allow uploading artifacts and cache
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Go - name: Install Go
uses: actions/setup-go@v5 uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with: with:
go-version: ${{ matrix.GO_SEMVER }} go-version: ${{ matrix.GO_SEMVER }}
check-latest: true check-latest: true
@ -99,7 +111,7 @@ jobs:
env: env:
CGO_ENABLED: 0 CGO_ENABLED: 0
run: | run: |
go build -tags nobadger -trimpath -ldflags="-w -s" -v go build -trimpath -ldflags="-w -s" -v
- name: Smoke test Caddy - name: Smoke test Caddy
working-directory: ./cmd/caddy working-directory: ./cmd/caddy
@ -108,7 +120,7 @@ jobs:
./caddy stop ./caddy stop
- name: Publish Build Artifact - name: Publish Build Artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
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 }}
@ -122,7 +134,7 @@ jobs:
# continue-on-error: true # continue-on-error: true
run: | run: |
# (go test -v -coverprofile=cover-profile.out -race ./... 2>&1) > test-results/test-result.out # (go test -v -coverprofile=cover-profile.out -race ./... 2>&1) > test-results/test-result.out
go test -tags nobadger -v -coverprofile="cover-profile.out" -short -race ./... go test -v -coverprofile="cover-profile.out" -short -race ./...
# echo "status=$?" >> $GITHUB_OUTPUT # echo "status=$?" >> $GITHUB_OUTPUT
# Relevant step if we reinvestigate publishing test/coverage reports # Relevant step if we reinvestigate publishing test/coverage reports
@ -142,12 +154,21 @@ jobs:
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 == github.repository && github.actor != 'dependabot[bot]' if: github.event.pull_request.head.repo.full_name == 'caddyserver/caddy' && 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@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
allowed-endpoints: ci-s390x.caddyserver.com:22
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Run Tests - name: Run Tests
run: | run: |
set +e set +e
@ -170,7 +191,7 @@ jobs:
retries=3 retries=3
exit_code=0 exit_code=0
while ((retries > 0)); do while ((retries > 0)); do
CGO_ENABLED=0 go test -p 1 -tags nobadger -v ./... CGO_ENABLED=0 go test -p 1 -v ./...
exit_code=$? exit_code=$?
if ((exit_code == 0)); then if ((exit_code == 0)); then
break break
@ -194,11 +215,35 @@ 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: Checkout code - name: Harden the runner (Audit all outbound calls)
uses: actions/checkout@v4 uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: goreleaser/goreleaser-action@v6 - name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0
with: with:
version: latest version: latest
args: check args: check
- name: Install Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.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@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0
with:
version: latest
args: build --single-target --snapshot
env:
TAG: ${{ github.head_ref || github.ref_name }}

View File

@ -10,6 +10,15 @@ 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: build:
strategy: strategy:
@ -27,26 +36,30 @@ jobs:
- 'darwin' - 'darwin'
- 'netbsd' - 'netbsd'
go: go:
- '1.22' - '1.25'
- '1.23'
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.22' - go: '1.25'
GO_SEMVER: '~1.22.3' GO_SEMVER: '~1.25.0'
- go: '1.23'
GO_SEMVER: '~1.23.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@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Go - name: Install Go
uses: actions/setup-go@v5 uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with: with:
go-version: ${{ matrix.GO_SEMVER }} go-version: ${{ matrix.GO_SEMVER }}
check-latest: true check-latest: true
@ -63,11 +76,9 @@ 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: | run: go build -trimpath -o caddy-"$GOOS"-$GOARCH 2> /dev/null
GOOS=$GOOS GOARCH=$GOARCH go build -tags nobadger -trimpath -o caddy-"$GOOS"-$GOARCH 2> /dev/null

View File

@ -13,6 +13,10 @@ 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:
@ -40,14 +44,19 @@ jobs:
runs-on: ${{ matrix.OS_LABEL }} runs-on: ${{ matrix.OS_LABEL }}
steps: steps:
- uses: actions/checkout@v4 - name: Harden the runner (Audit all outbound calls)
- uses: actions/setup-go@v5 uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with: with:
go-version: '~1.23' egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: '~1.25'
check-latest: true check-latest: true
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v6 uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
with: with:
version: latest version: latest
@ -58,10 +67,39 @@ 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: govulncheck - name: Harden the runner (Audit all outbound calls)
uses: golang/govulncheck-action@v1 uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with: with:
go-version-input: '~1.23.0' egress-policy: audit
- name: govulncheck
uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4
with:
go-version-input: '~1.25.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@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review'
uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1
with:
comment-summary-in-pr: on-failure
# https://github.com/actions/dependency-review-action/issues/430#issuecomment-1468975566
base-ref: ${{ github.event.pull_request.base.sha || 'master' }}
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}

View File

@ -5,6 +5,13 @@ on:
tags: tags:
- 'v*.*.*' - 'v*.*.*'
env:
# https://github.com/actions/setup-go/issues/491
GOTOOLCHAIN: local
permissions:
contents: read
jobs: jobs:
release: release:
name: Release name: Release
@ -13,13 +20,13 @@ jobs:
os: os:
- ubuntu-latest - ubuntu-latest
go: go:
- '1.23' - '1.25'
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.23' - go: '1.25'
GO_SEMVER: '~1.23.0' GO_SEMVER: '~1.25.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
@ -31,19 +38,24 @@ jobs:
contents: write contents: write
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install Go - name: Install Go
uses: actions/setup-go@v5 uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
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@v4 runs this line: # tl;dr: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 runs this line:
# git -c protocol.version=2 fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +ebc278ec98bb24f2852b61fde2a9bf2e3d83818b:refs/tags/ # 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
@ -97,16 +109,20 @@ jobs:
git verify-tag "${{ steps.vars.outputs.version_tag }}" || exit 1 git verify-tag "${{ steps.vars.outputs.version_tag }}" || exit 1
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@main uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # 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@main uses: anchore/sbom-action/download-syft@7b36ad622f042cab6f59a75c2ac24ccb256e9b45 # 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@v6 uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0
with: with:
version: latest version: latest
args: release --clean --timeout 60m args: release --clean --timeout 60m

View File

@ -5,6 +5,9 @@ on:
release: release:
types: [published] types: [published]
permissions:
contents: read
jobs: jobs:
release: release:
name: Release Published name: Release Published
@ -13,12 +16,20 @@ 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@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Trigger event on caddyserver/dist - name: Trigger event on caddyserver/dist
uses: peter-evans/repository-dispatch@v3 uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
with: with:
token: ${{ secrets.REPO_DISPATCH_TOKEN }} token: ${{ secrets.REPO_DISPATCH_TOKEN }}
repository: caddyserver/dist repository: caddyserver/dist
@ -26,7 +37,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@v3 uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3.0.0
with: with:
token: ${{ secrets.REPO_DISPATCH_TOKEN }} token: ${{ secrets.REPO_DISPATCH_TOKEN }}
repository: caddyserver/caddy-docker repository: caddyserver/caddy-docker

86
.github/workflows/scorecard.yml vendored Normal file
View File

@ -0,0 +1,86 @@
# 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@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
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@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5
with:
sarif_file: results.sarif

View File

@ -1,27 +1,19 @@
linters-settings: version: "2"
errcheck: run:
exclude-functions: issues-exit-code: 1
- fmt.* tests: false
- (go.uber.org/zap/zapcore.ObjectEncoder).AddObject build-tags:
- (go.uber.org/zap/zapcore.ObjectEncoder).AddArray - nobadger
gci: - nomysql
sections: - nopgx
- standard # Standard section: captures all standard packages. output:
- default # Default section: contains all imports that could not be matched to another section type. formats:
- prefix(github.com/caddyserver/caddy/v2/cmd) # ensure that this is always at the top and always has a line break. text:
- prefix(github.com/caddyserver/caddy) # Custom section: groups all imports with the specified Prefix. path: stdout
# Skip generated files. print-linter-name: true
# Default: true print-issued-lines: true
skip-generated: true
# Enable custom order of sections.
# If `true`, make the section order the same as the order of `sections`.
# Default: false
custom-order: true
exhaustive:
ignore-enum-types: reflect.Kind|svc.Cmd
linters: linters:
disable-all: true default: none
enable: enable:
- asasalint - asasalint
- asciicheck - asciicheck
@ -35,148 +27,96 @@ linters:
- errcheck - errcheck
- errname - errname
- exhaustive - exhaustive
- gci
- gofmt
- goimports
- gofumpt
- gosec - gosec
- gosimple
- govet - govet
- ineffassign
- importas - importas
- ineffassign
- misspell - misspell
- prealloc - prealloc
- promlinter - promlinter
- sloglint - sloglint
- sqlclosecheck - sqlclosecheck
- staticcheck - staticcheck
- tenv
- testableexamples - testableexamples
- testifylint - testifylint
- tparallel - tparallel
- typecheck
- unconvert - unconvert
- unused - unused
- wastedassign - wastedassign
- whitespace - whitespace
- zerologlint - zerologlint
# these are implicitly disabled: settings:
# - containedctx staticcheck:
# - contextcheck checks: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022", "-QF1006", "-QF1008"] # default, and exclude 1 more undesired check
# - cyclop errcheck:
# - depguard exclude-functions:
# - errchkjson - fmt.*
# - errorlint - (go.uber.org/zap/zapcore.ObjectEncoder).AddObject
# - exhaustruct - (go.uber.org/zap/zapcore.ObjectEncoder).AddArray
# - execinquery exhaustive:
# - exhaustruct ignore-enum-types: reflect.Kind|svc.Cmd
# - forbidigo exclusions:
# - forcetypeassert generated: lax
# - funlen presets:
# - ginkgolinter - comments
# - gocheckcompilerdirectives - common-false-positives
# - gochecknoglobals - legacy
# - gochecknoinits - std-error-handling
# - gochecksumtype rules:
# - gocognit - linters:
# - goconst - gosec
# - gocritic text: G115 # TODO: Either we should fix the issues or nuke the linter if it's bad
# - gocyclo - linters:
# - godot - gosec
# - godox text: G107 # we aren't calling unknown URL
# - goerr113 - linters:
# - goheader - gosec
# - gomnd text: G203 # as a web server that's expected to handle any template, this is totally in the hands of the user.
# - gomoddirectives - linters:
# - gomodguard - gosec
# - goprintffuncname text: G204 # we're shelling out to known commands, not relying on user-defined input.
# - gosmopolitan - linters:
# - grouper - gosec
# - inamedparam # the choice of weakrand is deliberate, hence the named import "weakrand"
# - interfacebloat path: modules/caddyhttp/reverseproxy/selectionpolicies.go
# - ireturn text: G404
# - lll - linters:
# - loggercheck - gosec
# - maintidx path: modules/caddyhttp/reverseproxy/streaming.go
# - makezero text: G404
# - mirror - linters:
# - musttag - dupl
# - nakedret path: modules/logging/filters.go
# - nestif - linters:
# - nilerr - dupl
# - nilnil path: modules/caddyhttp/matchers.go
# - nlreturn - linters:
# - noctx - dupl
# - nolintlint path: modules/caddyhttp/vars.go
# - nonamedreturns - linters:
# - nosprintfhostport - errcheck
# - paralleltest path: _test\.go
# - perfsprint paths:
# - predeclared - third_party$
# - protogetter - builtin$
# - reassign - examples$
# - revive formatters:
# - rowserrcheck enable:
# - stylecheck - gci
# - tagalign - gofmt
# - tagliatelle - gofumpt
# - testpackage - goimports
# - thelper settings:
# - unparam gci:
# - usestdlibvars sections:
# - varnamelen - standard # Standard section: captures all standard packages.
# - wrapcheck - default # Default section: contains all imports that could not be matched to another section type.
# - wsl - prefix(github.com/caddyserver/caddy/v2/cmd) # ensure that this is always at the top and always has a line break.
- prefix(github.com/caddyserver/caddy) # Custom section: groups all imports with the specified Prefix.
run: custom-order: true
# default concurrency is a available CPU number. exclusions:
# concurrency: 4 # explicitly omit this value to fully utilize available resources. generated: lax
timeout: 5m paths:
issues-exit-code: 1 - third_party$
tests: false - builtin$
- examples$
# output configuration options
output:
formats:
- format: 'colored-line-number'
print-issued-lines: true
print-linter-name: true
issues:
exclude-rules:
- text: 'G115' # TODO: Either we should fix the issues or nuke the linter if it's bad
linters:
- gosec
# we aren't calling unknown URL
- text: 'G107' # G107: Url provided to HTTP request as taint input
linters:
- gosec
# as a web server that's expected to handle any template, this is totally in the hands of the user.
- text: 'G203' # G203: Use of unescaped data in HTML templates
linters:
- gosec
# we're shelling out to known commands, not relying on user-defined input.
- text: 'G204' # G204: Audit use of command execution
linters:
- gosec
# the choice of weakrand is deliberate, hence the named import "weakrand"
- path: modules/caddyhttp/reverseproxy/selectionpolicies.go
text: 'G404' # G404: Insecure random number source (rand)
linters:
- gosec
- path: modules/caddyhttp/reverseproxy/streaming.go
text: 'G404' # G404: Insecure random number source (rand)
linters:
- gosec
- path: modules/logging/filters.go
linters:
- dupl
- path: modules/caddyhttp/matchers.go
linters:
- dupl
- path: modules/caddyhttp/vars.go
linters:
- dupl
- path: _test\.go
linters:
- errcheck

View File

@ -13,8 +13,7 @@ before:
- 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 # prepare syso files for windows embedding
- go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest - /bin/sh -c 'for a in amd64 arm arm64; do XCADDY_SKIP_BUILD=1 GOOS=windows GOARCH=$a xcaddy build {{.Env.TAG}}; done'
- /bin/sh -c 'for a in amd64 arm arm64; do XCADDY_SKIP_BUILD=1 GOOS=windows GOARCH=$a $GOPATH/bin/xcaddy build {{.Env.TAG}}; done'
- /bin/sh -c 'mv /tmp/buildenv_*/*.syso caddy-build' - /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
@ -84,6 +83,8 @@ builds:
- -s -w - -s -w
tags: tags:
- nobadger - nobadger
- nomysql
- nopgx
signs: signs:
- cmd: cosign - cmd: cosign
@ -110,7 +111,7 @@ archives:
- id: default - id: default
format_overrides: format_overrides:
- goos: windows - goos: windows
format: zip formats: zip
name_template: >- name_template: >-
{{ .ProjectName }}_ {{ .ProjectName }}_
{{- .Version }}_ {{- .Version }}_
@ -191,6 +192,9 @@ 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
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,20 @@
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

View File

@ -14,9 +14,10 @@
<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://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://x.com/caddyserver" title="@caddyserver on Twitter"><img src="https://img.shields.io/twitter/follow/caddyserver" alt="@caddyserver on Twitter"></a>
<a href="https://caddy.community" title="Caddy Forum"><img src="https://img.shields.io/badge/community-forum-ff69b4.svg" alt="Caddy Forum"></a> <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>
@ -67,6 +68,7 @@
- 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
@ -87,7 +89,7 @@ See [our online documentation](https://caddyserver.com/docs/install) for other i
Requirements: Requirements:
- [Go 1.22.3 or newer](https://golang.org/dl/) - [Go 1.25.0 or newer](https://golang.org/dl/)
### For development ### For development
@ -131,7 +133,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` 7. Compile: `go build -tags=nobadger,nomysql,nopgx`
@ -176,7 +178,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/my/contact-us?dd=caddy) before help is needed. - We advise companies using Caddy to secure a support contract through [Ardan Labs](https://www.ardanlabs.com) 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!
@ -192,8 +194,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 Twitter: [@caddyserver](https://twitter.com/caddyserver)_ - _Project on X: [@caddyserver](https://x.com/caddyserver)_
- _Author on Twitter: [@mholt6](https://twitter.com/mholt6)_ - _Author on X: [@mholt6](https://x.com/mholt6)_
Caddy is a project of [ZeroSSL](https://zerossl.com), a Stack Holdings company. Caddy is a project of [ZeroSSL](https://zerossl.com), a Stack Holdings company.

108
admin.go
View File

@ -214,14 +214,15 @@ 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) adminHandler { func (admin *AdminConfig) newAdminHandler(addr NetworkAddress, remote bool, _ Context) 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 {
muxWrap.enforceHost = !addr.isWildcardInterface() // see comment in allowedOrigins() as to why we disable the host check for unix/fd networks
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
} }
@ -270,7 +271,6 @@ func (admin *AdminConfig) newAdminHandler(addr NetworkAddress, remote bool) admi
// 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,47 +311,43 @@ func (admin AdminConfig) allowedOrigins(addr NetworkAddress) []*url.URL {
for _, o := range admin.Origins { for _, o := range admin.Origins {
uniqueOrigins[o] = struct{}{} uniqueOrigins[o] = struct{}{}
} }
if admin.Origins == nil { // RFC 2616, Section 14.26:
// "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() {
if addr.IsUnixNetwork() || addr.IsFdNetwork() { uniqueOrigins[net.JoinHostPort("localhost", addr.port())] = struct{}{}
// RFC 2616, Section 14.26: uniqueOrigins[net.JoinHostPort("::1", addr.port())] = struct{}{}
// "A client MUST include a Host header field in all HTTP/1.1 request uniqueOrigins[net.JoinHostPort("127.0.0.1", addr.port())] = struct{}{}
// messages. If the requested URI does not include an Internet host } else {
// 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() && !addr.IsFdNetwork() {
uniqueOrigins[addr.JoinHostPort(0)] = struct{}{} uniqueOrigins[addr.JoinHostPort(0)] = struct{}{}
} }
} }
@ -382,7 +378,9 @@ 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).
func replaceLocalAdminServer(cfg *Config) error { // Critically note that some elements and functionality of the context
// 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
@ -424,7 +422,14 @@ func replaceLocalAdminServer(cfg *Config) error {
return err return err
} }
handler := cfg.Admin.newAdminHandler(addr, false) handler := cfg.Admin.newAdminHandler(addr, false, ctx)
// run the provisioners for loaded modules to make sure local
// state is properly re-initialized in the new admin server
err = cfg.Admin.provisionAdminRouters(ctx)
if err != nil {
return err
}
ln, err := addr.Listen(context.TODO(), 0, net.ListenConfig{}) ln, err := addr.Listen(context.TODO(), 0, net.ListenConfig{})
if err != nil { if err != nil {
@ -545,7 +550,14 @@ 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) handler := cfg.Admin.newAdminHandler(addr, true, ctx)
// run the provisioners for loaded modules to make sure local
// state is properly re-initialized in the new admin server
err = cfg.Admin.provisionAdminRouters(ctx)
if err != nil {
return err
}
// 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
@ -934,7 +946,7 @@ func (h adminHandler) originAllowed(origin *url.URL) bool {
return false return false
} }
// etagHasher returns a the hasher we used on the config to both // etagHasher returns 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.Hash { return xxhash.New() }
@ -1138,7 +1150,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 || idx >= len(arr) { if idx < 0 || (method != http.MethodPut && idx >= len(arr)) || 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)
} }
} }

View File

@ -15,12 +15,20 @@
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(`{
@ -199,7 +207,723 @@ func TestETags(t *testing.T) {
} }
func BenchmarkLoad(b *testing.B) { func BenchmarkLoad(b *testing.B) {
for i := 0; i < b.N; i++ { for b.Loop() {
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)
}
})
}
}

155
caddy.go
View File

@ -81,13 +81,17 @@ type Config struct {
// associated value. // associated value.
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 is a dict of fileSystems that will later be loaded from and added to.
filesystems FileSystems fileSystems FileSystems
} }
// App is a thing that Caddy runs. // App is a thing that Caddy runs.
@ -399,6 +403,7 @@ func unsyncedDecodeAndRun(cfgJSON []byte, allowPersist bool) error {
func run(newCfg *Config, start bool) (Context, error) { func run(newCfg *Config, start bool) (Context, error) {
ctx, err := provisionContext(newCfg, start) ctx, err := provisionContext(newCfg, start)
if err != nil { if err != nil {
globalMetrics.configSuccess.Set(0)
return ctx, err return ctx, err
} }
@ -406,6 +411,19 @@ func run(newCfg *Config, start bool) (Context, error) {
return ctx, nil 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 // Provision any admin routers which may need to access
// some of the other apps at runtime // some of the other apps at runtime
err = ctx.cfg.Admin.provisionAdminRouters(ctx) err = ctx.cfg.Admin.provisionAdminRouters(ctx)
@ -437,10 +455,16 @@ func run(newCfg *Config, start bool) (Context, error) {
if err != nil { if err != nil {
return ctx, err 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, // now that the user's config is running, finish setting up anything else,
// such as remote admin endpoint, config loader, etc. // such as remote admin endpoint, config loader, etc.
return ctx, finishSettingUp(ctx, ctx.cfg) err = finishSettingUp(ctx, ctx.cfg)
return ctx, err
} }
// provisionContext creates a new context from the given configuration and provisions // provisionContext creates a new context from the given configuration and provisions
@ -471,6 +495,7 @@ 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;
@ -495,19 +520,12 @@ 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)
if err != nil {
return ctx, fmt.Errorf("starting caddy administration endpoint: %v", err)
}
}
// create the new filesystem map // create the new filesystem map
newCfg.filesystems = &filesystems.FilesystemMap{} newCfg.fileSystems = &filesystems.FileSystemMap{}
// 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 {
@ -534,6 +552,14 @@ 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 {
@ -691,6 +717,9 @@ 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()
@ -720,8 +749,10 @@ 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 // let the rest of the program know we're quitting; only do it once
atomic.StoreInt32(exiting, 1) if !atomic.CompareAndSwapInt32(exiting, 0, 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 {
@ -1031,6 +1062,98 @@ func Version() (simple, full string) {
return 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.

View File

@ -15,6 +15,7 @@
package caddy package caddy
import ( import (
"context"
"testing" "testing"
"time" "time"
) )
@ -72,3 +73,21 @@ 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)
}
}

View File

@ -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.Replace(body, []byte("\r\n"), []byte("\n"), -1) normalizedBody := bytes.ReplaceAll(body, []byte("\r\n"), []byte("\n"))
formatted := Format(normalizedBody) formatted := Format(normalizedBody)
if bytes.Equal(formatted, normalizedBody) { if bytes.Equal(formatted, normalizedBody) {

View File

@ -308,9 +308,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 and returns them. Open curly brace tokens also indicate // into a slice of strings and returns them. Open curly brace tokens
// the end of arguments, and the curly brace is not included in // also indicate the end of arguments, and the curly brace is not
// the return value nor is it loaded. // included in 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 +320,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 and returns them. Open curly brace // retaining quotes) into a slice of strings and returns them.
// tokens also indicate the end of arguments, and the curly brace is // Open curly brace tokens also indicate the end of arguments,
// not included in the return value nor is it loaded. // and the curly brace is 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,6 +331,18 @@ 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

View File

@ -274,6 +274,66 @@ 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 {
} }

View File

@ -61,7 +61,8 @@ func Format(input []byte) []byte {
heredocMarker []rune heredocMarker []rune
heredocClosingMarker []rune heredocClosingMarker []rune
nesting int // indentation level nesting int // indentation level
withinBackquote bool
) )
write := func(ch rune) { write := func(ch rune) {
@ -88,9 +89,12 @@ func Format(input []byte) []byte {
} }
panic(err) panic(err)
} }
if ch == '`' {
withinBackquote = !withinBackquote
}
// detect whether we have the start of a heredoc // detect whether we have the start of a heredoc
if !quoted && !(heredoc != heredocClosed || heredocEscaped) && if !quoted && (heredoc == heredocClosed && !heredocEscaped) &&
space && last == '<' && ch == '<' { space && last == '<' && ch == '<' {
write(ch) write(ch)
heredoc = heredocOpening heredoc = heredocOpening
@ -220,7 +224,7 @@ func Format(input []byte) []byte {
openBrace = false openBrace = false
if beginningOfLine { if beginningOfLine {
indent() indent()
} else if !openBraceSpace { } else if !openBraceSpace || !unicode.IsSpace(last) {
write(' ') write(' ')
} }
write('{') write('{')
@ -236,14 +240,23 @@ func Format(input []byte) []byte {
switch { switch {
case ch == '{': case ch == '{':
openBrace = true openBrace = true
openBraceWritten = false
openBraceSpace = spacePrior && !beginningOfLine openBraceSpace = spacePrior && !beginningOfLine
if openBraceSpace { if openBraceSpace && newLines == 0 {
write(' ') write(' ')
} }
openBraceWritten = false
if withinBackquote {
write('{')
openBraceWritten = true
continue
}
continue continue
case ch == '}' && (spacePrior || !openBrace): case ch == '}' && (spacePrior || !openBrace):
if withinBackquote {
write('}')
continue
}
if last != '\n' { if last != '\n' {
nextLine() nextLine()
} }

View File

@ -432,6 +432,31 @@ block2 {
heredoc \<<HEREDOC heredoc \<<HEREDOC
respond "More than one space will be eaten" 200 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: "No trailing space on line before env variable",
input: `{
a
{$ENV_VAR}
}
`,
expect: `{
a
{$ENV_VAR}
}
`, `,
}, },
} { } {

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 == ' ' {
@ -323,7 +323,8 @@ func (l *lexer) finalizeHeredoc(val []rune, marker string) ([]rune, error) {
// 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 {
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) 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, cleanLineText, paddingToStrip)
} }
// strip, then append the line, with the newline, to the output. // strip, then append the line, with the newline, to the output.

View File

@ -264,8 +264,13 @@ func (p *parser) addresses() error {
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", value)
} }
token.Text = value // After the above, a comma surrounded by spaces would result
p.block.Keys = append(p.block.Keys, token) // 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
@ -374,28 +379,23 @@ func (p *parser) doImport(nesting int) error {
if len(blockTokens) > 0 { if len(blockTokens) > 0 {
// use such tokens to create a new dispenser, and then use it to parse each block // use such tokens to create a new dispenser, and then use it to parse each block
bd := NewDispenser(blockTokens) bd := NewDispenser(blockTokens)
// one iteration processes one sub-block inside the import
for bd.Next() { for bd.Next() {
// see if we can grab a key currentMappingKey := bd.Val()
var currentMappingKey string
if bd.Val() == "{" { if currentMappingKey == "{" {
return p.Err("anonymous blocks are not supported") return p.Err("anonymous blocks are not supported")
} }
currentMappingKey = bd.Val()
currentMappingTokens := []Token{} // load up all arguments (if there even are any)
// read all args until end of line / { currentMappingTokens := bd.RemainingArgsAsTokens()
if bd.NextArg() {
// load up the entire block
for mappingNesting := bd.Nesting(); bd.NextBlock(mappingNesting); {
currentMappingTokens = append(currentMappingTokens, bd.Token()) currentMappingTokens = append(currentMappingTokens, bd.Token())
for bd.NextArg() {
currentMappingTokens = append(currentMappingTokens, bd.Token())
}
// TODO(elee1766): we don't enter another mapping here because it's annoying to extract the { and } properly.
// maybe someone can do that in the future
} else {
// attempt to enter a block and add tokens to the currentMappingTokens
for mappingNesting := bd.Nesting(); bd.NextBlock(mappingNesting); {
currentMappingTokens = append(currentMappingTokens, bd.Token())
}
} }
blockMapping[currentMappingKey] = currentMappingTokens blockMapping[currentMappingKey] = currentMappingTokens
} }
} }
@ -418,7 +418,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 := filepath.Abs(p.Dispenser.File()) absFile, err := caddy.FastAbs(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)
} }
@ -617,7 +617,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 := filepath.Abs(importFile) filename, err := caddy.FastAbs(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)
} }

View File

@ -18,6 +18,7 @@ import (
"bytes" "bytes"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@ -555,6 +556,10 @@ 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 {
@ -614,8 +619,8 @@ 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", t.Errorf("Test %d: Expected %d keys in block %d, got %d: %v",
i, len(test.keys[j]), j, len(block.Keys)) i, len(test.keys[j]), j, len(block.Keys), block.Keys)
continue continue
} }
for k, addr := range block.GetKeysText() { for k, addr := range block.GetKeysText() {
@ -880,6 +885,51 @@ func TestRejectsGlobalMatcher(t *testing.T) {
} }
} }
func TestRejectAnonymousImportBlock(t *testing.T) {
p := testParser(`
(site) {
http://{args[0]} https://{args[0]} {
{block}
}
}
import site test.domain {
{
header_up Host {host}
header_up X-Real-IP {remote_host}
}
}
`)
_, err := p.parseAll()
if err == nil {
t.Fatal("Expected an error, but got nil")
}
expected := "anonymous blocks are not supported"
if !strings.HasPrefix(err.Error(), "anonymous blocks are not supported") {
t.Errorf("Expected error to start with '%s' but got '%v'", expected, err)
}
}
func TestAcceptSiteImportWithBraces(t *testing.T) {
p := testParser(`
(site) {
http://{args[0]} https://{args[0]} {
{block}
}
}
import site test.domain {
reverse_proxy http://192.168.1.1:8080 {
header_up Host {host}
}
}
`)
_, err := p.parseAll()
if err != nil {
t.Errorf("Expected error to be nil but got '%v'", err)
}
}
func testParser(input string) parser { func testParser(input string) parser {
return parser{Dispenser: NewTestDispenser(input)} return parser{Dispenser: NewTestDispenser(input)}
} }

View File

@ -31,7 +31,7 @@ import (
"github.com/caddyserver/caddy/v2/modules/caddyhttp" "github.com/caddyserver/caddy/v2/modules/caddyhttp"
) )
// mapAddressToServerBlocks returns a map of listener address to list of server // mapAddressToProtocolToServerBlocks 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
@ -329,8 +329,12 @@ func (st *ServerType) listenersForServerBlockAddress(sblock serverBlock, addr Ad
// use a map to prevent duplication // use a map to prevent duplication
listeners := map[string]map[string]struct{}{} listeners := map[string]map[string]struct{}{}
for _, lnCfgVal := range lnCfgVals { for _, lnCfgVal := range lnCfgVals {
for _, lnHost := range lnCfgVal.addresses { for _, lnAddr := range lnCfgVal.addresses {
networkAddr, err := caddy.ParseNetworkAddressFromHostPort(lnHost, lnPort) lnNetw, lnHost, _, err := caddy.SplitNetworkAddress(lnAddr)
if err != nil {
return nil, fmt.Errorf("splitting listener address: %v", err)
}
networkAddr, err := caddy.ParseNetworkAddress(caddy.JoinNetworkAddress(lnNetw, lnHost, lnPort))
if err != nil { if err != nil {
return nil, fmt.Errorf("parsing network address: %v", err) return nil, fmt.Errorf("parsing network address: %v", err)
} }

View File

@ -15,6 +15,7 @@
package httpcaddyfile package httpcaddyfile
import ( import (
"encoding/json"
"fmt" "fmt"
"html" "html"
"net/http" "net/http"
@ -24,7 +25,7 @@ import (
"time" "time"
"github.com/caddyserver/certmagic" "github.com/caddyserver/certmagic"
"github.com/mholt/acmez/v2/acme" "github.com/mholt/acmez/v3/acme"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
"github.com/caddyserver/caddy/v2" "github.com/caddyserver/caddy/v2"
@ -84,7 +85,7 @@ func parseBind(h Helper) ([]ConfigValue, error) {
// parseTLS parses the tls directive. Syntax: // parseTLS parses the tls directive. Syntax:
// //
// tls [<email>|internal]|[<cert_file> <key_file>] { // tls [<email>|internal|force_automate]|[<cert_file> <key_file>] {
// protocols <min> [<max>] // protocols <min> [<max>]
// ciphers <cipher_suites...> // ciphers <cipher_suites...>
// curves <curves...> // curves <curves...>
@ -99,7 +100,7 @@ func parseBind(h Helper) ([]ConfigValue, error) {
// ca <acme_ca_endpoint> // ca <acme_ca_endpoint>
// ca_root <pem_file> // ca_root <pem_file>
// key_type [ed25519|p256|p384|rsa2048|rsa4096] // key_type [ed25519|p256|p384|rsa2048|rsa4096]
// dns <provider_name> [...] // dns [<provider_name> [...]] (required, though, if DNS is not configured as global option)
// propagation_delay <duration> // propagation_delay <duration>
// propagation_timeout <duration> // propagation_timeout <duration>
// resolvers <dns_servers...> // resolvers <dns_servers...>
@ -107,6 +108,7 @@ func parseBind(h Helper) ([]ConfigValue, error) {
// dns_challenge_override_domain <domain> // dns_challenge_override_domain <domain>
// on_demand // on_demand
// reuse_private_keys // reuse_private_keys
// force_automate
// eab <key_id> <mac_key> // eab <key_id> <mac_key>
// issuer <module_name> [...] // issuer <module_name> [...]
// get_certificate <module_name> [...] // get_certificate <module_name> [...]
@ -126,6 +128,10 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
var certManagers []certmagic.Manager var certManagers []certmagic.Manager
var onDemand bool var onDemand bool
var reusePrivateKeys bool var reusePrivateKeys bool
var forceAutomate bool
// Track which DNS challenge options are set
var dnsOptionsSet []string
firstLine := h.RemainingArgs() firstLine := h.RemainingArgs()
switch len(firstLine) { switch len(firstLine) {
@ -133,8 +139,10 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
case 1: case 1:
if firstLine[0] == "internal" { if firstLine[0] == "internal" {
internalIssuer = new(caddytls.InternalIssuer) internalIssuer = new(caddytls.InternalIssuer)
} else if firstLine[0] == "force_automate" {
forceAutomate = true
} else if !strings.Contains(firstLine[0], "@") { } else if !strings.Contains(firstLine[0], "@") {
return nil, h.Err("single argument must either be 'internal' or an email address") return nil, h.Err("single argument must either be 'internal', 'force_automate', or an email address")
} else { } else {
acmeIssuer = &caddytls.ACMEIssuer{ acmeIssuer = &caddytls.ACMEIssuer{
Email: firstLine[0], Email: firstLine[0],
@ -308,10 +316,6 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
certManagers = append(certManagers, certManager) certManagers = append(certManagers, certManager)
case "dns": case "dns":
if !h.NextArg() {
return nil, h.ArgErr()
}
provName := h.Val()
if acmeIssuer == nil { if acmeIssuer == nil {
acmeIssuer = new(caddytls.ACMEIssuer) acmeIssuer = new(caddytls.ACMEIssuer)
} }
@ -321,12 +325,19 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
if acmeIssuer.Challenges.DNS == nil { if acmeIssuer.Challenges.DNS == nil {
acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig) acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
} }
modID := "dns.providers." + provName // DNS provider configuration optional, since it may be configured globally via the TLS app with global options
unm, err := caddyfile.UnmarshalModule(h.Dispenser, modID) if h.NextArg() {
if err != nil { provName := h.Val()
return nil, err modID := "dns.providers." + provName
unm, err := caddyfile.UnmarshalModule(h.Dispenser, modID)
if err != nil {
return nil, err
}
acmeIssuer.Challenges.DNS.ProviderRaw = caddyconfig.JSONModuleObject(unm, "name", provName, h.warnings)
} else if h.Option("dns") == nil {
// if DNS is omitted locally, it needs to be configured globally
return nil, h.ArgErr()
} }
acmeIssuer.Challenges.DNS.ProviderRaw = caddyconfig.JSONModuleObject(unm, "name", provName, h.warnings)
case "resolvers": case "resolvers":
args := h.RemainingArgs() args := h.RemainingArgs()
@ -342,6 +353,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
if acmeIssuer.Challenges.DNS == nil { if acmeIssuer.Challenges.DNS == nil {
acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig) acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
} }
dnsOptionsSet = append(dnsOptionsSet, "resolvers")
acmeIssuer.Challenges.DNS.Resolvers = args acmeIssuer.Challenges.DNS.Resolvers = args
case "propagation_delay": case "propagation_delay":
@ -363,6 +375,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
if acmeIssuer.Challenges.DNS == nil { if acmeIssuer.Challenges.DNS == nil {
acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig) acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
} }
dnsOptionsSet = append(dnsOptionsSet, "propagation_delay")
acmeIssuer.Challenges.DNS.PropagationDelay = caddy.Duration(delay) acmeIssuer.Challenges.DNS.PropagationDelay = caddy.Duration(delay)
case "propagation_timeout": case "propagation_timeout":
@ -390,6 +403,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
if acmeIssuer.Challenges.DNS == nil { if acmeIssuer.Challenges.DNS == nil {
acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig) acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
} }
dnsOptionsSet = append(dnsOptionsSet, "propagation_timeout")
acmeIssuer.Challenges.DNS.PropagationTimeout = caddy.Duration(timeout) acmeIssuer.Challenges.DNS.PropagationTimeout = caddy.Duration(timeout)
case "dns_ttl": case "dns_ttl":
@ -411,6 +425,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
if acmeIssuer.Challenges.DNS == nil { if acmeIssuer.Challenges.DNS == nil {
acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig) acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
} }
dnsOptionsSet = append(dnsOptionsSet, "dns_ttl")
acmeIssuer.Challenges.DNS.TTL = caddy.Duration(ttl) acmeIssuer.Challenges.DNS.TTL = caddy.Duration(ttl)
case "dns_challenge_override_domain": case "dns_challenge_override_domain":
@ -427,6 +442,7 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
if acmeIssuer.Challenges.DNS == nil { if acmeIssuer.Challenges.DNS == nil {
acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig) acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
} }
dnsOptionsSet = append(dnsOptionsSet, "dns_challenge_override_domain")
acmeIssuer.Challenges.DNS.OverrideDomain = arg[0] acmeIssuer.Challenges.DNS.OverrideDomain = arg[0]
case "ca_root": case "ca_root":
@ -462,6 +478,18 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
} }
} }
// Validate DNS challenge config: any DNS challenge option except "dns" requires a DNS provider
if acmeIssuer != nil && acmeIssuer.Challenges != nil && acmeIssuer.Challenges.DNS != nil {
dnsCfg := acmeIssuer.Challenges.DNS
providerSet := dnsCfg.ProviderRaw != nil || h.Option("dns") != nil
if len(dnsOptionsSet) > 0 && !providerSet {
return nil, h.Errf(
"setting DNS challenge options [%s] requires a DNS provider (set with the 'dns' subdirective or 'acme_dns' global option)",
strings.Join(dnsOptionsSet, ", "),
)
}
}
// a naked tls directive is not allowed // a naked tls directive is not allowed
if len(firstLine) == 0 && !hasBlock { if len(firstLine) == 0 && !hasBlock {
return nil, h.ArgErr() return nil, h.ArgErr()
@ -569,6 +597,15 @@ func parseTLS(h Helper) ([]ConfigValue, error) {
}) })
} }
// if enabled, the names in the site addresses will be
// added to the automation policies
if forceAutomate {
configVals = append(configVals, ConfigValue{
Class: "tls.force_automate",
Value: true,
})
}
// custom certificate selection // custom certificate selection
if len(certSelector.AnyTag) > 0 { if len(certSelector.AnyTag) > 0 {
cp.CertSelection = &certSelector cp.CertSelection = &certSelector
@ -827,13 +864,18 @@ func parseHandleErrors(h Helper) ([]ConfigValue, error) {
return nil, h.Errf("segment was not parsed as a subroute") return nil, h.Errf("segment was not parsed as a subroute")
} }
// wrap the subroutes
wrappingRoute := caddyhttp.Route{
HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject(subroute, "handler", "subroute", nil)},
}
subroute = &caddyhttp.Subroute{
Routes: []caddyhttp.Route{wrappingRoute},
}
if expression != "" { if expression != "" {
statusMatcher := caddy.ModuleMap{ statusMatcher := caddy.ModuleMap{
"expression": h.JSON(caddyhttp.MatchExpression{Expr: expression}), "expression": h.JSON(caddyhttp.MatchExpression{Expr: expression}),
} }
for i := range subroute.Routes { subroute.Routes[0].MatcherSetsRaw = []caddy.ModuleMap{statusMatcher}
subroute.Routes[i].MatcherSetsRaw = []caddy.ModuleMap{statusMatcher}
}
} }
return []ConfigValue{ return []ConfigValue{
{ {
@ -981,6 +1023,50 @@ func parseLogHelper(h Helper, globalLogNames map[string]struct{}) ([]ConfigValue
} }
cl.WriterRaw = caddyconfig.JSONModuleObject(wo, "output", moduleName, h.warnings) cl.WriterRaw = caddyconfig.JSONModuleObject(wo, "output", moduleName, h.warnings)
case "sampling":
d := h.Dispenser.NewFromNextSegment()
for d.NextArg() {
// consume any tokens on the same line, if any.
}
sampling := &caddy.LogSampling{}
for nesting := d.Nesting(); d.NextBlock(nesting); {
subdir := d.Val()
switch subdir {
case "interval":
if !d.NextArg() {
return nil, d.ArgErr()
}
interval, err := time.ParseDuration(d.Val() + "ns")
if err != nil {
return nil, d.Errf("failed to parse interval: %v", err)
}
sampling.Interval = interval
case "first":
if !d.NextArg() {
return nil, d.ArgErr()
}
first, err := strconv.Atoi(d.Val())
if err != nil {
return nil, d.Errf("failed to parse first: %v", err)
}
sampling.First = first
case "thereafter":
if !d.NextArg() {
return nil, d.ArgErr()
}
thereafter, err := strconv.Atoi(d.Val())
if err != nil {
return nil, d.Errf("failed to parse thereafter: %v", err)
}
sampling.Thereafter = thereafter
default:
return nil, d.Errf("unrecognized subdirective: %s", subdir)
}
}
cl.Sampling = sampling
case "core": case "core":
if !h.NextArg() { if !h.NextArg() {
return nil, h.ArgErr() return nil, h.ArgErr()
@ -1100,6 +1186,11 @@ func parseLogSkip(h Helper) (caddyhttp.MiddlewareHandler, error) {
if h.NextArg() { if h.NextArg() {
return nil, h.ArgErr() return nil, h.ArgErr()
} }
if h.NextBlock(0) {
return nil, h.Err("log_skip directive does not accept blocks")
}
return caddyhttp.VarsMiddleware{"log_skip": true}, nil return caddyhttp.VarsMiddleware{"log_skip": true}, nil
} }

View File

@ -62,6 +62,20 @@ func TestLogDirectiveSyntax(t *testing.T) {
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"},"core":{"module":"mock"},"include":["http.log.access.name-override"]}}},"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"logs":{"default_logger_name":"name-override"}}}}}}`,
expectError: false, 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,
},
} { } {
adapter := caddyfile.Adapter{ adapter := caddyfile.Adapter{

View File

@ -16,6 +16,7 @@ package httpcaddyfile
import ( import (
"encoding/json" "encoding/json"
"maps"
"net" "net"
"slices" "slices"
"sort" "sort"
@ -173,10 +174,12 @@ func RegisterDirectiveOrder(dir string, position Positional, standardDir string)
if d != standardDir { if d != standardDir {
continue continue
} }
if position == Before { switch position {
case Before:
newOrder = append(newOrder[:i], append([]string{dir}, newOrder[i:]...)...) newOrder = append(newOrder[:i], append([]string{dir}, newOrder[i:]...)...)
} else if position == After { case After:
newOrder = append(newOrder[:i+1], append([]string{dir}, newOrder[i+1:]...)...) newOrder = append(newOrder[:i+1], append([]string{dir}, newOrder[i+1:]...)...)
case First, Last:
} }
break break
} }
@ -365,9 +368,7 @@ 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))
for key, val := range h.matcherDefs { maps.Copy(matcherDefs, 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++ {
@ -483,12 +484,29 @@ 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 strings.TrimSuffix(iPM[0], "*") == strings.TrimSuffix(jPM[0], "*") { if iPathTrimmed == jPathTrimmed {
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
} }

View File

@ -15,6 +15,7 @@
package httpcaddyfile package httpcaddyfile
import ( import (
"cmp"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net" "net"
@ -186,12 +187,25 @@ 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,
} }
@ -336,7 +350,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[[]string, string](defaultLog.Exclude) defaultLog.Exclude = slices.Compact(defaultLog.Exclude)
} }
} }
// we may have not actually added anything, so remove if empty // we may have not actually added anything, so remove if empty
@ -619,12 +633,6 @@ func (st *ServerType) serversFromPairings(
srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig) srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
} }
srv.AutoHTTPS.IgnoreLoadedCerts = true srv.AutoHTTPS.IgnoreLoadedCerts = true
case "prefer_wildcard":
if srv.AutoHTTPS == nil {
srv.AutoHTTPS = new(caddyhttp.AutoHTTPSConfig)
}
srv.AutoHTTPS.PreferWildcard = true
} }
} }
@ -739,6 +747,14 @@ 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
@ -769,21 +785,20 @@ 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,
if !cp.SettingsEmpty() { // or if the configuration explicitly automates certs for
// 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
} }
} }
} }
wildcardHosts := []string{}
for _, addr := range sblock.parsedKeys {
if strings.HasPrefix(addr.Host, "*.") {
wildcardHosts = append(wildcardHosts, addr.Host[2:])
}
}
for _, addr := range sblock.parsedKeys { for _, addr := range sblock.parsedKeys {
// 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) {
@ -799,18 +814,6 @@ func (st *ServerType) serversFromPairings(
} }
} }
// If prefer wildcard is enabled, then we add hosts that are
// already covered by the wildcard to the skip list
if srv.AutoHTTPS != nil && srv.AutoHTTPS.PreferWildcard && addr.Scheme == "https" {
baseDomain := addr.Host
if idx := strings.Index(baseDomain, "."); idx != -1 {
baseDomain = baseDomain[idx+1:]
}
if !strings.HasPrefix(addr.Host, "*.") && slices.Contains(wildcardHosts, baseDomain) {
srv.AutoHTTPS.Skip = append(srv.AutoHTTPS.Skip, addr.Host)
}
}
// If TLS is specified as directive, it will also result in 1 or more connection policy being created // If TLS is specified as directive, it will also result in 1 or more connection policy being created
// Thus, catch-all address with non-standard port, e.g. :8443, can have TLS enabled without // Thus, catch-all address with non-standard port, e.g. :8443, can have TLS enabled without
// specifying prefix "https://" // specifying prefix "https://"
@ -827,6 +830,7 @@ 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 &&
@ -1057,11 +1061,40 @@ 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 = append(cps[:j], cps[j+1:]...) cps = slices.Delete(cps, j, 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) {
@ -1095,6 +1128,12 @@ 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 {
@ -1135,6 +1174,9 @@ 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
} }
@ -1154,12 +1196,13 @@ func consolidateConnPolicies(cps caddytls.ConnectionPolicies) (caddytls.Connecti
} }
} }
cps = append(cps[:j], cps[j+1:]...) cps = slices.Delete(cps, j, j+1)
i-- i--
break break
} }
} }
} }
return cps, nil return cps, nil
} }
@ -1448,9 +1491,9 @@ func (st *ServerType) compileEncodedMatcherSets(sblock serverBlock) ([]caddy.Mod
// 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.RequestMatcher var matcherSets []map[string]caddyhttp.RequestMatcherWithError
for _, mp := range matcherPairs { for _, mp := range matcherPairs {
matcherSet := make(map[string]caddyhttp.RequestMatcher) matcherSet := make(map[string]caddyhttp.RequestMatcherWithError)
if len(mp.hostm) > 0 { if len(mp.hostm) > 0 {
matcherSet["host"] = mp.hostm matcherSet["host"] = mp.hostm
} }
@ -1509,12 +1552,17 @@ func parseMatcherDefinitions(d *caddyfile.Dispenser, matchers map[string]caddy.M
if err != nil { if err != nil {
return err return err
} }
rm, ok := unm.(caddyhttp.RequestMatcher)
if !ok { if rm, ok := unm.(caddyhttp.RequestMatcherWithError); ok {
return fmt.Errorf("matcher module '%s' is not a request matcher", matcherName) matchers[definitionName][matcherName] = caddyconfig.JSON(rm, nil)
return nil
} }
matchers[definitionName][matcherName] = caddyconfig.JSON(rm, nil) // nolint:staticcheck
return nil if rm, ok := unm.(caddyhttp.RequestMatcher); ok {
matchers[definitionName][matcherName] = caddyconfig.JSON(rm, nil)
return nil
}
return fmt.Errorf("matcher module '%s' is not a request matcher", matcherName)
} }
// if the next token is quoted, we can assume it's not a matcher name // if the next token is quoted, we can assume it's not a matcher name
@ -1558,7 +1606,7 @@ func parseMatcherDefinitions(d *caddyfile.Dispenser, matchers map[string]caddy.M
return nil return nil
} }
func encodeMatcherSet(matchers map[string]caddyhttp.RequestMatcher) (caddy.ModuleMap, error) { func encodeMatcherSet(matchers map[string]caddyhttp.RequestMatcherWithError) (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)
@ -1638,6 +1686,18 @@ 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.

View File

@ -19,11 +19,13 @@ import (
"strconv" "strconv"
"github.com/caddyserver/certmagic" "github.com/caddyserver/certmagic"
"github.com/mholt/acmez/v2/acme" "github.com/libdns/libdns"
"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"
) )
@ -38,12 +40,13 @@ func init() {
RegisterGlobalOption("fallback_sni", parseOptSingleString) RegisterGlobalOption("fallback_sni", parseOptSingleString)
RegisterGlobalOption("order", parseOptOrder) RegisterGlobalOption("order", parseOptOrder)
RegisterGlobalOption("storage", parseOptStorage) RegisterGlobalOption("storage", parseOptStorage)
RegisterGlobalOption("storage_clean_interval", parseOptDuration) RegisterGlobalOption("storage_check", parseStorageCheck)
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", parseOptACMEDNS) RegisterGlobalOption("acme_dns", parseOptDNS)
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)
@ -53,12 +56,15 @@ 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("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 }
@ -187,6 +193,40 @@ 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()
@ -201,25 +241,6 @@ func parseOptDuration(d *caddyfile.Dispenser, _ any) (any, error) {
return caddy.Duration(dur), nil return caddy.Duration(dur), nil
} }
func parseOptACMEDNS(d *caddyfile.Dispenser, _ any) (any, error) {
if !d.Next() { // consume option name
return nil, d.ArgErr()
}
if !d.Next() { // get DNS module name
return nil, d.ArgErr()
}
modID := "dns.providers." + d.Val()
unm, err := caddyfile.UnmarshalModule(d, modID)
if err != nil {
return nil, err
}
prov, ok := unm.(certmagic.DNSProvider)
if !ok {
return nil, d.Errf("module %s (%T) is not a certmagic.DNSProvider", modID, unm)
}
return prov, nil
}
func parseOptACMEEAB(d *caddyfile.Dispenser, _ any) (any, error) { func parseOptACMEEAB(d *caddyfile.Dispenser, _ any) (any, error) {
eab := new(acme.EAB) eab := new(acme.EAB)
d.Next() // consume option name d.Next() // consume option name
@ -394,36 +415,10 @@ func parseOptOnDemand(d *caddyfile.Dispenser, _ any) (any, error) {
ond.PermissionRaw = caddyconfig.JSONModuleObject(perm, "module", modName, nil) ond.PermissionRaw = caddyconfig.JSONModuleObject(perm, "module", modName, nil)
case "interval": case "interval":
if !d.NextArg() { return nil, d.Errf("the on_demand_tls 'interval' option is no longer supported, remove it from your config")
return nil, d.ArgErr()
}
dur, err := caddy.ParseDuration(d.Val())
if err != nil {
return nil, err
}
if ond == nil {
ond = new(caddytls.OnDemandConfig)
}
if ond.RateLimit == nil {
ond.RateLimit = new(caddytls.RateLimit)
}
ond.RateLimit.Interval = caddy.Duration(dur)
case "burst": case "burst":
if !d.NextArg() { return nil, d.Errf("the on_demand_tls 'burst' option is no longer supported, remove it from your config")
return nil, d.ArgErr()
}
burst, err := strconv.Atoi(d.Val())
if err != nil {
return nil, err
}
if ond == nil {
ond = new(caddytls.OnDemandConfig)
}
if ond.RateLimit == nil {
ond.RateLimit = new(caddytls.RateLimit)
}
ond.RateLimit.Burst = burst
default: default:
return nil, d.Errf("unrecognized parameter '%s'", d.Val()) return nil, d.Errf("unrecognized parameter '%s'", d.Val())
@ -463,8 +458,6 @@ func parseOptAutoHTTPS(d *caddyfile.Dispenser, _ any) (any, error) {
case "disable_certs": case "disable_certs":
case "ignore_loaded_certs": case "ignore_loaded_certs":
case "prefer_wildcard": case "prefer_wildcard":
break
default: default:
return "", d.Errf("auto_https must be one of 'off', 'disable_redirects', 'disable_certs', 'ignore_loaded_certs', or 'prefer_wildcard'") return "", d.Errf("auto_https must be one of 'off', 'disable_redirects', 'disable_certs', 'ignore_loaded_certs', or 'prefer_wildcard'")
} }
@ -472,6 +465,24 @@ func parseOptAutoHTTPS(d *caddyfile.Dispenser, _ any) (any, error) {
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)
} }
@ -541,3 +552,74 @@ 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
}

View File

@ -240,13 +240,16 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
} }
case "metrics": case "metrics":
if d.NextArg() { 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.")
return nil, d.ArgErr()
}
if nesting := d.Nesting(); d.NextBlock(nesting) {
return nil, d.ArgErr()
}
serverOpts.Metrics = new(caddyhttp.Metrics) serverOpts.Metrics = new(caddyhttp.Metrics)
for nesting := d.Nesting(); d.NextBlock(nesting); {
switch d.Val() {
case "per_host":
serverOpts.Metrics.PerHost = true
default:
return nil, d.Errf("unrecognized metrics option '%s'", d.Val())
}
}
case "trace": case "trace":
if d.NextArg() { if d.NextArg() {

View File

@ -52,19 +52,30 @@ 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}",

View File

@ -25,7 +25,7 @@ import (
"strings" "strings"
"github.com/caddyserver/certmagic" "github.com/caddyserver/certmagic"
"github.com/mholt/acmez/v2/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"
@ -92,6 +92,26 @@ 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 var addresses []string
@ -115,6 +135,12 @@ 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
@ -125,6 +151,13 @@ 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 // reuse private keys tls
if _, ok := sblock.pile["tls.reuse_private_keys"]; ok { if _, ok := sblock.pile["tls.reuse_private_keys"]; ok {
ap.ReusePrivateKeys = true ap.ReusePrivateKeys = true
@ -217,9 +250,21 @@ 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 = sblock.hostsFromKeysNotHTTP(httpPort) ap.SubjectsRaw = hostsNotHTTP
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
@ -258,6 +303,7 @@ 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)
} }
@ -292,7 +338,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 := 0; i < clVal.Len(); i++ { for i := range clVal.Len() {
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)
@ -311,6 +357,42 @@ 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 {
@ -359,6 +441,13 @@ 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)
} }
@ -375,12 +464,12 @@ 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"] _, 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"] globalACMEEAB := options["acme_eab"]
globalPreferredChains := options["preferred_chains"] globalPreferredChains := options["preferred_chains"]
hasGlobalACMEDefaults := globalEmail != nil || globalACMECA != nil || globalACMECARoot != nil || globalACMEDNS != nil || globalACMEEAB != nil || globalPreferredChains != nil hasGlobalACMEDefaults := globalEmail != nil || globalACMECA != nil || globalACMECARoot != nil || globalACMEDNS || globalACMEEAB != nil || globalPreferredChains != nil
if hasGlobalACMEDefaults { if hasGlobalACMEDefaults {
for i := 0; i < len(tlsApp.Automation.Policies); i++ { for i := range tlsApp.Automation.Policies {
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
@ -418,10 +507,7 @@ func (st ServerType) buildTLSApp(
} }
// consolidate automation policies that are the exact same // consolidate automation policies that are the exact same
tlsApp.Automation.Policies = consolidateAutomationPolicies( tlsApp.Automation.Policies = consolidateAutomationPolicies(tlsApp.Automation.Policies)
tlsApp.Automation.Policies,
slices.Contains(autoHTTPS, "prefer_wildcard"),
)
// ensure automation policies don't overlap subjects (this should be // ensure automation policies don't overlap subjects (this should be
// an error at provision-time as well, but catch it in the adapt phase // an error at provision-time as well, but catch it in the adapt phase
@ -463,7 +549,7 @@ 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 := options["acme_dns"] globalACMEDNS, globalACMEDNSok := options["acme_dns"] // can be set to nil (to use globally-defined "dns" value instead), but it is still set
globalACMEEAB := options["acme_eab"] globalACMEEAB := options["acme_eab"]
globalPreferredChains := options["preferred_chains"] globalPreferredChains := options["preferred_chains"]
globalCertLifetime := options["cert_lifetime"] globalCertLifetime := options["cert_lifetime"]
@ -478,11 +564,22 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
if globalACMECARoot != nil && !slices.Contains(acmeIssuer.TrustedRootsPEMFiles, globalACMECARoot.(string)) { if globalACMECARoot != nil && !slices.Contains(acmeIssuer.TrustedRootsPEMFiles, globalACMECARoot.(string)) {
acmeIssuer.TrustedRootsPEMFiles = append(acmeIssuer.TrustedRootsPEMFiles, globalACMECARoot.(string)) acmeIssuer.TrustedRootsPEMFiles = append(acmeIssuer.TrustedRootsPEMFiles, globalACMECARoot.(string))
} }
if globalACMEDNS != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.DNS == nil) { if globalACMEDNSok {
acmeIssuer.Challenges = &caddytls.ChallengesConfig{ globalDNS := options["dns"]
DNS: &caddytls.DNSChallengeConfig{ if globalDNS != nil {
ProviderRaw: caddyconfig.JSONModuleObject(globalACMEDNS, "name", globalACMEDNS.(caddy.Module).CaddyModule().ID.Name(), nil), // If global `dns` is set, do NOT set provider in issuer, just set empty dns config
}, acmeIssuer.Challenges = &caddytls.ChallengesConfig{
DNS: &caddytls.DNSChallengeConfig{},
}
} else if globalACMEDNS != nil {
// Set a global DNS provider if `acme_dns` is set and `dns` is NOT set
acmeIssuer.Challenges = &caddytls.ChallengesConfig{
DNS: &caddytls.DNSChallengeConfig{
ProviderRaw: caddyconfig.JSONModuleObject(globalACMEDNS, "name", globalACMEDNS.(caddy.Module).CaddyModule().ID.Name(), nil),
},
}
} else {
return fmt.Errorf("acme_dns specified without DNS provider config, but no provider specified with 'dns' global option")
} }
} }
if globalACMEEAB != nil && acmeIssuer.ExternalAccount == nil { if globalACMEEAB != nil && acmeIssuer.ExternalAccount == nil {
@ -491,7 +588,8 @@ 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)
} }
if globalHTTPPort != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.HTTP == nil || acmeIssuer.Challenges.HTTP.AlternatePort == 0) { // 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 { if acmeIssuer.Challenges == nil {
acmeIssuer.Challenges = new(caddytls.ChallengesConfig) acmeIssuer.Challenges = new(caddytls.ChallengesConfig)
} }
@ -500,7 +598,7 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
} }
acmeIssuer.Challenges.HTTP.AlternatePort = globalHTTPPort.(int) acmeIssuer.Challenges.HTTP.AlternatePort = globalHTTPPort.(int)
} }
if globalHTTPSPort != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.TLSALPN == nil || acmeIssuer.Challenges.TLSALPN.AlternatePort == 0) { 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 { if acmeIssuer.Challenges == nil {
acmeIssuer.Challenges = new(caddytls.ChallengesConfig) acmeIssuer.Challenges = new(caddytls.ChallengesConfig)
} }
@ -529,12 +627,18 @@ func newBaseAutomationPolicy(
_, 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 hasGlobalAutomationOpts := hasIssuers || hasLocalCerts || hasKeyType || hasOCSPStapling
globalACMECA := options["acme_ca"]
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 { if !hasGlobalAutomationOpts && !hasGlobalACMEDefaults {
if always { if always {
return new(caddytls.AutomationPolicy), nil return new(caddytls.AutomationPolicy), nil
} }
@ -556,6 +660,14 @@ 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
@ -567,7 +679,7 @@ func newBaseAutomationPolicy(
// consolidateAutomationPolicies combines automation policies that are the same, // consolidateAutomationPolicies combines automation policies that are the same,
// for a cleaner overall output. // for a cleaner overall output.
func consolidateAutomationPolicies(aps []*caddytls.AutomationPolicy, preferWildcard bool) []*caddytls.AutomationPolicy { func consolidateAutomationPolicies(aps []*caddytls.AutomationPolicy) []*caddytls.AutomationPolicy {
// sort from most specific to least specific; we depend on this ordering // sort from most specific to least specific; we depend on this ordering
sort.SliceStable(aps, func(i, j int) bool { sort.SliceStable(aps, func(i, j int) bool {
if automationPolicyIsSubset(aps[i], aps[j]) { if automationPolicyIsSubset(aps[i], aps[j]) {
@ -652,31 +764,6 @@ outer:
j-- j--
} }
} }
if preferWildcard {
// remove subjects from i if they're covered by a wildcard in j
iSubjs := aps[i].SubjectsRaw
for iSubj := 0; iSubj < len(iSubjs); iSubj++ {
for jSubj := range aps[j].SubjectsRaw {
if !strings.HasPrefix(aps[j].SubjectsRaw[jSubj], "*.") {
continue
}
if certmagic.MatchWildcard(aps[i].SubjectsRaw[iSubj], aps[j].SubjectsRaw[jSubj]) {
iSubjs = slices.Delete(iSubjs, iSubj, iSubj+1)
iSubj--
break
}
}
}
aps[i].SubjectsRaw = iSubjs
// remove i if it has no subjects left
if len(aps[i].SubjectsRaw) == 0 {
aps = slices.Delete(aps, i, i+1)
i--
continue outer
}
}
} }
} }
@ -748,3 +835,20 @@ func automationPolicyHasAllPublicNames(ap *caddytls.AutomationPolicy) bool {
func isTailscaleDomain(name string) bool { func isTailscaleDomain(name string) bool {
return strings.HasSuffix(strings.ToLower(name), ".ts.net") 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
}

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. Uf you don't have control // read just like the admin API's `/load` endpoint. If 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 {

View File

@ -31,8 +31,8 @@ import (
_ "github.com/caddyserver/caddy/v2/modules/standard" _ "github.com/caddyserver/caddy/v2/modules/standard"
) )
// Defaults store any configuration required to make the tests run // Config store any configuration required to make the tests run
type Defaults struct { type Config 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
@ -44,7 +44,7 @@ type Defaults struct {
} }
// Default testing values // Default testing values
var Default = Defaults{ var Default = Config{
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"}, Certificates: []string{"/caddy.localhost.crt", "/caddy.localhost.key"},
TestRequestTimeout: 5 * time.Second, TestRequestTimeout: 5 * time.Second,
@ -61,6 +61,7 @@ type Tester struct {
Client *http.Client Client *http.Client
configLoaded bool configLoaded bool
t testing.TB t testing.TB
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
@ -78,9 +79,29 @@ 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
} }
@ -113,7 +134,7 @@ func (tc *Tester) initServer(rawConfig string, configType string) error {
return nil return nil
} }
err := validateTestPrerequisites(tc.t) err := validateTestPrerequisites(tc)
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
@ -121,7 +142,7 @@ 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/", Default.AdminPort)) res, err := http.Get(fmt.Sprintf("http://localhost:%d/config/", tc.config.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
@ -151,10 +172,10 @@ func (tc *Tester) initServer(rawConfig string, configType string) error {
tc.t.Logf("After: %s", rawConfig) tc.t.Logf("After: %s", rawConfig)
} }
client := &http.Client{ client := &http.Client{
Timeout: Default.LoadRequestTimeout, Timeout: tc.config.LoadRequestTimeout,
} }
start := time.Now() start := time.Now()
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/load", Default.AdminPort), strings.NewReader(rawConfig)) req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/load", tc.config.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
@ -205,11 +226,11 @@ func (tc *Tester) ensureConfigRunning(rawConfig string, configType string) error
} }
client := &http.Client{ client := &http.Client{
Timeout: Default.LoadRequestTimeout, Timeout: tc.config.LoadRequestTimeout,
} }
fetchConfig := func(client *http.Client) any { fetchConfig := func(client *http.Client) any {
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", Default.AdminPort)) resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", tc.config.AdminPort))
if err != nil { if err != nil {
return nil return nil
} }
@ -237,30 +258,30 @@ func (tc *Tester) ensureConfigRunning(rawConfig string, configType string) error
} }
const initConfig = `{ const initConfig = `{
admin localhost:2999 admin localhost:%d
} }
` `
// 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(t testing.TB) error { func validateTestPrerequisites(tc *Tester) error {
// check certificates are found // check certificates are found
for _, certName := range Default.Certificates { for _, certName := range tc.config.Certificates {
if _, err := os.Stat(getIntegrationDir() + certName); errors.Is(err, fs.ErrNotExist) { if _, err := os.Stat(getIntegrationDir() + certName); errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("caddy integration test certificates (%s) not found", certName) return fmt.Errorf("caddy integration test certificates (%s) not found", certName)
} }
} }
if isCaddyAdminRunning() != nil { if isCaddyAdminRunning(tc) != 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
} }
t.Cleanup(func() { tc.t.Cleanup(func() {
os.Remove(f.Name()) os.Remove(f.Name())
}) })
if _, err := f.WriteString(initConfig); err != nil { if _, err := fmt.Fprintf(f, initConfig, tc.config.AdminPort); err != nil {
return err return err
} }
@ -271,23 +292,23 @@ func validateTestPrerequisites(t testing.TB) 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() != nil; retries-- { for retries := 10; retries > 0 && isCaddyAdminRunning(tc) != 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() return isCaddyAdminRunning(tc)
} }
func isCaddyAdminRunning() error { func isCaddyAdminRunning(tc *Tester) error {
// assert that caddy is running // assert that caddy is running
client := &http.Client{ client := &http.Client{
Timeout: Default.LoadRequestTimeout, Timeout: tc.config.LoadRequestTimeout,
} }
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", Default.AdminPort)) resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", tc.config.AdminPort))
if err != nil { if err != nil {
return fmt.Errorf("caddy integration test caddy server not running. Expected to be listening on localhost:%d", Default.AdminPort) return fmt.Errorf("caddy integration test caddy server not running. Expected to be listening on localhost:%d", tc.config.AdminPort)
} }
resp.Body.Close() resp.Body.Close()

View File

@ -6,17 +6,20 @@ import (
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
"fmt" "fmt"
"log/slog"
"net" "net"
"net/http" "net/http"
"strings" "strings"
"testing" "testing"
"github.com/caddyserver/caddy/v2" "github.com/mholt/acmez/v3"
"github.com/caddyserver/caddy/v2/caddytest" "github.com/mholt/acmez/v3/acme"
"github.com/mholt/acmez/v2"
"github.com/mholt/acmez/v2/acme"
smallstepacme "github.com/smallstep/certificates/acme" smallstepacme "github.com/smallstep/certificates/acme"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/exp/zapslog"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/caddytest"
) )
const acmeChallengePort = 9081 const acmeChallengePort = 9081
@ -48,7 +51,7 @@ func TestACMEServerWithDefaults(t *testing.T) {
Client: &acme.Client{ Client: &acme.Client{
Directory: "https://acme.localhost:9443/acme/local/directory", Directory: "https://acme.localhost:9443/acme/local/directory",
HTTPClient: tester.Client, HTTPClient: tester.Client,
Logger: logger, Logger: slog.New(zapslog.NewHandler(logger.Core())),
}, },
ChallengeSolvers: map[string]acmez.Solver{ ChallengeSolvers: map[string]acmez.Solver{
acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger}, acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},
@ -117,7 +120,7 @@ func TestACMEServerWithMismatchedChallenges(t *testing.T) {
Client: &acme.Client{ Client: &acme.Client{
Directory: "https://acme.localhost:9443/acme/local/directory", Directory: "https://acme.localhost:9443/acme/local/directory",
HTTPClient: tester.Client, HTTPClient: tester.Client,
Logger: logger, Logger: slog.New(zapslog.NewHandler(logger.Core())),
}, },
ChallengeSolvers: map[string]acmez.Solver{ ChallengeSolvers: map[string]acmez.Solver{
acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger}, acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},

View File

@ -5,13 +5,16 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
"log/slog"
"strings" "strings"
"testing" "testing"
"github.com/caddyserver/caddy/v2/caddytest" "github.com/mholt/acmez/v3"
"github.com/mholt/acmez/v2" "github.com/mholt/acmez/v3/acme"
"github.com/mholt/acmez/v2/acme"
"go.uber.org/zap" "go.uber.org/zap"
"go.uber.org/zap/exp/zapslog"
"github.com/caddyserver/caddy/v2/caddytest"
) )
func TestACMEServerDirectory(t *testing.T) { func TestACMEServerDirectory(t *testing.T) {
@ -76,7 +79,7 @@ func TestACMEServerAllowPolicy(t *testing.T) {
Client: &acme.Client{ Client: &acme.Client{
Directory: "https://acme.localhost:9443/acme/local/directory", Directory: "https://acme.localhost:9443/acme/local/directory",
HTTPClient: tester.Client, HTTPClient: tester.Client,
Logger: logger, Logger: slog.New(zapslog.NewHandler(logger.Core())),
}, },
ChallengeSolvers: map[string]acmez.Solver{ ChallengeSolvers: map[string]acmez.Solver{
acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger}, acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},
@ -165,7 +168,7 @@ func TestACMEServerDenyPolicy(t *testing.T) {
Client: &acme.Client{ Client: &acme.Client{
Directory: "https://acme.localhost:9443/acme/local/directory", Directory: "https://acme.localhost:9443/acme/local/directory",
HTTPClient: tester.Client, HTTPClient: tester.Client,
Logger: logger, Logger: slog.New(zapslog.NewHandler(logger.Core())),
}, },
ChallengeSolvers: map[string]acmez.Solver{ ChallengeSolvers: map[string]acmez.Solver{
acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger}, acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},

View File

@ -0,0 +1,68 @@
{
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": {
"name": "mock"
}
}
},
"module": "acme"
}
]
}
]
}
}
}
}

View File

@ -0,0 +1,53 @@
{
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"
}
}
}
}

View File

@ -0,0 +1,9 @@
{
acme_dns
}
example.com {
respond "Hello World"
}
----------
acme_dns specified without DNS provider config, but no provider specified with 'dns' global option

View File

@ -0,0 +1,72 @@
{
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"
}
}
}
}
}

View File

@ -0,0 +1,80 @@
{
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"
}
}
}
}
}

View File

@ -0,0 +1,71 @@
{
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"
}
}
}
}
}

View File

@ -5,15 +5,15 @@
root_cn "Internal Root Cert" root_cn "Internal Root Cert"
intermediate_cn "Internal Intermediate Cert" intermediate_cn "Internal Intermediate Cert"
} }
} }
} }
acme.example.com { acme.example.com {
acme_server { acme_server {
ca internal ca internal
sign_with_root sign_with_root
} }
} }
---------- ----------
{ {
"apps": { "apps": {

View File

@ -0,0 +1,12 @@
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.

View File

@ -0,0 +1,9 @@
:8080 {
respond "one"
}
:8080 {
respond "two"
}
----------
ambiguous site definition: :8080

View File

@ -0,0 +1,5 @@
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

View File

@ -0,0 +1,12 @@
{
servers {
srv0 {
listen :8080
}
srv1 {
listen :8080
}
}
}
----------
parsing caddyfile tokens for 'servers': unrecognized servers option 'srv0', at Caddyfile:3

View File

@ -21,6 +21,8 @@ encode {
zstd zstd
gzip 5 gzip 5
} }
encode
---------- ----------
{ {
"apps": { "apps": {
@ -76,6 +78,17 @@ encode {
"zstd", "zstd",
"gzip" "gzip"
] ]
},
{
"encodings": {
"gzip": {},
"zstd": {}
},
"handler": "encode",
"prefer": [
"zstd",
"gzip"
]
} }
] ]
} }

View File

@ -106,20 +106,29 @@ example.com {
"handler": "subroute", "handler": "subroute",
"routes": [ "routes": [
{ {
"group": "group0",
"handle": [ "handle": [
{ {
"handler": "rewrite", "handler": "subroute",
"uri": "/{http.error.status_code}.html" "routes": [
} {
] "group": "group0",
}, "handle": [
{ {
"handle": [ "handler": "rewrite",
{ "uri": "/{http.error.status_code}.html"
"handler": "file_server", }
"hide": [ ]
"./Caddyfile" },
{
"handle": [
{
"handler": "file_server",
"hide": [
"./Caddyfile"
]
}
]
}
] ]
} }
] ]

View File

@ -165,8 +165,17 @@ bar.localhost {
{ {
"handle": [ "handle": [
{ {
"body": "404 or 410 error", "handler": "subroute",
"handler": "static_response" "routes": [
{
"handle": [
{
"body": "404 or 410 error",
"handler": "static_response"
}
]
}
]
} }
], ],
"match": [ "match": [
@ -178,8 +187,17 @@ bar.localhost {
{ {
"handle": [ "handle": [
{ {
"body": "Error In range [500 .. 599]", "handler": "subroute",
"handler": "static_response" "routes": [
{
"handle": [
{
"body": "Error In range [500 .. 599]",
"handler": "static_response"
}
]
}
]
} }
], ],
"match": [ "match": [
@ -208,8 +226,17 @@ bar.localhost {
{ {
"handle": [ "handle": [
{ {
"body": "404 or 410 error from second site", "handler": "subroute",
"handler": "static_response" "routes": [
{
"handle": [
{
"body": "404 or 410 error from second site",
"handler": "static_response"
}
]
}
]
} }
], ],
"match": [ "match": [
@ -221,8 +248,17 @@ bar.localhost {
{ {
"handle": [ "handle": [
{ {
"body": "Error In range [500 .. 599] from second site", "handler": "subroute",
"handler": "static_response" "routes": [
{
"handle": [
{
"body": "Error In range [500 .. 599] from second site",
"handler": "static_response"
}
]
}
]
} }
], ],
"match": [ "match": [

View File

@ -96,8 +96,17 @@ localhost:3010 {
{ {
"handle": [ "handle": [
{ {
"body": "Error in the [400 .. 499] range", "handler": "subroute",
"handler": "static_response" "routes": [
{
"handle": [
{
"body": "Error in the [400 .. 499] range",
"handler": "static_response"
}
]
}
]
} }
], ],
"match": [ "match": [

View File

@ -116,8 +116,17 @@ localhost:2099 {
{ {
"handle": [ "handle": [
{ {
"body": "Error in the [400 .. 499] range", "handler": "subroute",
"handler": "static_response" "routes": [
{
"handle": [
{
"body": "Error in the [400 .. 499] range",
"handler": "static_response"
}
]
}
]
} }
], ],
"match": [ "match": [
@ -129,8 +138,17 @@ localhost:2099 {
{ {
"handle": [ "handle": [
{ {
"body": "Error code is equal to 500 or in the [300..399] range", "handler": "subroute",
"handler": "static_response" "routes": [
{
"handle": [
{
"body": "Error code is equal to 500 or in the [300..399] range",
"handler": "static_response"
}
]
}
]
} }
], ],
"match": [ "match": [

View File

@ -96,8 +96,17 @@ localhost:3010 {
{ {
"handle": [ "handle": [
{ {
"body": "404 or 410 error", "handler": "subroute",
"handler": "static_response" "routes": [
{
"handle": [
{
"body": "404 or 410 error",
"handler": "static_response"
}
]
}
]
} }
], ],
"match": [ "match": [

View File

@ -116,8 +116,17 @@ localhost:2099 {
{ {
"handle": [ "handle": [
{ {
"body": "Error in the [400 .. 499] range", "handler": "subroute",
"handler": "static_response" "routes": [
{
"handle": [
{
"body": "Error in the [400 .. 499] range",
"handler": "static_response"
}
]
}
]
} }
], ],
"match": [ "match": [
@ -129,8 +138,17 @@ localhost:2099 {
{ {
"handle": [ "handle": [
{ {
"body": "Fallback route: code outside the [400..499] range", "handler": "subroute",
"handler": "static_response" "routes": [
{
"handle": [
{
"body": "Fallback route: code outside the [400..499] range",
"handler": "static_response"
}
]
}
]
} }
] ]
} }

View File

@ -0,0 +1,260 @@
{
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
}
]
}
}
}
}
}
}

View File

@ -0,0 +1,36 @@
:80
file_server {
browse {
file_limit 4000
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":80"
],
"routes": [
{
"handle": [
{
"browse": {
"file_limit": 4000
},
"handler": "file_server",
"hide": [
"./Caddyfile"
]
}
]
}
]
}
}
}
}
}

View File

@ -3,6 +3,10 @@
file_server { file_server {
precompressed zstd br gzip precompressed zstd br gzip
} }
file_server {
precompressed
}
---------- ----------
{ {
"apps": { "apps": {
@ -30,6 +34,22 @@ file_server {
"br", "br",
"gzip" "gzip"
] ]
},
{
"handler": "file_server",
"hide": [
"./Caddyfile"
],
"precompressed": {
"br": {},
"gzip": {},
"zstd": {}
},
"precompressed_order": [
"br",
"zstd",
"gzip"
]
} }
] ]
} }

View File

@ -1,6 +1,6 @@
app.example.com { app.example.com {
forward_auth authelia:9091 { forward_auth authelia:9091 {
uri /api/verify?rd=https://authelia.example.com uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
} }
@ -39,6 +39,13 @@ app.example.com {
] ]
}, },
"routes": [ "routes": [
{
"handle": [
{
"handler": "vars"
}
]
},
{ {
"handle": [ "handle": [
{ {
@ -47,19 +54,104 @@ app.example.com {
"set": { "set": {
"Remote-Email": [ "Remote-Email": [
"{http.reverse_proxy.header.Remote-Email}" "{http.reverse_proxy.header.Remote-Email}"
], ]
}
}
}
],
"match": [
{
"not": [
{
"vars": {
"{http.reverse_proxy.header.Remote-Email}": [
""
]
}
}
]
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"Remote-Groups": [ "Remote-Groups": [
"{http.reverse_proxy.header.Remote-Groups}" "{http.reverse_proxy.header.Remote-Groups}"
], ]
}
}
}
],
"match": [
{
"not": [
{
"vars": {
"{http.reverse_proxy.header.Remote-Groups}": [
""
]
}
}
]
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"Remote-Name": [ "Remote-Name": [
"{http.reverse_proxy.header.Remote-Name}" "{http.reverse_proxy.header.Remote-Name}"
], ]
}
}
}
],
"match": [
{
"not": [
{
"vars": {
"{http.reverse_proxy.header.Remote-Name}": [
""
]
}
}
]
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"Remote-User": [ "Remote-User": [
"{http.reverse_proxy.header.Remote-User}" "{http.reverse_proxy.header.Remote-User}"
] ]
} }
} }
} }
],
"match": [
{
"not": [
{
"vars": {
"{http.reverse_proxy.header.Remote-User}": [
""
]
}
}
]
}
] ]
} }
] ]
@ -80,7 +172,7 @@ app.example.com {
}, },
"rewrite": { "rewrite": {
"method": "GET", "method": "GET",
"uri": "/api/verify?rd=https://authelia.example.com" "uri": "/api/authz/forward-auth"
}, },
"upstreams": [ "upstreams": [
{ {

View File

@ -28,6 +28,13 @@ forward_auth localhost:9000 {
] ]
}, },
"routes": [ "routes": [
{
"handle": [
{
"handler": "vars"
}
]
},
{ {
"handle": [ "handle": [
{ {
@ -36,22 +43,131 @@ forward_auth localhost:9000 {
"set": { "set": {
"1": [ "1": [
"{http.reverse_proxy.header.A}" "{http.reverse_proxy.header.A}"
], ]
"3": [ }
"{http.reverse_proxy.header.C}" }
], }
"5": [ ],
"{http.reverse_proxy.header.E}" "match": [
], {
"not": [
{
"vars": {
"{http.reverse_proxy.header.A}": [
""
]
}
}
]
}
]
},
{
"handle": [
{
"handler": "headers",
"request": {
"set": {
"B": [ "B": [
"{http.reverse_proxy.header.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": [ "D": [
"{http.reverse_proxy.header.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}": [
""
]
}
}
]
}
] ]
} }
] ]

View File

@ -9,6 +9,8 @@
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
@ -17,8 +19,6 @@
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
@ -72,14 +72,12 @@
"permission": { "permission": {
"endpoint": "https://example.com", "endpoint": "https://example.com",
"module": "http" "module": "http"
},
"rate_limit": {
"interval": 30000000000,
"burst": 20
} }
} }
}, },
"disable_ocsp_stapling": true "disable_ocsp_stapling": true,
"disable_storage_check": true,
"disable_storage_clean": true
} }
} }
} }

View File

@ -17,8 +17,6 @@
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
@ -89,10 +87,6 @@
"permission": { "permission": {
"endpoint": "https://example.com", "endpoint": "https://example.com",
"module": "http" "module": "http"
},
"rate_limit": {
"interval": 30000000000,
"burst": 20
} }
}, },
"ocsp_interval": 172800000000000, "ocsp_interval": 172800000000000,

View File

@ -16,8 +16,6 @@
} }
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
@ -74,10 +72,6 @@
"permission": { "permission": {
"endpoint": "https://example.com", "endpoint": "https://example.com",
"module": "http" "module": "http"
},
"rate_limit": {
"interval": 30000000000,
"burst": 20
} }
} }
} }

View File

@ -0,0 +1,23 @@
{
log {
sampling {
interval 300
first 50
thereafter 40
}
}
}
----------
{
"logging": {
"logs": {
"default": {
"sampling": {
"interval": 300,
"first": 50,
"thereafter": 40
}
}
}
}
}

View File

@ -31,9 +31,6 @@ example.com
"automation": { "automation": {
"policies": [ "policies": [
{ {
"subjects": [
"example.com"
],
"issuers": [ "issuers": [
{ {
"module": "acme", "module": "acme",

View File

@ -12,10 +12,14 @@
@images path /images/* @images path /images/*
header @images { header @images {
Cache-Control "public, max-age=3600, stale-while-revalidate=86400" Cache-Control "public, max-age=3600, stale-while-revalidate=86400"
match {
status 200
}
} }
header { header {
+Link "Foo" +Link "Foo"
+Link "Bar" +Link "Bar"
match status 200
} }
header >Set Defer header >Set Defer
header >Replace Deferred Replacement header >Replace Deferred Replacement
@ -42,6 +46,11 @@
{ {
"handler": "headers", "handler": "headers",
"response": { "response": {
"require": {
"status_code": [
200
]
},
"set": { "set": {
"Cache-Control": [ "Cache-Control": [
"public, max-age=3600, stale-while-revalidate=86400" "public, max-age=3600, stale-while-revalidate=86400"
@ -136,6 +145,11 @@
"Foo", "Foo",
"Bar" "Bar"
] ]
},
"require": {
"status_code": [
200
]
} }
} }
}, },

View File

@ -0,0 +1,64 @@
:80 {
header Test-Static ":443" "STATIC-WORKS"
header Test-Dynamic ":{http.request.local.port}" "DYNAMIC-WORKS"
header Test-Complex "port-{http.request.local.port}-end" "COMPLEX-{http.request.method}"
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":80"
],
"routes": [
{
"handle": [
{
"handler": "headers",
"response": {
"replace": {
"Test-Static": [
{
"replace": "STATIC-WORKS",
"search_regexp": ":443"
}
]
}
}
},
{
"handler": "headers",
"response": {
"replace": {
"Test-Dynamic": [
{
"replace": "DYNAMIC-WORKS",
"search_regexp": ":{http.request.local.port}"
}
]
}
}
},
{
"handler": "headers",
"response": {
"replace": {
"Test-Complex": [
{
"replace": "COMPLEX-{http.request.method}",
"search_regexp": "port-{http.request.local.port}-end"
}
]
}
}
}
]
}
]
}
}
}
}
}

View File

@ -1,11 +1,12 @@
example.com { example.com {
respond <<EOF respond <<EOF
<html> <html>
<head><title>Foo</title> <head><title>Foo</title>
<body>Foo</body> <body>Foo</body>
</html> </html>
EOF 200 EOF 200
} }
---------- ----------
{ {
"apps": { "apps": {

View File

@ -0,0 +1,41 @@
:80
handle {
respond <<END
line1
line2
END
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":80"
],
"routes": [
{
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"body": " line1\n line2",
"handler": "static_response"
}
]
}
]
}
]
}
]
}
}
}
}
}

View File

@ -0,0 +1,9 @@
:80
handle {
respond <<EOF
Hello
# missing EOF marker
}
----------
mismatched leading whitespace in heredoc <<EOF on line #5 [ Hello], expected whitespace [# missing ] to match the closing marker

View File

@ -0,0 +1,9 @@
:80
handle {
respond <<END!
Hello
END!
}
----------
heredoc marker on line #4 must contain only alpha-numeric characters, dashes and underscores; got 'END!'

View File

@ -0,0 +1,10 @@
:80
handle {
respond <<END
line1
line2
END
}
----------
mismatched leading whitespace in heredoc <<END on line #5 [ line1], expected whitespace [ ] to match the closing marker

View File

@ -0,0 +1,9 @@
:80
handle {
respond <<
Hello
END
}
----------
parsing caddyfile tokens for 'handle': unrecognized directive: Hello - are you sure your Caddyfile structure (nesting and braces) is correct?, at Caddyfile:7

View File

@ -0,0 +1,9 @@
:80
handle {
respond <<<END
Hello
END
}
----------
too many '<' for heredoc on line #4; only use two, for example <<END

View File

@ -0,0 +1,13 @@
(site) {
http://{args[0]} https://{args[0]} {
{block}
}
}
import site test.domain {
{
header_up Host {host}
header_up X-Real-IP {remote_host}
}
}
----------
anonymous blocks are not supported

View File

@ -0,0 +1,65 @@
(site) {
https://{args[0]} {
{block}
}
}
import site test.domain {
reverse_proxy http://192.168.1.1:8080 {
header_up Host {host}
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":443"
],
"routes": [
{
"match": [
{
"host": [
"test.domain"
]
}
],
"handle": [
{
"handler": "subroute",
"routes": [
{
"handle": [
{
"handler": "reverse_proxy",
"headers": {
"request": {
"set": {
"Host": [
"{http.request.host}"
]
}
}
},
"upstreams": [
{
"dial": "192.168.1.1:8080"
}
]
}
]
}
]
}
],
"terminal": true
}
]
}
}
}
}
}

View File

@ -0,0 +1,12 @@
(import1) {
import import2
}
(import2) {
import import1
}
import import1
----------
a cycle of imports exists between Caddyfile:import2 and Caddyfile:import1

View File

@ -0,0 +1,5 @@
example.com {
invoke foo
}
----------
cannot invoke named route 'foo', which was not defined

View File

@ -0,0 +1,45 @@
:80 {
log {
sampling {
interval 300
first 50
thereafter 40
}
}
}
----------
{
"logging": {
"logs": {
"default": {
"exclude": [
"http.log.access.log0"
]
},
"log0": {
"sampling": {
"interval": 300,
"first": 50,
"thereafter": 40
},
"include": [
"http.log.access.log0"
]
}
}
},
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":80"
],
"logs": {
"default_logger_name": "log0"
}
}
}
}
}
}

View File

@ -1,23 +1,23 @@
example.com example.com
map {host} {my_placeholder} {magic_number} { map {host} {my_placeholder} {magic_number} {
# Should output boolean "true" and an integer # Should output boolean "true" and an integer
example.com true 3 example.com true 3
# Should output a string and null # Should output a string and null
foo.example.com "string value" foo.example.com "string value"
# Should output two strings (quoted int) # Should output two strings (quoted int)
(.*)\.example.com "${1} subdomain" "5" (.*)\.example.com "${1} subdomain" "5"
# Should output null and a string (quoted int) # Should output null and a string (quoted int)
~.*\.net$ - `7` ~.*\.net$ - `7`
# Should output a float and the string "false" # Should output a float and the string "false"
~.*\.xyz$ 123.456 "false" ~.*\.xyz$ 123.456 "false"
# Should output two strings, second being escaped quote # Should output two strings, second being escaped quote
default "unknown domain" \""" default "unknown domain" \"""
} }
vars foo bar vars foo bar
@ -27,6 +27,7 @@ vars {
ghi 2.3 ghi 2.3
jkl "mn op" jkl "mn op"
} }
---------- ----------
{ {
"apps": { "apps": {

View File

@ -0,0 +1,9 @@
@foo {
path /foo
}
handle {
respond "should not work"
}
----------
request matchers may not be defined globally, they must be in a site block; found @foo, at Caddyfile:1

View File

@ -0,0 +1,39 @@
{
metrics
servers :80 {
metrics {
per_host
}
}
}
:80 {
respond "Hello"
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":80"
],
"routes": [
{
"handle": [
{
"body": "Hello",
"handler": "static_response"
}
]
}
]
}
},
"metrics": {
"per_host": true
}
}
}
}

View File

@ -0,0 +1,37 @@
{
servers :80 {
metrics {
per_host
}
}
}
:80 {
respond "Hello"
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":80"
],
"routes": [
{
"handle": [
{
"body": "Hello",
"handler": "static_response"
}
]
}
]
}
},
"metrics": {
"per_host": true
}
}
}
}

View File

@ -8,7 +8,7 @@ route {
} }
not path */ not path */
} }
redir @canonicalPath {http.request.orig_uri.path}/ 308 redir @canonicalPath {orig_path}/{orig_?query} 308
# If the requested file does not exist, try index files # If the requested file does not exist, try index files
@indexFiles { @indexFiles {
@ -17,7 +17,7 @@ route {
split_path .php split_path .php
} }
} }
rewrite @indexFiles {http.matchers.file.relative} rewrite @indexFiles {file_match.relative}
# Proxy PHP files to the FastCGI responder # Proxy PHP files to the FastCGI responder
@phpFiles { @phpFiles {
@ -50,7 +50,7 @@ route {
"handler": "static_response", "handler": "static_response",
"headers": { "headers": {
"Location": [ "Location": [
"{http.request.orig_uri.path}/" "{http.request.orig_uri.path}/{http.request.orig_uri.prefixed_query}"
] ]
}, },
"status_code": 308 "status_code": 308

View File

@ -42,7 +42,7 @@
"handler": "static_response", "handler": "static_response",
"headers": { "headers": {
"Location": [ "Location": [
"{http.request.orig_uri.path}/" "{http.request.orig_uri.path}/{http.request.orig_uri.prefixed_query}"
] ]
}, },
"status_code": 308 "status_code": 308
@ -58,6 +58,7 @@
"{http.request.uri.path}/index.php", "{http.request.uri.path}/index.php",
"index.php" "index.php"
], ],
"try_policy": "first_exist_fallback",
"split_path": [ "split_path": [
".php" ".php"
] ]

View File

@ -33,7 +33,7 @@ php_fastcgi @test localhost:9000
"handler": "static_response", "handler": "static_response",
"headers": { "headers": {
"Location": [ "Location": [
"{http.request.orig_uri.path}/" "{http.request.orig_uri.path}/{http.request.orig_uri.prefixed_query}"
] ]
}, },
"status_code": 308 "status_code": 308
@ -73,7 +73,8 @@ php_fastcgi @test localhost:9000
"{http.request.uri.path}", "{http.request.uri.path}",
"{http.request.uri.path}/index.php", "{http.request.uri.path}/index.php",
"index.php" "index.php"
] ],
"try_policy": "first_exist_fallback"
} }
} }
] ]

View File

@ -43,7 +43,7 @@ php_fastcgi localhost:9000 {
"handler": "static_response", "handler": "static_response",
"headers": { "headers": {
"Location": [ "Location": [
"{http.request.orig_uri.path}/" "{http.request.orig_uri.path}/{http.request.orig_uri.prefixed_query}"
] ]
}, },
"status_code": 308 "status_code": 308
@ -59,6 +59,7 @@ php_fastcgi localhost:9000 {
"{http.request.uri.path}/index.php5", "{http.request.uri.path}/index.php5",
"index.php5" "index.php5"
], ],
"try_policy": "first_exist_fallback",
"split_path": [ "split_path": [
".php", ".php",
".php5" ".php5"

View File

@ -46,7 +46,7 @@ php_fastcgi localhost:9000 {
"handler": "static_response", "handler": "static_response",
"headers": { "headers": {
"Location": [ "Location": [
"{http.request.orig_uri.path}/" "{http.request.orig_uri.path}/{http.request.orig_uri.prefixed_query}"
] ]
}, },
"status_code": 308 "status_code": 308

View File

@ -0,0 +1,95 @@
:8884
php_fastcgi localhost:9000 {
# some php_fastcgi-specific subdirectives
split .php .php5
env VAR1 value1
env VAR2 value2
root /var/www
try_files {path} index.php
dial_timeout 3s
read_timeout 10s
write_timeout 20s
# passed through to reverse_proxy (directive order doesn't matter!)
lb_policy random
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":8884"
],
"routes": [
{
"match": [
{
"file": {
"try_files": [
"{http.request.uri.path}",
"index.php"
],
"try_policy": "first_exist_fallback",
"split_path": [
".php",
".php5"
]
}
}
],
"handle": [
{
"handler": "rewrite",
"uri": "{http.matchers.file.relative}"
}
]
},
{
"match": [
{
"path": [
"*.php",
"*.php5"
]
}
],
"handle": [
{
"handler": "reverse_proxy",
"load_balancing": {
"selection_policy": {
"policy": "random"
}
},
"transport": {
"dial_timeout": 3000000000,
"env": {
"VAR1": "value1",
"VAR2": "value2"
},
"protocol": "fastcgi",
"read_timeout": 10000000000,
"root": "/var/www",
"split_path": [
".php",
".php5"
],
"write_timeout": 20000000000
},
"upstreams": [
{
"dial": "localhost:9000"
}
]
}
]
}
]
}
}
}
}
}

View File

@ -0,0 +1,41 @@
:8884
reverse_proxy 127.0.0.1:65535 {
transport http {
forward_proxy_url http://localhost:8080
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":8884"
],
"routes": [
{
"handle": [
{
"handler": "reverse_proxy",
"transport": {
"network_proxy": {
"from": "url",
"url": "http://localhost:8080"
},
"protocol": "http"
},
"upstreams": [
{
"dial": "127.0.0.1:65535"
}
]
}
]
}
]
}
}
}
}
}

View File

@ -0,0 +1,40 @@
:8884
reverse_proxy 127.0.0.1:65535 {
transport http {
network_proxy none
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":8884"
],
"routes": [
{
"handle": [
{
"handler": "reverse_proxy",
"transport": {
"network_proxy": {
"from": "none"
},
"protocol": "http"
},
"upstreams": [
{
"dial": "127.0.0.1:65535"
}
]
}
]
}
]
}
}
}
}
}

View File

@ -0,0 +1,41 @@
:8884
reverse_proxy 127.0.0.1:65535 {
transport http {
network_proxy url http://localhost:8080
}
}
----------
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [
":8884"
],
"routes": [
{
"handle": [
{
"handler": "reverse_proxy",
"transport": {
"network_proxy": {
"from": "url",
"url": "http://localhost:8080"
},
"protocol": "http"
},
"upstreams": [
{
"dial": "127.0.0.1:65535"
}
]
}
]
}
]
}
}
}
}
}

View File

@ -0,0 +1,7 @@
:70000
handle {
respond "should not work"
}
----------
port 70000 is out of range

View File

@ -0,0 +1,7 @@
:-1
handle {
respond "should not work"
}
----------
port -1 is out of range

View File

@ -0,0 +1,7 @@
foo://example.com
handle {
respond "hello"
}
----------
unsupported URL scheme foo://

View File

@ -0,0 +1,7 @@
wss://example.com:70000
handle {
respond "should not work"
}
----------
port 70000 is out of range

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