mirror of
https://github.com/caddyserver/caddy.git
synced 2026-05-25 16:22:36 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e6eed42bd | |||
| 98cd4333a1 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
[*]
|
||||
end_of_line = lf
|
||||
|
||||
[caddytest/integration/caddyfile_adapt/*.caddyfiletest]
|
||||
[caddytest/integration/caddyfile_adapt/*.txt]
|
||||
indent_style = tab
|
||||
@@ -1 +0,0 @@
|
||||
*.go text eol=lf
|
||||
+7
-17
@@ -1,7 +1,7 @@
|
||||
Contributing to Caddy
|
||||
=====================
|
||||
|
||||
Welcome! Thank you for choosing to be a part of our community. Caddy wouldn't be nearly as excellent without your involvement!
|
||||
Welcome! Thank you for choosing to be a part of our community. Caddy wouldn't be great without your involvement!
|
||||
|
||||
For starters, we invite you to join [the Caddy forum](https://caddy.community) where you can hang out with other Caddy users and developers.
|
||||
|
||||
@@ -25,7 +25,7 @@ Other menu items:
|
||||
|
||||
You can have a huge impact on the project by helping with its code. To contribute code to Caddy, first submit or comment in an issue to discuss your contribution, then open a [pull request](https://github.com/caddyserver/caddy/pulls) (PR). If you're new to our community, that's okay: **we gladly welcome pull requests from anyone, regardless of your native language or coding experience.** You can get familiar with Caddy's code base by using [code search at Sourcegraph](https://sourcegraph.com/github.com/caddyserver/caddy).
|
||||
|
||||
We hold contributions to a high standard for quality :bowtie:, so don't be surprised if we ask for revisions—even if it seems small or insignificant. Please don't take it personally. :blue_heart: If your change is on the right track, we can guide you to make it mergeable.
|
||||
We hold contributions to a high standard for quality :bowtie:, so don't be surprised if we ask for revisions—even if it seems small or insignificant. Please don't take it personally. :blue_heart: If your change is on the right track, we can guide you to make it mergable.
|
||||
|
||||
Here are some of the expectations we have of contributors:
|
||||
|
||||
@@ -35,29 +35,19 @@ Here are some of the expectations we have of contributors:
|
||||
|
||||
- **Keep related commits together in a PR.** We do want pull requests to be small, but you should also keep multiple related commits in the same PR if they rely on each other.
|
||||
|
||||
- **Write tests.** Good, automated tests are very valuable! Written properly, they ensure your change works, and that other changes in the future won't break your change. CI checks should pass.
|
||||
- **Write tests.** Tests are essential! Written properly, they ensure your change works, and that other changes in the future won't break your change. CI checks should pass.
|
||||
|
||||
- **Benchmarks should be included for optimizations.** Optimizations sometimes make code harder to read or have changes that are less than obvious. They should be proven with benchmarks and profiling.
|
||||
- **Benchmarks should be included for optimizations.** Optimizations sometimes make code harder to read or have changes that are less than obvious. They should be proven with benchmarks or profiling.
|
||||
|
||||
- **[Squash](http://gitready.com/advanced/2009/02/10/squashing-commits-with-rebase.html) insignificant commits.** Every commit should be significant. Commits which merely rewrite a comment or fix a typo can be combined into another commit that has more substance. Interactive rebase can do this, or a simpler way is `git reset --soft <diverging-commit>` then `git commit -s`.
|
||||
|
||||
- **Be responsible for and maintain your contributions.** Caddy is a growing project, and it's much better when individual contributors help maintain their change after it is merged.
|
||||
- **Own your contributions.** Caddy is a growing project, and it's much better when individual contributors help maintain their change after it is merged.
|
||||
|
||||
- **Use comments properly.** We expect good godoc comments for package-level functions, types, and values. Comments are also useful whenever the purpose for a line of code is not obvious.
|
||||
|
||||
- **Pull requests may still get closed.** The longer a PR stays open and idle, the more likely it is to be closed. If we haven't reviewed it in a while, it probably means the change is not a priority. Please don't take this personally, we're trying to balance a lot of tasks! If nobody else has commented or reacted to the PR, it likely means your change is useful only to you. The reality is this happens quite a lot. We don't tend to accept PRs that aren't generally helpful. For these reasons or others, the PR may get closed even after a review. We are not obligated to accept all proposed changes, even if the best justification we can give is something vague like, "It doesn't sit right." Sometimes PRs are just the wrong thing or the wrong time. Because it is open source, you can always build your own modified version of Caddy with a change you need, even if we reject it in the official repo. Plus, because Caddy is extensible, it's possible your feature could make a great plugin instead!
|
||||
- **Pull requests may still get closed.** The longer a PR stays open and idle, the more likely it is to be closed. If we haven't reviewed it in a while, it probably means the change is not a priority. Please don't take this personally, we're trying to balance a lot of tasks! If nobody else has commented or reacted to the PR, it likely means your change is useful only to you. The reality is this happens quite a bit. We don't tend to accept PRs that aren't generally helpful. For these reasons or others, the PR may get closed even after a review. We are not obligated to accept all proposed changes, even if the best justification we can give is something vague like, "It doesn't sit right." Sometimes PRs are just the wrong thing or the wrong time. Because it is open source, you can always build your own modified version of Caddy with a change you need, even if we reject it in the official repo.
|
||||
|
||||
- **You certify that you wrote and comprehend the code you submit.** The Caddy project welcomes original contributions that comply with [our CLA](https://cla-assistant.io/caddyserver/caddy), meaning that authors must be able to certify that they created or have rights to the code they are contributing. In addition, we require that code is not simply copy-pasted from Q/A sites or AI language models without full comprehension and rigorous testing. In other words: contributors are allowed to refer to communities for assistance and use AI tools such as language models for inspiration, but code which originates from or is assisted by these resources MUST be:
|
||||
|
||||
- Licensed for you to freely share
|
||||
- Fully comprehended by you (be able to explain every line of code)
|
||||
- Verified by automated tests when feasible, or thorough manual tests otherwise
|
||||
|
||||
We have found that current language models (LLMs, like ChatGPT) may understand code syntax and even problem spaces to an extent, but often fail in subtle ways to convey true knowledge and produce correct algorithms. Integrated tools such as GitHub Copilot and Sourcegraph Cody may be used for inspiration, but code generated by these tools still needs to meet our criteria for licensing, human comprehension, and testing. These tools may be used to help write code comments and tests as long as you can certify they are accurate and correct. Note that it is often more trouble than it's worth to certify that Copilot (for example) is not giving you code that is possibly plagiarised, unlicensed, or licensed with incompatible terms -- as the Caddy project cannot accept such contributions. If that's too difficult for you (or impossible), then we recommend using these resources only for inspiration and write your own code. Ultimately, you (the contributor) are responsible for the code you're submitting.
|
||||
|
||||
As a courtesy to reviewers, we kindly ask that you disclose when contributing code that was generated by an AI tool or copied from another website so we can be aware of what to look for in code review.
|
||||
|
||||
We often grant [collaborator status](#collaborator-instructions) to contributors who author one or more significant, high-quality PRs that are merged into the code base.
|
||||
We often grant [collaborator status](#collaborator-instructions) to contributors who author one or more significant, high-quality PRs that are merged into the code base!
|
||||
|
||||
|
||||
#### HOW TO MAKE A PULL REQUEST TO CADDY
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
name: Issue
|
||||
description: An actionable development item, like a bug report or feature request
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for opening an issue! This is for actionable development items like bug reports and feature requests.
|
||||
If you have a question about using Caddy, please [post on our forums](https://caddy.community) instead.
|
||||
- type: textarea
|
||||
id: content
|
||||
attributes:
|
||||
label: Issue Details
|
||||
placeholder: Describe the issue here. Be specific by providing complete logs and minimal instructions to reproduce, or a thoughtful proposal, etc.
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: assistance-disclosure
|
||||
attributes:
|
||||
label: Assistance Disclosure
|
||||
description: "Our project allows assistance by AI/LLM tools as long as it is disclosed and described so we can better respond. Please certify whether you have used any such tooling related to this issue:"
|
||||
options:
|
||||
-
|
||||
- AI used
|
||||
- AI not used
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: assistance-description
|
||||
attributes:
|
||||
label: If AI was used, describe the extent to which it was used.
|
||||
description: 'Examples: "ChatGPT translated from my native language" or "Claude proposed this change/feature"'
|
||||
@@ -1,5 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Caddy forum
|
||||
url: https://caddy.community
|
||||
about: If you have questions (or answers!) about using Caddy, please use our forum
|
||||
+10
-12
@@ -5,11 +5,11 @@ The Caddy project would like to make sure that it stays on top of all practicall
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| -------- | ----------|
|
||||
| 2.latest | ✔️ |
|
||||
| 1.x | :x: |
|
||||
| < 1.x | :x: |
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 2.x | :white_check_mark: |
|
||||
| 1.x | :x: |
|
||||
| < 1.x | :x: |
|
||||
|
||||
|
||||
## Acceptable Scope
|
||||
@@ -18,13 +18,13 @@ A security report must demonstrate a security bug in the source code from this r
|
||||
|
||||
Some security problems are the result of interplay between different components of the Web, rather than a vulnerability in the web server itself. Please only report vulnerabilities in the web server itself, as we cannot coerce the rest of the Web to be fixed (for example, we do not consider IP spoofing, BGP hijacks, or missing/misconfigured HTTP headers a vulnerability in the Caddy web server).
|
||||
|
||||
Vulnerabilities caused by misconfigurations are out of scope. Yes, it is entirely possible to craft and use a configuration that is unsafe, just like with every other web server; we recommend against doing that. Similarly, external misconfigurations are out of scope. For example, an open or forwarded port from a public network to a Caddy instance intended to serve only internal clients is not a vulnerability in Caddy.
|
||||
Vulnerabilities caused by misconfigurations are out of scope. Yes, it is entirely possible to craft and use a configuration that is unsafe, just like with every other web server; we recommend against doing that.
|
||||
|
||||
We do not accept reports if the steps imply or require a compromised system or third-party software, as we cannot control those. We expect that users secure their own systems and keep all their software patched. For example, if untrusted users are able to upload/write/host arbitrary files in the web root directory, it is NOT a security bug in Caddy if those files get served to clients; however, it _would_ be a valid report if a bug in Caddy's source code unintentionally gave unauthorized users the ability to upload unsafe files or delete files without relying on an unpatched system or piece of software.
|
||||
|
||||
Client-side exploits are out of scope. In other words, it is not a bug in Caddy if the web browser does something unsafe, even if the downloaded content was served by Caddy. (Those kinds of exploits can generally be mitigated by proper configuration of HTTP headers.) As a general rule, the content served by Caddy is not considered in scope because content is configurable by the site owner or the associated web application.
|
||||
|
||||
Security bugs in code dependencies (including Go's standard library) are out of scope. Instead, if a dependency has patched a relevant security bug, please feel free to open a public issue or pull request to update that dependency in our code.
|
||||
Security bugs in code dependencies are out of scope. Instead, if a dependency has patched a relevant security bug, please feel free to open a public issue or pull request to update that dependency in our code.
|
||||
|
||||
|
||||
## Reporting a Vulnerability
|
||||
@@ -33,8 +33,6 @@ We get a lot of difficult reports that turn out to be invalid. Clear, obvious re
|
||||
|
||||
First please ensure your report falls within the accepted scope of security bugs (above).
|
||||
|
||||
**YOU MUST DISCLOSE THE USE OF LLMs ("AI") INVOLVED IN ANY WAY.** Whether you are using AI for discovery, as part of writing the report or its replies, and/or testing or validating proofs and changes, we require you to mention the extent of it. **FAILURE TO INCLUDE A DISCLOSURE MAY LEAD TO IMMEDIATE DISMISSAL OF YOUR REPORT AND POTENTIAL BLOCKLISTING.**
|
||||
|
||||
We'll need enough information to verify the bug and make a patch. To speed things up, please include:
|
||||
|
||||
- Most minimal possible config (without redactions!)
|
||||
@@ -44,15 +42,15 @@ We'll need enough information to verify the bug and make a patch. To speed thing
|
||||
- Specific minimal steps to reproduce the issue from scratch
|
||||
- A working patch
|
||||
|
||||
Please DO NOT use containers, VMs, cloud instances or services, or any other complex infrastructure in your steps. Always prefer `curl -v` instead of web browsers.
|
||||
Please DO NOT use containers, VMs, cloud instances or services, or any other complex infrastructure in your steps. Always prefer `curl` instead of web browsers.
|
||||
|
||||
We consider publicly-registered domain names to be public information. This necessary in order to maintain the integrity of certificate transparency, public DNS, and other public trust systems. Do not redact domain names from your reports. The actual content of your domain name affects Caddy's behavior, so we need the exact domain name(s) to reproduce with, or your report will be ignored.
|
||||
|
||||
It will speed things up if you suggest a working patch, such as a code diff, and explain why and how it works. Reports that are not actionable, do not contain enough information, are too pushy/demanding, or are not able to convince us that it is a viable and practical attack on the web server itself may be deferred to a later time or possibly ignored, depending on available resources. Priority will be given to credible, responsible reports that are constructive, specific, and actionable. (We get a lot of invalid reports.) Thank you for understanding.
|
||||
|
||||
When you are ready, please submit a [new private vulnerability report](https://github.com/caddyserver/caddy/security/advisories/new).
|
||||
When you are ready, please email Matt Holt (the author) directly: matt at dyanim dot com.
|
||||
|
||||
Please don't encrypt the message. It only makes the process more complicated.
|
||||
Please don't encrypt the email body. It only makes the process more complicated.
|
||||
|
||||
Please also understand that due to our nature as an open source project, we do not have a budget to award security bounties. We can only thank you.
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
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:
|
||||
interval: "monthly"
|
||||
@@ -1,29 +0,0 @@
|
||||
|
||||
|
||||
|
||||
## Assistance Disclosure
|
||||
<!--
|
||||
Thank you for contributing! Please note:
|
||||
|
||||
The use of AI/LLM tools is allowed so long as it is disclosed, so
|
||||
that we can provide better code review and maintain project quality.
|
||||
|
||||
If you used AI/LLM tooling in any way related to this PR, please
|
||||
let us know to what extent it was utilized.
|
||||
|
||||
Examples:
|
||||
|
||||
"No AI was used."
|
||||
"I wrote the code, but Claude generated the tests."
|
||||
"I consulted ChatGPT for a solution, but I authored/coded it myself."
|
||||
"Cody generated the code, and I verified it is correct."
|
||||
"Copilot provided tab completion for code and comments."
|
||||
|
||||
We expect that you have vetted your contributions for correctness.
|
||||
Additionally, signing our CLA certifies that you have the rights to
|
||||
contribute this change.
|
||||
|
||||
Replace the text below with your disclosure:
|
||||
-->
|
||||
|
||||
_This PR is missing an assistance disclosure._
|
||||
@@ -1,30 +0,0 @@
|
||||
name: AI Moderator
|
||||
permissions: read-all
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
jobs:
|
||||
spam-detection:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
models: read
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@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
|
||||
@@ -1,221 +0,0 @@
|
||||
name: Release Proposal Approval Tracker
|
||||
|
||||
on:
|
||||
pull_request_review:
|
||||
types: [submitted, dismissed]
|
||||
pull_request:
|
||||
types: [labeled, unlabeled, synchronize, closed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check-approvals:
|
||||
name: Track Maintainer Approvals
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on PRs with release-proposal label
|
||||
if: contains(github.event.pull_request.labels.*.name, 'release-proposal') && github.event.pull_request.state == 'open'
|
||||
|
||||
steps:
|
||||
- name: Check approvals and update PR
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
MAINTAINER_LOGINS: ${{ secrets.MAINTAINER_LOGINS }}
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Extract version from PR title (e.g., "Release Proposal: v1.2.3")
|
||||
const versionMatch = pr.title.match(/Release Proposal:\s*(v[\d.]+(?:-[\w.]+)?)/);
|
||||
const commitMatch = pr.body.match(/\*\*Target Commit:\*\*\s*`([a-f0-9]+)`/);
|
||||
|
||||
if (!versionMatch || !commitMatch) {
|
||||
console.log('Could not extract version from title or commit from body');
|
||||
return;
|
||||
}
|
||||
|
||||
const version = versionMatch[1];
|
||||
const targetCommit = commitMatch[1];
|
||||
|
||||
console.log(`Version: ${version}, Target Commit: ${targetCommit}`);
|
||||
|
||||
// Get all reviews
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number
|
||||
});
|
||||
|
||||
// Get list of maintainers
|
||||
const maintainerLoginsRaw = process.env.MAINTAINER_LOGINS || '';
|
||||
const maintainerLogins = maintainerLoginsRaw
|
||||
.split(/[,;]/)
|
||||
.map(login => login.trim())
|
||||
.filter(login => login.length > 0);
|
||||
|
||||
console.log(`Maintainer logins: ${maintainerLogins.join(', ')}`);
|
||||
|
||||
// Get the latest review from each user
|
||||
const latestReviewsByUser = {};
|
||||
reviews.data.forEach(review => {
|
||||
const username = review.user.login;
|
||||
if (!latestReviewsByUser[username] || new Date(review.submitted_at) > new Date(latestReviewsByUser[username].submitted_at)) {
|
||||
latestReviewsByUser[username] = review;
|
||||
}
|
||||
});
|
||||
|
||||
// Count approvals from maintainers
|
||||
const maintainerApprovals = Object.entries(latestReviewsByUser)
|
||||
.filter(([username, review]) =>
|
||||
maintainerLogins.includes(username) &&
|
||||
review.state === 'APPROVED'
|
||||
)
|
||||
.map(([username, review]) => username);
|
||||
|
||||
const approvalCount = maintainerApprovals.length;
|
||||
console.log(`Found ${approvalCount} maintainer approvals from: ${maintainerApprovals.join(', ')}`);
|
||||
|
||||
// Get current labels
|
||||
const currentLabels = pr.labels.map(label => label.name);
|
||||
const hasApprovedLabel = currentLabels.includes('approved');
|
||||
const hasAwaitingApprovalLabel = currentLabels.includes('awaiting-approval');
|
||||
|
||||
if (approvalCount >= 2 && !hasApprovedLabel) {
|
||||
console.log('✅ Quorum reached! Updating PR...');
|
||||
|
||||
// Remove awaiting-approval label if present
|
||||
if (hasAwaitingApprovalLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
name: 'awaiting-approval'
|
||||
}).catch(e => console.log('Label not found:', e.message));
|
||||
}
|
||||
|
||||
// Add approved label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
labels: ['approved']
|
||||
});
|
||||
|
||||
// Add comment with tagging instructions
|
||||
const approversList = maintainerApprovals.map(u => `@${u}`).join(', ');
|
||||
const commentBody = [
|
||||
'## ✅ Approval Quorum Reached',
|
||||
'',
|
||||
`This release proposal has been approved by ${approvalCount} maintainers: ${approversList}`,
|
||||
'',
|
||||
'### Tagging Instructions',
|
||||
'',
|
||||
'A maintainer should now create and push the signed tag:',
|
||||
'',
|
||||
'```bash',
|
||||
`git checkout ${targetCommit}`,
|
||||
`git tag -s ${version} -m "Release ${version}"`,
|
||||
`git push origin ${version}`,
|
||||
`git checkout -`,
|
||||
'```',
|
||||
'',
|
||||
'The release workflow will automatically start when the tag is pushed.'
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: commentBody
|
||||
});
|
||||
|
||||
console.log('Posted tagging instructions');
|
||||
} else if (approvalCount < 2 && hasApprovedLabel) {
|
||||
console.log('⚠️ Approval count dropped below quorum, removing approved label');
|
||||
|
||||
// Remove approved label
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
name: 'approved'
|
||||
}).catch(e => console.log('Label not found:', e.message));
|
||||
|
||||
// Add awaiting-approval label
|
||||
if (!hasAwaitingApprovalLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
labels: ['awaiting-approval']
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log(`⏳ Waiting for more approvals (${approvalCount}/2 required)`);
|
||||
}
|
||||
|
||||
handle-pr-closed:
|
||||
name: Handle PR Closed Without Tag
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
contains(github.event.pull_request.labels.*.name, 'release-proposal') &&
|
||||
github.event.action == 'closed' && !contains(github.event.pull_request.labels.*.name, 'released')
|
||||
|
||||
steps:
|
||||
- name: Add cancelled label and comment
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Check if the release-in-progress label is present
|
||||
const hasReleaseInProgress = pr.labels.some(label => label.name === 'release-in-progress');
|
||||
|
||||
if (hasReleaseInProgress) {
|
||||
// PR was closed while release was in progress - this is unusual
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: '⚠️ **Warning:** This PR was closed while a release was in progress. This may indicate an error. Please verify the release status.'
|
||||
});
|
||||
} else {
|
||||
// PR was closed before tag was created - this is normal cancellation
|
||||
const versionMatch = pr.title.match(/Release Proposal:\s*(v[\d.]+(?:-[\w.]+)?)/);
|
||||
const version = versionMatch ? versionMatch[1] : 'unknown';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: `## 🚫 Release Proposal Cancelled\n\nThis release proposal for ${version} was closed without creating the tag.\n\nIf you want to proceed with this release later, you can create a new release proposal.`
|
||||
});
|
||||
}
|
||||
|
||||
// Add cancelled label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
labels: ['cancelled']
|
||||
});
|
||||
|
||||
// Remove other workflow labels if present
|
||||
const labelsToRemove = ['awaiting-approval', 'approved', 'release-in-progress'];
|
||||
for (const label of labelsToRemove) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
name: label
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`Label ${label} not found or already removed`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Added cancelled label and cleaned up workflow labels');
|
||||
|
||||
+48
-115
@@ -12,72 +12,51 @@ on:
|
||||
- master
|
||||
- 2.*
|
||||
|
||||
env:
|
||||
GOFLAGS: '-tags=nobadger,nomysql,nopgx'
|
||||
# https://github.com/actions/setup-go/issues/491
|
||||
GOTOOLCHAIN: local
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
# Default is true, cancels jobs for other platforms in the matrix if one fails
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- linux
|
||||
- mac
|
||||
- windows
|
||||
go:
|
||||
- '1.26'
|
||||
os: [ ubuntu-latest, macos-latest, windows-latest ]
|
||||
go: [ '1.17', '1.18' ]
|
||||
|
||||
include:
|
||||
# Set the minimum Go patch version for the given Go minor
|
||||
# Usable via ${{ matrix.GO_SEMVER }}
|
||||
- go: '1.26'
|
||||
GO_SEMVER: '~1.26.0'
|
||||
- go: '1.17'
|
||||
GO_SEMVER: '~1.17.9'
|
||||
|
||||
- go: '1.18'
|
||||
GO_SEMVER: '~1.18.1'
|
||||
|
||||
# Set some variables per OS, usable via ${{ matrix.VAR }}
|
||||
# OS_LABEL: the VM label from GitHub Actions (see https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories)
|
||||
# CADDY_BIN_PATH: the path to the compiled Caddy binary, for artifact publishing
|
||||
# SUCCESS: the typical value for $? per OS (Windows/pwsh returns 'True')
|
||||
- os: linux
|
||||
OS_LABEL: ubuntu-latest
|
||||
- os: ubuntu-latest
|
||||
CADDY_BIN_PATH: ./cmd/caddy/caddy
|
||||
SUCCESS: 0
|
||||
|
||||
- os: mac
|
||||
OS_LABEL: macos-14
|
||||
- os: macos-latest
|
||||
CADDY_BIN_PATH: ./cmd/caddy/caddy
|
||||
SUCCESS: 0
|
||||
|
||||
- os: windows
|
||||
OS_LABEL: windows-latest
|
||||
- os: windows-latest
|
||||
CADDY_BIN_PATH: ./cmd/caddy/caddy.exe
|
||||
SUCCESS: 'True'
|
||||
|
||||
runs-on: ${{ matrix.OS_LABEL }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
actions: write # to allow uploading artifacts and cache
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.GO_SEMVER }}
|
||||
check-latest: true
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# These tools would be useful if we later decide to reinvestigate
|
||||
# publishing test/coverage reports to some tool for easier consumption
|
||||
# - name: Install test and coverage analysis tools
|
||||
@@ -85,11 +64,10 @@ jobs:
|
||||
# go get github.com/axw/gocov/gocov
|
||||
# go get github.com/AlekSi/gocov-xml
|
||||
# go get -u github.com/jstemmer/go-junit-report
|
||||
# echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
|
||||
# echo "::add-path::$(go env GOPATH)/bin"
|
||||
|
||||
- name: Print Go version and environment
|
||||
id: vars
|
||||
shell: bash
|
||||
run: |
|
||||
printf "Using go at: $(which go)\n"
|
||||
printf "Go version: $(go version)\n"
|
||||
@@ -99,7 +77,24 @@ jobs:
|
||||
env
|
||||
printf "Git version: $(git version)\n\n"
|
||||
# Calculate the short SHA1 hash of the git commit
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "::set-output name=short_sha::$(git rev-parse --short HEAD)"
|
||||
|
||||
- name: Cache the build cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
# In order:
|
||||
# * Module download cache
|
||||
# * Build cache (Linux)
|
||||
# * Build cache (Mac)
|
||||
# * Build cache (Windows)
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
~/Library/Caches/go-build
|
||||
~\AppData\Local\go-build
|
||||
key: ${{ runner.os }}-${{ matrix.go }}-go-ci-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.go }}-go-ci
|
||||
|
||||
- name: Get dependencies
|
||||
run: |
|
||||
@@ -113,18 +108,11 @@ jobs:
|
||||
run: |
|
||||
go build -trimpath -ldflags="-w -s" -v
|
||||
|
||||
- name: Smoke test Caddy
|
||||
working-directory: ./cmd/caddy
|
||||
run: |
|
||||
./caddy start
|
||||
./caddy stop
|
||||
|
||||
- name: Publish Build Artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@v1
|
||||
with:
|
||||
name: caddy_${{ runner.os }}_go${{ matrix.go }}_${{ steps.vars.outputs.short_sha }}
|
||||
path: ${{ matrix.CADDY_BIN_PATH }}
|
||||
compression-level: 0
|
||||
|
||||
# Commented bits below were useful to allow the job to continue
|
||||
# even if the tests fail, so we can publish the report separately
|
||||
@@ -135,7 +123,7 @@ jobs:
|
||||
run: |
|
||||
# (go test -v -coverprofile=cover-profile.out -race ./... 2>&1) > test-results/test-result.out
|
||||
go test -v -coverprofile="cover-profile.out" -short -race ./...
|
||||
# echo "status=$?" >> $GITHUB_OUTPUT
|
||||
# echo "::set-output name=status::$?"
|
||||
|
||||
# Relevant step if we reinvestigate publishing test/coverage reports
|
||||
# - name: Prepare coverage reports
|
||||
@@ -147,103 +135,48 @@ jobs:
|
||||
|
||||
# To return the correct result even though we set 'continue-on-error: true'
|
||||
# - name: Coerce correct build result
|
||||
# if: matrix.os != 'windows' && steps.step_test.outputs.status != ${{ matrix.SUCCESS }}
|
||||
# if: matrix.os != 'windows-latest' && steps.step_test.outputs.status != ${{ matrix.SUCCESS }}
|
||||
# run: |
|
||||
# echo "step_test ${{ steps.step_test.outputs.status }}\n"
|
||||
# exit 1
|
||||
|
||||
s390x-test:
|
||||
name: test (s390x on IBM Z)
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.head.repo.full_name == 'caddyserver/caddy' && github.actor != 'dependabot[bot]'
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
continue-on-error: true # August 2020: s390x VM is down due to weather and power issues
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
allowed-endpoints: ci-s390x.caddyserver.com:22
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Checkout code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
- name: Run Tests
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p ~/.ssh && echo -e "${SSH_KEY//_/\\n}" > ~/.ssh/id_ecdsa && chmod og-rwx ~/.ssh/id_ecdsa
|
||||
|
||||
# short sha is enough?
|
||||
short_sha=$(git rev-parse --short HEAD)
|
||||
|
||||
# To shorten the following lines
|
||||
ssh_opts="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||
ssh_host="$CI_USER@ci-s390x.caddyserver.com"
|
||||
|
||||
# The environment is fresh, so there's no point in keeping accepting and adding the key.
|
||||
rsync -arz -e "ssh $ssh_opts" --progress --delete --exclude '.git' . "$ssh_host":/var/tmp/"$short_sha"
|
||||
ssh $ssh_opts -t "$ssh_host" bash <<EOF
|
||||
cd /var/tmp/$short_sha
|
||||
go version
|
||||
go env
|
||||
printf "\n\n"
|
||||
retries=3
|
||||
exit_code=0
|
||||
while ((retries > 0)); do
|
||||
CGO_ENABLED=0 go test -p 1 -v ./...
|
||||
exit_code=$?
|
||||
if ((exit_code == 0)); then
|
||||
break
|
||||
fi
|
||||
echo "\n\nTest failed: \$exit_code, retrying..."
|
||||
((retries--))
|
||||
done
|
||||
echo "Remote exit code: \$exit_code"
|
||||
exit \$exit_code
|
||||
EOF
|
||||
rsync -arz -e "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" --progress --delete --exclude '.git' . caddy-ci@ci-s390x.caddyserver.com:/var/tmp/"$short_sha"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -t caddy-ci@ci-s390x.caddyserver.com "cd /var/tmp/$short_sha; go version; go env; printf "\n\n";CGO_ENABLED=0 go test -v ./..."
|
||||
test_result=$?
|
||||
|
||||
# There's no need leaving the files around
|
||||
ssh $ssh_opts "$ssh_host" "rm -rf /var/tmp/'$short_sha'"
|
||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null caddy-ci@ci-s390x.caddyserver.com "rm -rf /var/tmp/'$short_sha'"
|
||||
|
||||
echo "Test exit code: $test_result"
|
||||
exit $test_result
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.S390X_SSH_KEY }}
|
||||
CI_USER: ${{ secrets.CI_USER }}
|
||||
|
||||
goreleaser-check:
|
||||
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:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
|
||||
- uses: goreleaser/goreleaser-action@v2
|
||||
with:
|
||||
version: latest
|
||||
args: check
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
with:
|
||||
go-version: "~1.26"
|
||||
check-latest: true
|
||||
- name: Install xcaddy
|
||||
run: |
|
||||
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
|
||||
xcaddy version
|
||||
- uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
|
||||
with:
|
||||
version: latest
|
||||
args: build --single-target --snapshot
|
||||
env:
|
||||
TAG: ${{ github.head_ref || github.ref_name }}
|
||||
TAG: ${{ steps.vars.outputs.version_tag }}
|
||||
|
||||
@@ -10,56 +10,25 @@ on:
|
||||
- master
|
||||
- 2.*
|
||||
|
||||
env:
|
||||
GOFLAGS: '-tags=nobadger,nomysql,nopgx'
|
||||
CGO_ENABLED: '0'
|
||||
# https://github.com/actions/setup-go/issues/491
|
||||
GOTOOLCHAIN: local
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
cross-build-test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
goos:
|
||||
- 'aix'
|
||||
- 'linux'
|
||||
- 'solaris'
|
||||
- 'illumos'
|
||||
- 'dragonfly'
|
||||
- 'freebsd'
|
||||
- 'openbsd'
|
||||
- 'windows'
|
||||
- 'darwin'
|
||||
- 'netbsd'
|
||||
go:
|
||||
- '1.26'
|
||||
goos: ['android', 'linux', 'solaris', 'illumos', 'dragonfly', 'freebsd', 'openbsd', 'plan9', 'windows', 'darwin', 'netbsd']
|
||||
go: [ '1.18' ]
|
||||
|
||||
include:
|
||||
# Set the minimum Go patch version for the given Go minor
|
||||
# Usable via ${{ matrix.GO_SEMVER }}
|
||||
- go: '1.26'
|
||||
GO_SEMVER: '~1.26.0'
|
||||
- go: '1.18'
|
||||
GO_SEMVER: '~1.18.1'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.GO_SEMVER }}
|
||||
check-latest: true
|
||||
@@ -74,11 +43,32 @@ jobs:
|
||||
printf "\n\nSystem environment:\n\n"
|
||||
env
|
||||
|
||||
- name: Cache the build cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
# In order:
|
||||
# * Module download cache
|
||||
# * Build cache (Linux)
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: cross-build-go${{ matrix.go }}-${{ matrix.goos }}-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
cross-build-go${{ matrix.go }}-${{ matrix.goos }}
|
||||
|
||||
- name: Checkout code into the Go module directory
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Run Build
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goos == 'aix' && 'ppc64' || 'amd64' }}
|
||||
shell: bash
|
||||
continue-on-error: true
|
||||
working-directory: ./cmd/caddy
|
||||
run: go build -trimpath -o caddy-"$GOOS"-$GOARCH 2> /dev/null
|
||||
run: |
|
||||
GOOS=$GOOS go build -trimpath -o caddy-"$GOOS"-amd64 2> /dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "::warning ::$GOOS Build Failed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -10,96 +10,21 @@ on:
|
||||
- master
|
||||
- 2.*
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# https://github.com/actions/setup-go/issues/491
|
||||
GOTOOLCHAIN: local
|
||||
|
||||
jobs:
|
||||
# From https://github.com/golangci/golangci-lint-action
|
||||
golangci:
|
||||
permissions:
|
||||
contents: read # for actions/checkout to fetch code
|
||||
pull-requests: read # for golangci/golangci-lint-action to fetch pull requests
|
||||
name: lint
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- linux
|
||||
- mac
|
||||
- windows
|
||||
|
||||
include:
|
||||
- os: linux
|
||||
OS_LABEL: ubuntu-latest
|
||||
|
||||
- os: mac
|
||||
OS_LABEL: macos-14
|
||||
|
||||
- os: windows
|
||||
OS_LABEL: windows-latest
|
||||
|
||||
runs-on: ${{ matrix.OS_LABEL }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
with:
|
||||
go-version: '~1.26'
|
||||
go-version: '~1.17.9'
|
||||
check-latest: true
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
version: latest
|
||||
|
||||
# Windows times out frequently after about 5m50s if we don't set a longer timeout.
|
||||
args: --timeout 10m
|
||||
|
||||
version: v1.44
|
||||
# Optional: show only new issues if it's a pull request. The default value is `false`.
|
||||
# only-new-issues: true
|
||||
|
||||
govulncheck:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: govulncheck
|
||||
uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4
|
||||
with:
|
||||
go-version-input: '~1.26.0'
|
||||
check-latest: true
|
||||
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 # v4.8.0
|
||||
with:
|
||||
comment-summary-in-pr: on-failure
|
||||
# https://github.com/actions/dependency-review-action/issues/430#issuecomment-1468975566
|
||||
base-ref: ${{ github.event.pull_request.base.sha || 'master' }}
|
||||
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
name: Release Proposal
|
||||
|
||||
# This workflow creates a release proposal as a PR that requires approval from maintainers
|
||||
# Triggered manually by maintainers when ready to prepare a release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (e.g., v2.8.0)'
|
||||
required: true
|
||||
type: string
|
||||
commit_hash:
|
||||
description: 'Commit hash to release from'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
create-proposal:
|
||||
name: Create Release Proposal
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Trim and validate inputs
|
||||
id: inputs
|
||||
run: |
|
||||
# Trim whitespace from inputs
|
||||
VERSION=$(echo "${{ inputs.version }}" | xargs)
|
||||
COMMIT_HASH=$(echo "${{ inputs.commit_hash }}" | xargs)
|
||||
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "commit_hash=$COMMIT_HASH" >> $GITHUB_OUTPUT
|
||||
|
||||
# Validate version format
|
||||
if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
|
||||
echo "Error: Version must follow semver format (e.g., v2.8.0 or v2.8.0-beta.1)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate commit hash format
|
||||
if [[ ! "$COMMIT_HASH" =~ ^[a-f0-9]{7,40}$ ]]; then
|
||||
echo "Error: Commit hash must be a valid SHA (7-40 characters)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if commit exists
|
||||
if ! git cat-file -e "$COMMIT_HASH"; then
|
||||
echo "Error: Commit $COMMIT_HASH does not exist"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check if tag already exists
|
||||
run: |
|
||||
if git rev-parse "${{ steps.inputs.outputs.version }}" >/dev/null 2>&1; then
|
||||
echo "Error: Tag ${{ steps.inputs.outputs.version }} already exists"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check for existing proposal PR
|
||||
id: check_existing
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const version = '${{ steps.inputs.outputs.version }}';
|
||||
|
||||
// Search for existing open PRs with release-proposal label that match this version
|
||||
const openPRs = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
sort: 'updated',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
const existingOpenPR = openPRs.data.find(pr =>
|
||||
pr.title.includes(version) &&
|
||||
pr.labels.some(label => label.name === 'release-proposal')
|
||||
);
|
||||
|
||||
if (existingOpenPR) {
|
||||
const hasReleased = existingOpenPR.labels.some(label => label.name === 'released');
|
||||
const hasReleaseInProgress = existingOpenPR.labels.some(label => label.name === 'release-in-progress');
|
||||
|
||||
if (hasReleased || hasReleaseInProgress) {
|
||||
core.setFailed(`A release for ${version} is already in progress or completed: ${existingOpenPR.html_url}`);
|
||||
} else {
|
||||
core.setFailed(`An open release proposal already exists for ${version}: ${existingOpenPR.html_url}\n\nPlease use the existing PR or close it first.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for closed PRs with this version that were cancelled
|
||||
const closedPRs = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'closed',
|
||||
sort: 'updated',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
const cancelledPR = closedPRs.data.find(pr =>
|
||||
pr.title.includes(version) &&
|
||||
pr.labels.some(label => label.name === 'release-proposal') &&
|
||||
pr.labels.some(label => label.name === 'cancelled')
|
||||
);
|
||||
|
||||
if (cancelledPR) {
|
||||
console.log(`Found previously cancelled proposal for ${version}: ${cancelledPR.html_url}`);
|
||||
console.log('Creating new proposal to replace cancelled one...');
|
||||
} else {
|
||||
console.log(`No existing proposal found for ${version}, proceeding...`);
|
||||
}
|
||||
|
||||
- name: Generate changelog and create branch
|
||||
id: setup
|
||||
run: |
|
||||
VERSION="${{ steps.inputs.outputs.version }}"
|
||||
COMMIT_HASH="${{ steps.inputs.outputs.commit_hash }}"
|
||||
|
||||
# Create a new branch for the release proposal
|
||||
BRANCH_NAME="release_proposal-$VERSION"
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
|
||||
# Calculate how many commits behind HEAD
|
||||
COMMITS_BEHIND=$(git rev-list --count ${COMMIT_HASH}..HEAD)
|
||||
|
||||
if [ "$COMMITS_BEHIND" -eq 0 ]; then
|
||||
BEHIND_INFO="This is the latest commit (HEAD)"
|
||||
else
|
||||
BEHIND_INFO="This commit is **${COMMITS_BEHIND} commits behind HEAD**"
|
||||
fi
|
||||
|
||||
echo "commits_behind=$COMMITS_BEHIND" >> $GITHUB_OUTPUT
|
||||
echo "behind_info=$BEHIND_INFO" >> $GITHUB_OUTPUT
|
||||
|
||||
# Get the last tag
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$LAST_TAG" ]; then
|
||||
echo "No previous tag found, generating full changelog"
|
||||
COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "$COMMIT_HASH")
|
||||
else
|
||||
echo "Generating changelog since $LAST_TAG"
|
||||
COMMITS=$(git log --pretty=format:"- %s (%h)" --reverse "${LAST_TAG}..$COMMIT_HASH")
|
||||
fi
|
||||
|
||||
# Store changelog for PR body
|
||||
CLEANSED_COMMITS=$(echo "$COMMITS" | sed 's/`/\\`/g')
|
||||
echo "changelog<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CLEANSED_COMMITS" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
# Create empty commit for the PR
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git commit --allow-empty -m "Release proposal for $VERSION"
|
||||
|
||||
# Push the branch
|
||||
git push origin "$BRANCH_NAME"
|
||||
|
||||
echo "branch_name=$BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create release proposal PR
|
||||
id: create_pr
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const changelog = `${{ steps.setup.outputs.changelog }}`;
|
||||
|
||||
const pr = await github.rest.pulls.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: `Release Proposal: ${{ steps.inputs.outputs.version }}`,
|
||||
head: '${{ steps.setup.outputs.branch_name }}',
|
||||
base: 'master',
|
||||
body: `## Release Proposal: ${{ steps.inputs.outputs.version }}
|
||||
|
||||
**Target Commit:** \`${{ steps.inputs.outputs.commit_hash }}\`
|
||||
**Requested by:** @${{ github.actor }}
|
||||
**Commit Status:** ${{ steps.setup.outputs.behind_info }}
|
||||
|
||||
This PR proposes creating release tag \`${{ steps.inputs.outputs.version }}\` at commit \`${{ steps.inputs.outputs.commit_hash }}\`.
|
||||
|
||||
### Approval Process
|
||||
|
||||
This PR requires **approval from 2+ maintainers** before the tag can be created.
|
||||
|
||||
### What happens next?
|
||||
|
||||
1. Maintainers review this proposal
|
||||
2. When 2+ maintainer approvals are received, an automated workflow will post tagging instructions
|
||||
3. A maintainer manually creates and pushes the signed tag
|
||||
4. The release workflow is triggered automatically by the tag push
|
||||
5. Upon release completion, this PR is closed and the branch is deleted
|
||||
|
||||
### Changes Since Last Release
|
||||
|
||||
${changelog}
|
||||
|
||||
### Release Checklist
|
||||
|
||||
- [ ] All tests pass
|
||||
- [ ] Security review completed
|
||||
- [ ] Documentation updated
|
||||
- [ ] Breaking changes documented
|
||||
|
||||
---
|
||||
|
||||
**Note:** Tag creation is manual and requires a signed tag from a maintainer.`,
|
||||
draft: true
|
||||
});
|
||||
|
||||
// Add labels
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.data.number,
|
||||
labels: ['release-proposal', 'awaiting-approval']
|
||||
});
|
||||
|
||||
console.log(`Created PR: ${pr.data.html_url}`);
|
||||
|
||||
return { number: pr.data.number, url: pr.data.html_url };
|
||||
result-encoding: json
|
||||
|
||||
- name: Post summary
|
||||
run: |
|
||||
echo "## Release Proposal PR Created! 🚀" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Version: **${{ steps.inputs.outputs.version }}**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Commit: **${{ steps.inputs.outputs.commit_hash }}**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Status: ${{ steps.setup.outputs.behind_info }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "PR: ${{ fromJson(steps.create_pr.outputs.result).url }}" >> $GITHUB_STEP_SUMMARY
|
||||
+41
-437
@@ -5,373 +5,36 @@ on:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
env:
|
||||
# https://github.com/actions/setup-go/issues/491
|
||||
GOTOOLCHAIN: local
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify-tag:
|
||||
name: Verify Tag Signature and Approvals
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
outputs:
|
||||
verification_passed: ${{ steps.verify.outputs.passed }}
|
||||
tag_version: ${{ steps.info.outputs.version }}
|
||||
proposal_issue_number: ${{ steps.find_proposal.outputs.result && fromJson(steps.find_proposal.outputs.result).number || '' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Force fetch upstream tags -- because 65 minutes
|
||||
# tl;dr: actions/checkout@v3 runs this line:
|
||||
# git -c protocol.version=2 fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +ebc278ec98bb24f2852b61fde2a9bf2e3d83818b:refs/tags/
|
||||
# which makes its own local lightweight tag, losing all the annotations in the process. Our earlier script ran:
|
||||
# git fetch --prune --unshallow
|
||||
# which doesn't overwrite that tag because that would be destructive.
|
||||
# Credit to @francislavoie for the investigation.
|
||||
# https://github.com/actions/checkout/issues/290#issuecomment-680260080
|
||||
- name: Force fetch upstream tags
|
||||
run: git fetch --tags --force
|
||||
|
||||
- name: Get tag info
|
||||
id: info
|
||||
run: |
|
||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
# https://github.community/t5/GitHub-Actions/How-to-get-just-the-tag-name/m-p/32167/highlight/true#M1027
|
||||
- name: Print Go version and environment
|
||||
id: vars
|
||||
run: |
|
||||
printf "Using go at: $(which go)\n"
|
||||
printf "Go version: $(go version)\n"
|
||||
printf "\n\nGo environment:\n\n"
|
||||
go env
|
||||
printf "\n\nSystem environment:\n\n"
|
||||
env
|
||||
echo "version_tag=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
# Add "pip install" CLI tools to PATH
|
||||
echo ~/.local/bin >> $GITHUB_PATH
|
||||
|
||||
# Parse semver
|
||||
TAG=${GITHUB_REF/refs\/tags\//}
|
||||
SEMVER_RE='[^0-9]*\([0-9]*\)[.]\([0-9]*\)[.]\([0-9]*\)\([0-9A-Za-z\.-]*\)'
|
||||
TAG_MAJOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\1#"`
|
||||
TAG_MINOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\2#"`
|
||||
TAG_PATCH=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\3#"`
|
||||
TAG_SPECIAL=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\4#"`
|
||||
echo "tag_major=${TAG_MAJOR}" >> $GITHUB_OUTPUT
|
||||
echo "tag_minor=${TAG_MINOR}" >> $GITHUB_OUTPUT
|
||||
echo "tag_patch=${TAG_PATCH}" >> $GITHUB_OUTPUT
|
||||
echo "tag_special=${TAG_SPECIAL}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Validate commits and tag signatures
|
||||
id: verify
|
||||
env:
|
||||
signing_keys: ${{ secrets.SIGNING_KEYS }}
|
||||
run: |
|
||||
# Read the string into an array, splitting by IFS
|
||||
IFS=";" read -ra keys_collection <<< "$signing_keys"
|
||||
|
||||
# ref: https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#example-usage-of-the-runner-context
|
||||
touch "${{ runner.temp }}/allowed_signers"
|
||||
|
||||
# Iterate and print the split elements
|
||||
for item in "${keys_collection[@]}"; do
|
||||
|
||||
# trim leading whitespaces
|
||||
item="${item##*( )}"
|
||||
|
||||
# trim trailing whitespaces
|
||||
item="${item%%*( )}"
|
||||
|
||||
IFS=" " read -ra key_components <<< "$item"
|
||||
# git wants it in format: email address, type, public key
|
||||
# ssh has it in format: type, public key, email address
|
||||
echo "${key_components[2]} namespaces=\"git\" ${key_components[0]} ${key_components[1]}" >> "${{ runner.temp }}/allowed_signers"
|
||||
done
|
||||
|
||||
git config set --global gpg.ssh.allowedSignersFile "${{ runner.temp }}/allowed_signers"
|
||||
|
||||
echo "Verifying the tag: ${{ steps.vars.outputs.version_tag }}"
|
||||
|
||||
# Verify the tag is signed
|
||||
if ! git verify-tag -v "${{ steps.vars.outputs.version_tag }}" 2>&1; then
|
||||
echo "❌ Tag verification failed!"
|
||||
echo "passed=false" >> $GITHUB_OUTPUT
|
||||
git push --delete origin "${{ steps.vars.outputs.version_tag }}"
|
||||
exit 1
|
||||
fi
|
||||
# Run it again to capture the output
|
||||
git verify-tag -v "${{ steps.vars.outputs.version_tag }}" 2>&1 | tee /tmp/verify-output.txt;
|
||||
|
||||
# SSH verification output typically includes the key fingerprint
|
||||
# Use GNU grep with Perl regex for cleaner extraction (Linux environment)
|
||||
KEY_SHA256=$(grep -oP "SHA256:[\"']?\K[A-Za-z0-9+/=]+(?=[\"']?)" /tmp/verify-output.txt | head -1 || echo "")
|
||||
|
||||
if [ -z "$KEY_SHA256" ]; then
|
||||
# Try alternative pattern with "key" prefix
|
||||
KEY_SHA256=$(grep -oP "key SHA256:[\"']?\K[A-Za-z0-9+/=]+(?=[\"']?)" /tmp/verify-output.txt | head -1 || echo "")
|
||||
fi
|
||||
|
||||
if [ -z "$KEY_SHA256" ]; then
|
||||
# Fallback: extract any base64-like string (40+ chars)
|
||||
KEY_SHA256=$(grep -oP '[A-Za-z0-9+/]{40,}=?' /tmp/verify-output.txt | head -1 || echo "")
|
||||
fi
|
||||
|
||||
if [ -z "$KEY_SHA256" ]; then
|
||||
echo "Somehow could not extract SSH key fingerprint from git verify-tag output"
|
||||
echo "Cancelling flow and deleting tag"
|
||||
echo "passed=false" >> $GITHUB_OUTPUT
|
||||
git push --delete origin "${{ steps.vars.outputs.version_tag }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Tag verification succeeded!"
|
||||
echo "passed=true" >> $GITHUB_OUTPUT
|
||||
echo "key_id=$KEY_SHA256" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Find related release proposal
|
||||
id: find_proposal
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const version = '${{ steps.vars.outputs.version_tag }}';
|
||||
|
||||
// Search for PRs with release-proposal label that match this version
|
||||
const prs = await github.rest.pulls.list({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open', // Changed to 'all' to find both open and closed PRs
|
||||
sort: 'updated',
|
||||
direction: 'desc'
|
||||
});
|
||||
|
||||
// Find the most recent PR for this version
|
||||
const proposal = prs.data.find(pr =>
|
||||
pr.title.includes(version) &&
|
||||
pr.labels.some(label => label.name === 'release-proposal')
|
||||
);
|
||||
|
||||
if (!proposal) {
|
||||
console.log(`⚠️ No release proposal PR found for ${version}`);
|
||||
console.log('This might be a hotfix or emergency release');
|
||||
return { number: null, approved: true, approvals: 0, proposedCommit: null };
|
||||
}
|
||||
|
||||
console.log(`Found proposal PR #${proposal.number} for version ${version}`);
|
||||
|
||||
// Extract commit hash from PR body
|
||||
const commitMatch = proposal.body.match(/\*\*Target Commit:\*\*\s*`([a-f0-9]+)`/);
|
||||
const proposedCommit = commitMatch ? commitMatch[1] : null;
|
||||
|
||||
if (proposedCommit) {
|
||||
console.log(`Proposal was for commit: ${proposedCommit}`);
|
||||
} else {
|
||||
console.log('⚠️ No target commit hash found in PR body');
|
||||
}
|
||||
|
||||
// Get PR reviews to extract approvers
|
||||
let approvers = 'Validated by automation';
|
||||
let approvalCount = 2; // Minimum required
|
||||
|
||||
try {
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: proposal.number
|
||||
});
|
||||
|
||||
// Get latest review per user and filter for approvals
|
||||
const latestReviewsByUser = {};
|
||||
reviews.data.forEach(review => {
|
||||
const username = review.user.login;
|
||||
if (!latestReviewsByUser[username] || new Date(review.submitted_at) > new Date(latestReviewsByUser[username].submitted_at)) {
|
||||
latestReviewsByUser[username] = review;
|
||||
}
|
||||
});
|
||||
|
||||
const approvalReviews = Object.values(latestReviewsByUser).filter(review =>
|
||||
review.state === 'APPROVED'
|
||||
);
|
||||
|
||||
if (approvalReviews.length > 0) {
|
||||
approvers = approvalReviews.map(r => '@' + r.user.login).join(', ');
|
||||
approvalCount = approvalReviews.length;
|
||||
console.log(`Found ${approvalCount} approvals from: ${approvers}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Could not fetch reviews: ${error.message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
number: proposal.number,
|
||||
approved: true,
|
||||
approvals: approvalCount,
|
||||
approvers: approvers,
|
||||
proposedCommit: proposedCommit
|
||||
};
|
||||
result-encoding: json
|
||||
|
||||
- name: Verify proposal commit
|
||||
run: |
|
||||
APPROVALS='${{ steps.find_proposal.outputs.result }}'
|
||||
|
||||
# Parse JSON
|
||||
PROPOSED_COMMIT=$(echo "$APPROVALS" | jq -r '.proposedCommit')
|
||||
CURRENT_COMMIT="${{ steps.info.outputs.sha }}"
|
||||
|
||||
echo "Proposed commit: $PROPOSED_COMMIT"
|
||||
echo "Current commit: $CURRENT_COMMIT"
|
||||
|
||||
# Check if commits match (if proposal had a target commit)
|
||||
if [ "$PROPOSED_COMMIT" != "null" ] && [ -n "$PROPOSED_COMMIT" ]; then
|
||||
# Normalize both commits to full SHA for comparison
|
||||
PROPOSED_FULL=$(git rev-parse "$PROPOSED_COMMIT" 2>/dev/null || echo "")
|
||||
CURRENT_FULL=$(git rev-parse "$CURRENT_COMMIT" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$PROPOSED_FULL" ]; then
|
||||
echo "⚠️ Could not resolve proposed commit: $PROPOSED_COMMIT"
|
||||
elif [ "$PROPOSED_FULL" != "$CURRENT_FULL" ]; then
|
||||
echo "❌ Commit mismatch!"
|
||||
echo "The tag points to commit $CURRENT_FULL but the proposal was for $PROPOSED_FULL"
|
||||
echo "This indicates an error in tag creation."
|
||||
# Delete the tag remotely
|
||||
git push --delete origin "${{ steps.vars.outputs.version_tag }}"
|
||||
echo "Tag ${{steps.vars.outputs.version_tag}} has been deleted"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Commit hash matches proposal"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ No target commit found in proposal (might be legacy release)"
|
||||
fi
|
||||
|
||||
echo "✅ Tag verification completed"
|
||||
|
||||
- name: Update release proposal PR
|
||||
if: fromJson(steps.find_proposal.outputs.result).number != null
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const result = ${{ steps.find_proposal.outputs.result }};
|
||||
|
||||
if (result.number) {
|
||||
// Add in-progress label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: result.number,
|
||||
labels: ['release-in-progress']
|
||||
});
|
||||
|
||||
// Remove approved label if present
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: result.number,
|
||||
name: 'approved'
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('Approved label not found:', e.message);
|
||||
}
|
||||
|
||||
const commentBody = [
|
||||
'## 🚀 Release Workflow Started',
|
||||
'',
|
||||
'- **Tag:** ${{ steps.info.outputs.version }}',
|
||||
'- **Signed by key:** ${{ steps.verify.outputs.key_id }}',
|
||||
'- **Commit:** ${{ steps.info.outputs.sha }}',
|
||||
'- **Approved by:** ' + result.approvers,
|
||||
'',
|
||||
'Release workflow is now running. This PR will be updated when the release is published.'
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: result.number,
|
||||
body: commentBody
|
||||
});
|
||||
}
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
APPROVALS='${{ steps.find_proposal.outputs.result }}'
|
||||
PROPOSED_COMMIT=$(echo "$APPROVALS" | jq -r '.proposedCommit // "N/A"')
|
||||
APPROVERS=$(echo "$APPROVALS" | jq -r '.approvers // "N/A"')
|
||||
|
||||
echo "## Tag Verification Summary 🔐" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Tag:** ${{ steps.info.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Commit:** ${{ steps.info.outputs.sha }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Proposed Commit:** $PROPOSED_COMMIT" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Signature:** ✅ Verified" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Signed by:** ${{ steps.verify.outputs.key_id }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Approvals:** ✅ Sufficient" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Approved by:** $APPROVERS" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Proceeding with release build..." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: verify-tag
|
||||
if: ${{ needs.verify-tag.outputs.verification_passed == 'true' }}
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
go:
|
||||
- '1.26'
|
||||
os: [ ubuntu-latest ]
|
||||
go: [ '1.18' ]
|
||||
|
||||
include:
|
||||
# Set the minimum Go patch version for the given Go minor
|
||||
# Usable via ${{ matrix.GO_SEMVER }}
|
||||
- go: '1.26'
|
||||
GO_SEMVER: '~1.26.0'
|
||||
- go: '1.18'
|
||||
GO_SEMVER: '~1.18.1'
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
# https://github.com/sigstore/cosign/issues/1258#issuecomment-1002251233
|
||||
# https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings
|
||||
permissions:
|
||||
id-token: write
|
||||
# https://docs.github.com/en/rest/overview/permissions-required-for-github-apps#permission-on-contents
|
||||
# "Releases" is part of `contents`, so it needs the `write`
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.GO_SEMVER }}
|
||||
check-latest: true
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Force fetch upstream tags -- because 65 minutes
|
||||
# tl;dr: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v4.2.2 runs this line:
|
||||
# tl;dr: actions/checkout@v3 runs this line:
|
||||
# git -c protocol.version=2 fetch --no-tags --prune --progress --no-recurse-submodules --depth=1 origin +ebc278ec98bb24f2852b61fde2a9bf2e3d83818b:refs/tags/
|
||||
# which makes its own local lightweight tag, losing all the annotations in the process. Our earlier script ran:
|
||||
# git fetch --prune --unshallow
|
||||
@@ -391,8 +54,8 @@ jobs:
|
||||
go env
|
||||
printf "\n\nSystem environment:\n\n"
|
||||
env
|
||||
echo "version_tag=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
|
||||
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "::set-output name=version_tag::${GITHUB_REF/refs\/tags\//}"
|
||||
echo "::set-output name=short_sha::$(git rev-parse --short HEAD)"
|
||||
|
||||
# Add "pip install" CLI tools to PATH
|
||||
echo ~/.local/bin >> $GITHUB_PATH
|
||||
@@ -404,38 +67,48 @@ jobs:
|
||||
TAG_MINOR=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\2#"`
|
||||
TAG_PATCH=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\3#"`
|
||||
TAG_SPECIAL=`echo ${TAG#v} | sed -e "s#$SEMVER_RE#\4#"`
|
||||
echo "tag_major=${TAG_MAJOR}" >> $GITHUB_OUTPUT
|
||||
echo "tag_minor=${TAG_MINOR}" >> $GITHUB_OUTPUT
|
||||
echo "tag_patch=${TAG_PATCH}" >> $GITHUB_OUTPUT
|
||||
echo "tag_special=${TAG_SPECIAL}" >> $GITHUB_OUTPUT
|
||||
echo "::set-output name=tag_major::${TAG_MAJOR}"
|
||||
echo "::set-output name=tag_minor::${TAG_MINOR}"
|
||||
echo "::set-output name=tag_patch::${TAG_PATCH}"
|
||||
echo "::set-output name=tag_special::${TAG_SPECIAL}"
|
||||
|
||||
# Cloudsmith CLI tooling for pushing releases
|
||||
# See https://help.cloudsmith.io/docs/cli
|
||||
- name: Install Cloudsmith CLI
|
||||
run: pip install --upgrade cloudsmith-cli
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # main
|
||||
- name: Cosign version
|
||||
run: cosign version
|
||||
- name: Install Syft
|
||||
uses: anchore/sbom-action/download-syft@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # main
|
||||
- name: Syft version
|
||||
run: syft version
|
||||
- name: Install xcaddy
|
||||
- name: Validate commits and tag signatures
|
||||
run: |
|
||||
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
|
||||
xcaddy version
|
||||
|
||||
# Import Matt Holt's key
|
||||
curl 'https://github.com/mholt.gpg' | gpg --import
|
||||
|
||||
echo "Verifying the tag: ${{ steps.vars.outputs.version_tag }}"
|
||||
# tags are only accepted if signed by Matt's key
|
||||
git verify-tag "${{ steps.vars.outputs.version_tag }}" || exit 1
|
||||
|
||||
- name: Cache the build cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
# In order:
|
||||
# * Module download cache
|
||||
# * Build cache (Linux)
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
~/.cache/go-build
|
||||
key: ${{ runner.os }}-go${{ matrix.go }}-release-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go${{ matrix.go }}-release
|
||||
|
||||
# GoReleaser will take care of publishing those artifacts into the release
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
|
||||
uses: goreleaser/goreleaser-action@v2
|
||||
with:
|
||||
version: latest
|
||||
args: release --clean --timeout 60m
|
||||
args: release --rm-dist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ steps.vars.outputs.version_tag }}
|
||||
COSIGN_EXPERIMENTAL: 1
|
||||
|
||||
# Only publish on non-special tags (e.g. non-beta)
|
||||
# We will continue to push to Gemfury for the foreseeable future, although
|
||||
@@ -494,72 +167,3 @@ jobs:
|
||||
echo "Pushing $filename to 'testing'"
|
||||
cloudsmith push deb caddy/testing/any-distro/any-version $filename
|
||||
done
|
||||
|
||||
- name: Update release proposal PR
|
||||
if: needs.verify-tag.outputs.proposal_issue_number != ''
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const prNumber = parseInt('${{ needs.verify-tag.outputs.proposal_issue_number }}');
|
||||
|
||||
if (prNumber) {
|
||||
// Get PR details to find the branch
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber
|
||||
});
|
||||
|
||||
const branchName = pr.data.head.ref;
|
||||
|
||||
// Remove in-progress label
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
name: 'release-in-progress'
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('Label not found:', e.message);
|
||||
}
|
||||
|
||||
// Add released label
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
labels: ['released']
|
||||
});
|
||||
|
||||
// Add final comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: '## ✅ Release Published\n\nThe release has been successfully published and is now available.'
|
||||
});
|
||||
|
||||
// Close the PR if it's still open
|
||||
if (pr.data.state === 'open') {
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber,
|
||||
state: 'closed'
|
||||
});
|
||||
console.log(`Closed PR #${prNumber}`);
|
||||
}
|
||||
|
||||
// Delete the branch
|
||||
try {
|
||||
await github.rest.git.deleteRef({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: `heads/${branchName}`
|
||||
});
|
||||
console.log(`Deleted branch: ${branchName}`);
|
||||
} catch (e) {
|
||||
console.log(`Could not delete branch ${branchName}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,31 +5,19 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release Published
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
os: [ ubuntu-latest ]
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
actions: write
|
||||
|
||||
steps:
|
||||
|
||||
# See https://github.com/peter-evans/repository-dispatch
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Trigger event on caddyserver/dist
|
||||
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0
|
||||
uses: peter-evans/repository-dispatch@v1
|
||||
with:
|
||||
token: ${{ secrets.REPO_DISPATCH_TOKEN }}
|
||||
repository: caddyserver/dist
|
||||
@@ -37,7 +25,7 @@ jobs:
|
||||
client-payload: '{"tag": "${{ github.event.release.tag_name }}"}'
|
||||
|
||||
- name: Trigger event on caddyserver/caddy-docker
|
||||
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4.0.0
|
||||
uses: peter-evans/repository-dispatch@v1
|
||||
with:
|
||||
token: ${{ secrets.REPO_DISPATCH_TOKEN }}
|
||||
repository: caddyserver/caddy-docker
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
# This workflow uses actions that are not certified by GitHub. They are provided
|
||||
# by a third-party and are governed by separate terms of service, privacy
|
||||
# policy, and support documentation.
|
||||
|
||||
name: OpenSSF Scorecard supply-chain security
|
||||
on:
|
||||
# For Branch-Protection check. Only the default branch is supported. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
|
||||
branch_protection_rule:
|
||||
# To guarantee Maintained check is occasionally updated. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||
schedule:
|
||||
- cron: '20 2 * * 5'
|
||||
push:
|
||||
branches: [ "master", "2.*" ]
|
||||
pull_request:
|
||||
branches: [ "master", "2.*" ]
|
||||
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
# `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.
|
||||
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
|
||||
permissions:
|
||||
# Needed to upload the results to code-scanning dashboard.
|
||||
security-events: write
|
||||
# Needed to publish results and get a badge (see publish_results below).
|
||||
id-token: write
|
||||
# Uncomment the permissions below if installing in a private repository.
|
||||
# contents: read
|
||||
# actions: read
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
|
||||
# - you want to enable the Branch-Protection check on a *public* repository, or
|
||||
# - you are installing Scorecard on a *private* repository
|
||||
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
|
||||
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
|
||||
|
||||
# Public repositories:
|
||||
# - Publish results to OpenSSF REST API for easy access by consumers
|
||||
# - Allows the repository to include the Scorecard badge.
|
||||
# - See https://github.com/ossf/scorecard-action#publishing-results.
|
||||
# For private repositories:
|
||||
# - `publish_results` will always be set to `false`, regardless
|
||||
# of the value entered here.
|
||||
publish_results: true
|
||||
|
||||
# (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore
|
||||
# file_mode: git
|
||||
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard (optional).
|
||||
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.29.5
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -3,7 +3,6 @@ _gitignore/
|
||||
Caddyfile
|
||||
Caddyfile.*
|
||||
!caddyfile/
|
||||
!caddyfile.go
|
||||
|
||||
# artifacts from pprof tooling
|
||||
*.prof
|
||||
@@ -12,8 +11,6 @@ Caddyfile.*
|
||||
# build artifacts and helpers
|
||||
cmd/caddy/caddy
|
||||
cmd/caddy/caddy.exe
|
||||
cmd/caddy/tmp/*.exe
|
||||
cmd/caddy/.env
|
||||
|
||||
# mac specific
|
||||
.DS_Store
|
||||
|
||||
+86
-110
@@ -1,122 +1,98 @@
|
||||
version: "2"
|
||||
run:
|
||||
issues-exit-code: 1
|
||||
tests: false
|
||||
build-tags:
|
||||
- nobadger
|
||||
- nomysql
|
||||
- nopgx
|
||||
output:
|
||||
formats:
|
||||
text:
|
||||
path: stdout
|
||||
print-linter-name: true
|
||||
print-issued-lines: true
|
||||
linters-settings:
|
||||
errcheck:
|
||||
ignore: fmt:.*,go.uber.org/zap/zapcore:^Add.*
|
||||
ignoretests: true
|
||||
|
||||
linters:
|
||||
default: none
|
||||
disable-all: true
|
||||
enable:
|
||||
- asasalint
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- decorder
|
||||
- dogsled
|
||||
- dupl
|
||||
- dupword
|
||||
- durationcheck
|
||||
- deadcode
|
||||
- errcheck
|
||||
- errname
|
||||
- exhaustive
|
||||
- gofmt
|
||||
- goimports
|
||||
- gosec
|
||||
- gosimple
|
||||
- govet
|
||||
- importas
|
||||
- ineffassign
|
||||
- misspell
|
||||
- prealloc
|
||||
- promlinter
|
||||
- sloglint
|
||||
- sqlclosecheck
|
||||
- staticcheck
|
||||
- testableexamples
|
||||
- testifylint
|
||||
- tparallel
|
||||
- structcheck
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unused
|
||||
- wastedassign
|
||||
- whitespace
|
||||
- zerologlint
|
||||
settings:
|
||||
staticcheck:
|
||||
checks: ["all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022", "-QF1006", "-QF1008"] # default, and exclude 1 more undesired check
|
||||
errcheck:
|
||||
exclude-functions:
|
||||
- fmt.*
|
||||
- (go.uber.org/zap/zapcore.ObjectEncoder).AddObject
|
||||
- (go.uber.org/zap/zapcore.ObjectEncoder).AddArray
|
||||
exhaustive:
|
||||
ignore-enum-types: reflect.Kind|svc.Cmd
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
rules:
|
||||
- linters:
|
||||
- gosec
|
||||
text: G115 # TODO: Either we should fix the issues or nuke the linter if it's bad
|
||||
- linters:
|
||||
- gosec
|
||||
text: G107 # we aren't calling unknown URL
|
||||
- linters:
|
||||
- gosec
|
||||
text: G203 # as a web server that's expected to handle any template, this is totally in the hands of the user.
|
||||
- linters:
|
||||
- gosec
|
||||
text: G204 # we're shelling out to known commands, not relying on user-defined input.
|
||||
- linters:
|
||||
- gosec
|
||||
# the choice of weakrand is deliberate, hence the named import "weakrand"
|
||||
path: modules/caddyhttp/reverseproxy/selectionpolicies.go
|
||||
text: G404
|
||||
- linters:
|
||||
- gosec
|
||||
path: modules/caddyhttp/reverseproxy/streaming.go
|
||||
text: G404
|
||||
- linters:
|
||||
- dupl
|
||||
path: modules/logging/filters.go
|
||||
- linters:
|
||||
- dupl
|
||||
path: modules/caddyhttp/matchers.go
|
||||
- linters:
|
||||
- dupl
|
||||
path: modules/caddyhttp/vars.go
|
||||
- linters:
|
||||
- errcheck
|
||||
path: _test\.go
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
- gofmt
|
||||
- gofumpt
|
||||
- goimports
|
||||
settings:
|
||||
gci:
|
||||
sections:
|
||||
- standard # Standard section: captures all standard packages.
|
||||
- default # Default section: contains all imports that could not be matched to another section type.
|
||||
- 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.
|
||||
custom-order: true
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- varcheck
|
||||
# these are implicitly disabled:
|
||||
# - asciicheck
|
||||
# - depguard
|
||||
# - dogsled
|
||||
# - dupl
|
||||
# - exhaustive
|
||||
# - exportloopref
|
||||
# - funlen
|
||||
# - gci
|
||||
# - gochecknoglobals
|
||||
# - gochecknoinits
|
||||
# - gocognit
|
||||
# - goconst
|
||||
# - gocritic
|
||||
# - gocyclo
|
||||
# - godot
|
||||
# - godox
|
||||
# - goerr113
|
||||
# - gofumpt
|
||||
# - goheader
|
||||
# - golint
|
||||
# - gomnd
|
||||
# - gomodguard
|
||||
# - goprintffuncname
|
||||
# - interfacer
|
||||
# - lll
|
||||
# - maligned
|
||||
# - nakedret
|
||||
# - nestif
|
||||
# - nlreturn
|
||||
# - noctx
|
||||
# - nolintlint
|
||||
# - rowserrcheck
|
||||
# - scopelint
|
||||
# - sqlclosecheck
|
||||
# - stylecheck
|
||||
# - testpackage
|
||||
# - unparam
|
||||
# - whitespace
|
||||
# - wsl
|
||||
|
||||
run:
|
||||
# default concurrency is a available CPU number.
|
||||
# concurrency: 4 # explicitly omit this value to fully utilize available resources.
|
||||
deadline: 5m
|
||||
issues-exit-code: 1
|
||||
tests: false
|
||||
|
||||
# output configuration options
|
||||
output:
|
||||
format: 'colored-line-number'
|
||||
print-issued-lines: true
|
||||
print-linter-name: true
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
# 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
|
||||
|
||||
+6
-92
@@ -1,39 +1,26 @@
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# The build is done in this particular way to build Caddy in a designated directory named in .gitignore.
|
||||
# This is so we can run goreleaser on tag without Git complaining of being dirty. The main.go in cmd/caddy directory
|
||||
# cannot be built within that directory due to changes necessary for the build causing Git to be dirty, which
|
||||
# subsequently causes gorleaser to refuse running.
|
||||
- rm -rf caddy-build caddy-dist vendor
|
||||
# vendor Caddy deps
|
||||
- go mod vendor
|
||||
- mkdir -p caddy-build
|
||||
- cp cmd/caddy/main.go caddy-build/main.go
|
||||
- /bin/sh -c 'cd ./caddy-build && go mod init caddy'
|
||||
# prepare syso files for windows embedding
|
||||
- /bin/sh -c 'for a in amd64 arm arm64; do XCADDY_SKIP_BUILD=1 GOOS=windows GOARCH=$a xcaddy build {{.Env.TAG}}; done'
|
||||
- /bin/sh -c 'mv /tmp/buildenv_*/*.syso caddy-build'
|
||||
# GoReleaser doesn't seem to offer {{.Tag}} at this stage, so we have to embed it into the env
|
||||
# so we run: TAG=$(git describe --abbrev=0) goreleaser release --rm-dist --skip-publish --skip-validate
|
||||
- go mod edit -require=github.com/caddyserver/caddy/v2@{{.Env.TAG}} ./caddy-build/go.mod
|
||||
# as of Go 1.16, `go` commands no longer automatically change go.{mod,sum}. We now have to explicitly
|
||||
# run `go mod tidy`. The `/bin/sh -c '...'` is because goreleaser can't find cd in PATH without shell invocation.
|
||||
- /bin/sh -c 'cd ./caddy-build && go mod tidy'
|
||||
# vendor the deps of the prepared to-build module
|
||||
- /bin/sh -c 'cd ./caddy-build && go mod vendor'
|
||||
- git clone --depth 1 https://github.com/caddyserver/dist caddy-dist
|
||||
- mkdir -p caddy-dist/man
|
||||
- go mod download
|
||||
- go run cmd/caddy/main.go manpage --directory ./caddy-dist/man
|
||||
- gzip -r ./caddy-dist/man/
|
||||
- /bin/sh -c 'go run cmd/caddy/main.go completion bash > ./caddy-dist/scripts/bash-completion'
|
||||
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
- GO111MODULE=on
|
||||
main: main.go
|
||||
dir: ./caddy-build
|
||||
binary: caddy
|
||||
goos:
|
||||
@@ -47,7 +34,6 @@ builds:
|
||||
- arm64
|
||||
- s390x
|
||||
- ppc64le
|
||||
- riscv64
|
||||
goarm:
|
||||
- "5"
|
||||
- "6"
|
||||
@@ -59,95 +45,28 @@ builds:
|
||||
goarch: ppc64le
|
||||
- goos: darwin
|
||||
goarch: s390x
|
||||
- goos: darwin
|
||||
goarch: riscv64
|
||||
- goos: windows
|
||||
goarch: ppc64le
|
||||
- goos: windows
|
||||
goarch: s390x
|
||||
- goos: windows
|
||||
goarch: riscv64
|
||||
- goos: freebsd
|
||||
goarch: ppc64le
|
||||
- goos: freebsd
|
||||
goarch: s390x
|
||||
- goos: freebsd
|
||||
goarch: riscv64
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
goarm: "5"
|
||||
flags:
|
||||
- -trimpath
|
||||
- -mod=readonly
|
||||
ldflags:
|
||||
- -s -w
|
||||
tags:
|
||||
- nobadger
|
||||
- nomysql
|
||||
- nopgx
|
||||
|
||||
signs:
|
||||
- cmd: cosign
|
||||
signature: "${artifact}.sig"
|
||||
certificate: '{{ trimsuffix (trimsuffix .Env.artifact ".zip") ".tar.gz" }}.pem'
|
||||
args: ["sign-blob", "--yes", "--output-signature=${signature}", "--output-certificate", "${certificate}", "${artifact}"]
|
||||
artifacts: all
|
||||
|
||||
sboms:
|
||||
- artifacts: binary
|
||||
documents:
|
||||
- >-
|
||||
{{ .ProjectName }}_
|
||||
{{- .Version }}_
|
||||
{{- if eq .Os "darwin" }}mac{{ else }}{{ .Os }}{{ end }}_
|
||||
{{- .Arch }}
|
||||
{{- with .Arm }}v{{ . }}{{ end }}
|
||||
{{- with .Mips }}_{{ . }}{{ end }}
|
||||
{{- if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}.sbom
|
||||
cmd: syft
|
||||
args: ["$artifact", "--file", "${document}", "--output", "cyclonedx-json"]
|
||||
|
||||
archives:
|
||||
- id: default
|
||||
format_overrides:
|
||||
- format_overrides:
|
||||
- goos: windows
|
||||
formats: zip
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_
|
||||
{{- .Version }}_
|
||||
{{- if eq .Os "darwin" }}mac{{ else }}{{ .Os }}{{ end }}_
|
||||
{{- .Arch }}
|
||||
{{- with .Arm }}v{{ . }}{{ end }}
|
||||
{{- with .Mips }}_{{ . }}{{ end }}
|
||||
{{- if not (eq .Amd64 "v1") }}{{ .Amd64 }}{{ end }}
|
||||
|
||||
# package the 'caddy-build' directory into a tarball,
|
||||
# allowing users to build the exact same set of files as ours.
|
||||
- id: source
|
||||
meta: true
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_buildable-artifact"
|
||||
files:
|
||||
- src: LICENSE
|
||||
dst: ./LICENSE
|
||||
- src: README.md
|
||||
dst: ./README.md
|
||||
- src: AUTHORS
|
||||
dst: ./AUTHORS
|
||||
- src: ./caddy-build
|
||||
dst: ./
|
||||
|
||||
source:
|
||||
enabled: true
|
||||
name_template: '{{ .ProjectName }}_{{ .Version }}_src'
|
||||
format: 'tar.gz'
|
||||
|
||||
# Additional files/template/globs you want to add to the source archive.
|
||||
#
|
||||
# Default: empty.
|
||||
files:
|
||||
- vendor
|
||||
|
||||
|
||||
format: zip
|
||||
replacements:
|
||||
darwin: mac
|
||||
checksum:
|
||||
algorithm: sha512
|
||||
|
||||
@@ -177,23 +96,18 @@ nfpms:
|
||||
- src: ./caddy-dist/welcome/index.html
|
||||
dst: /usr/share/caddy/index.html
|
||||
|
||||
- src: ./caddy-dist/scripts/bash-completion
|
||||
- src: ./caddy-dist/scripts/completions/bash-completion
|
||||
dst: /etc/bash_completion.d/caddy
|
||||
|
||||
- src: ./caddy-dist/config/Caddyfile
|
||||
dst: /etc/caddy/Caddyfile
|
||||
type: config
|
||||
|
||||
- src: ./caddy-dist/man/*
|
||||
dst: /usr/share/man/man8/
|
||||
|
||||
scripts:
|
||||
postinstall: ./caddy-dist/scripts/postinstall.sh
|
||||
preremove: ./caddy-dist/scripts/preremove.sh
|
||||
postremove: ./caddy-dist/scripts/postremove.sh
|
||||
|
||||
provides:
|
||||
- httpd
|
||||
|
||||
release:
|
||||
github:
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
repos:
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.16.3
|
||||
hooks:
|
||||
- id: gitleaks
|
||||
- repo: https://github.com/golangci/golangci-lint
|
||||
rev: v1.52.2
|
||||
hooks:
|
||||
- id: golangci-lint-config-verify
|
||||
- id: golangci-lint
|
||||
- id: golangci-lint-fmt
|
||||
- repo: https://github.com/jumanjihouse/pre-commit-hooks
|
||||
rev: 3.0.0
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
@@ -1,63 +1,28 @@
|
||||
<p align="center">
|
||||
<a href="https://caddyserver.com">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/1128849/210187358-e2c39003-9a5e-4dd5-a783-6deb6483ee72.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/1128849/210187356-dfb7f1c5-ac2e-43aa-bb23-fc014280ae1f.svg">
|
||||
<img src="https://user-images.githubusercontent.com/1128849/210187356-dfb7f1c5-ac2e-43aa-bb23-fc014280ae1f.svg" alt="Caddy" width="550">
|
||||
</picture>
|
||||
</a>
|
||||
<a href="https://caddyserver.com"><img src="https://user-images.githubusercontent.com/1128849/36338535-05fb646a-136f-11e8-987b-e6901e717d5a.png" alt="Caddy" width="450"></a>
|
||||
<br>
|
||||
<h3 align="center">a <a href="https://zerossl.com"><img src="https://user-images.githubusercontent.com/55066419/208327323-2770dc16-ec09-43a0-9035-c5b872c2ad7f.svg" height="28" style="vertical-align: -7.7px" valign="middle"></a> project</h3>
|
||||
<h3 align="center">a <a href="https://zerossl.com"><img src="https://caddyserver.com/resources/images/zerossl-logo.svg" height="28" valign="middle"></a> project</h3>
|
||||
</p>
|
||||
<hr>
|
||||
<h3 align="center">Every site on HTTPS</h3>
|
||||
<p align="center">Caddy is an extensible server platform that uses TLS by default.</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/caddyserver/caddy/actions?query=workflow%3ACross-Platform"><img src="https://github.com/caddyserver/caddy/workflows/Cross-Platform/badge.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>
|
||||
<a href="https://twitter.com/caddyserver" title="@caddyserver on Twitter"><img src="https://img.shields.io/badge/twitter-@caddyserver-55acee.svg" alt="@caddyserver on Twitter"></a>
|
||||
<a href="https://caddy.community" title="Caddy Forum"><img src="https://img.shields.io/badge/community-forum-ff69b4.svg" alt="Caddy Forum"></a>
|
||||
<br>
|
||||
<a href="https://sourcegraph.com/github.com/caddyserver/caddy?badge" title="Caddy on Sourcegraph"><img src="https://sourcegraph.com/github.com/caddyserver/caddy/-/badge.svg" alt="Caddy on Sourcegraph"></a>
|
||||
<a href="https://cloudsmith.io/~caddy/repos/"><img src="https://img.shields.io/badge/OSS%20hosting%20by-cloudsmith-blue?logo=cloudsmith" alt="Cloudsmith"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/caddyserver/caddy/releases">Releases</a> ·
|
||||
<a href="https://caddyserver.com/docs/">Documentation</a> ·
|
||||
<a href="https://caddy.community">Get Help</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/caddyserver/caddy/actions/workflows/ci.yml"><img src="https://github.com/caddyserver/caddy/actions/workflows/ci.yml/badge.svg"></a>
|
||||
|
||||
<a href="https://www.bestpractices.dev/projects/7141"><img src="https://www.bestpractices.dev/projects/7141/badge"></a>
|
||||
|
||||
<a href="https://pkg.go.dev/github.com/caddyserver/caddy/v2"><img src="https://img.shields.io/badge/godoc-reference-%23007d9c.svg"></a>
|
||||
|
||||
<a href="https://x.com/caddyserver" title="@caddyserver on Twitter"><img src="https://img.shields.io/twitter/follow/caddyserver" alt="@caddyserver on Twitter"></a>
|
||||
|
||||
<a href="https://caddy.community" title="Caddy Forum"><img src="https://img.shields.io/badge/community-forum-ff69b4.svg" alt="Caddy Forum"></a>
|
||||
<br>
|
||||
<a href="https://sourcegraph.com/github.com/caddyserver/caddy?badge" title="Caddy on Sourcegraph"><img src="https://sourcegraph.com/github.com/caddyserver/caddy/-/badge.svg" alt="Caddy on Sourcegraph"></a>
|
||||
|
||||
<a href="https://cloudsmith.io/~caddy/repos/"><img src="https://img.shields.io/badge/OSS%20hosting%20by-cloudsmith-blue?logo=cloudsmith" alt="Cloudsmith"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<b>Powered by</b>
|
||||
<br>
|
||||
<a href="https://github.com/caddyserver/certmagic">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/55066419/206946718-740b6371-3df3-4d72-a822-47e4c48af999.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/1128849/49704830-49d37200-fbd5-11e8-8385-767e0cd033c3.png">
|
||||
<img src="https://user-images.githubusercontent.com/1128849/49704830-49d37200-fbd5-11e8-8385-767e0cd033c3.png" alt="CertMagic" width="250">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- Warp sponsorship requests this section -->
|
||||
<div align="center" markdown="1">
|
||||
<hr>
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<a href="https://go.warp.dev/caddy">
|
||||
<img alt="Warp sponsorship" width="400" src="https://github.com/user-attachments/assets/c8efffde-18c7-4af4-83ed-b1aba2dda394">
|
||||
</a>
|
||||
|
||||
### [Warp, built for coding with multiple AI agents](https://go.warp.dev/caddy)
|
||||
[Available for MacOS, Linux, & Windows](https://go.warp.dev/caddy)<br>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
### Menu
|
||||
|
||||
@@ -72,8 +37,14 @@
|
||||
- [Getting help](#getting-help)
|
||||
- [About](#about)
|
||||
|
||||
<p align="center">
|
||||
<b>Powered by</b>
|
||||
<br>
|
||||
<a href="https://github.com/caddyserver/certmagic"><img src="https://user-images.githubusercontent.com/1128849/49704830-49d37200-fbd5-11e8-8385-767e0cd033c3.png" alt="CertMagic" width="250"></a>
|
||||
</p>
|
||||
|
||||
## [Features](https://caddyserver.com/features)
|
||||
|
||||
## [Features](https://caddyserver.com/v2)
|
||||
|
||||
- **Easy configuration** with the [Caddyfile](https://caddyserver.com/docs/caddyfile)
|
||||
- **Powerful configuration** with its [native JSON config](https://caddyserver.com/docs/json/)
|
||||
@@ -84,31 +55,30 @@
|
||||
- Fully-managed local CA for internal names & IPs
|
||||
- Can coordinate with other Caddy instances in a cluster
|
||||
- Multi-issuer fallback
|
||||
- Encrypted ClientHello (ECH) support
|
||||
- **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
|
||||
- **Scales to hundreds of thousands of sites** as proven in production
|
||||
- **HTTP/1.1, HTTP/2, and HTTP/3** all supported by default
|
||||
- **Scales to tens of thousands of sites** ... and probably more
|
||||
- **HTTP/1.1, HTTP/2, and experimental HTTP/3** support
|
||||
- **Highly extensible** [modular architecture](https://caddyserver.com/docs/architecture) lets Caddy do anything without bloat
|
||||
- **Runs anywhere** with **no external dependencies** (not even libc)
|
||||
- Written in Go, a language with higher **memory safety guarantees** than other servers
|
||||
- Actually **fun to use**
|
||||
- So much more to [discover](https://caddyserver.com/features)
|
||||
- So, so much more to [discover](https://caddyserver.com/v2)
|
||||
|
||||
## Install
|
||||
|
||||
The simplest, cross-platform way to get started is to download Caddy from [GitHub Releases](https://github.com/caddyserver/caddy/releases) and place the executable file in your PATH.
|
||||
The simplest, cross-platform way is to download from [GitHub Releases](https://github.com/caddyserver/caddy/releases) and place the executable file in your PATH.
|
||||
|
||||
See [our online documentation](https://caddyserver.com/docs/install) for other install instructions.
|
||||
For other install options, see https://caddyserver.com/docs/install.
|
||||
|
||||
## Build from source
|
||||
|
||||
Requirements:
|
||||
|
||||
- [Go 1.25.0 or newer](https://golang.org/dl/)
|
||||
- [Go 1.17 or newer](https://golang.org/dl/)
|
||||
|
||||
### For development
|
||||
|
||||
|
||||
_**Note:** These steps [will not embed proper version information](https://github.com/golang/go/issues/29228). For that, please follow the instructions in the next section._
|
||||
|
||||
```bash
|
||||
@@ -133,18 +103,11 @@ username ALL=(ALL:ALL) NOPASSWD: /usr/sbin/setcap
|
||||
|
||||
replacing `username` with your actual username. Please be careful and only do this if you know what you are doing! We are only qualified to document how to use Caddy, not Go tooling or your computer, and we are providing these instructions for convenience only; please learn how to use your own computer at your own risk and make any needful adjustments.
|
||||
|
||||
Then you can run the tests in all modules or a specific one:
|
||||
|
||||
```bash
|
||||
$ go test ./...
|
||||
$ go test ./modules/caddyhttp/tracing/
|
||||
```
|
||||
|
||||
### With version information and/or plugins
|
||||
|
||||
Using [our builder tool, `xcaddy`](https://github.com/caddyserver/xcaddy)...
|
||||
|
||||
```bash
|
||||
```
|
||||
$ xcaddy build
|
||||
```
|
||||
|
||||
@@ -156,7 +119,7 @@ $ xcaddy build
|
||||
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.
|
||||
6. (Optional) Add plugins by adding their import: `_ "import/path/here"`
|
||||
7. Compile: `go build -tags=nobadger,nomysql,nopgx`
|
||||
7. Compile: `go build`
|
||||
|
||||
|
||||
|
||||
@@ -201,9 +164,9 @@ The docs are also open source. You can contribute to them here: https://github.c
|
||||
|
||||
## Getting help
|
||||
|
||||
- We advise companies using Caddy to secure a support contract through [Ardan Labs](https://www.ardanlabs.com) before help is needed.
|
||||
- We **strongly recommend** that all professionals or companies using Caddy get a support contract through [Ardan Labs](https://www.ardanlabs.com/my/contact-us?dd=caddy) before help is needed.
|
||||
|
||||
- A [sponsorship](https://github.com/sponsors/mholt) goes a long way! We can offer private help to sponsors. If Caddy is benefitting your company, please consider a sponsorship. This not only helps fund full-time work to ensure the longevity of the project, it provides your company the resources, support, and discounts you need; along with being a great look for your company to your customers and potential customers!
|
||||
- A [sponsorship](https://github.com/sponsors/mholt) goes a long way! 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's also a great look for your company to your customers and potential customers!
|
||||
|
||||
- Individuals can exchange help for free on our community forum at https://caddy.community. Remember that people give help out of their spare time and good will. The best way to get help is to give it first!
|
||||
|
||||
@@ -217,9 +180,9 @@ 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.
|
||||
|
||||
- _Project on X: [@caddyserver](https://x.com/caddyserver)_
|
||||
- _Author on X: [@mholt6](https://x.com/mholt6)_
|
||||
- _Project on Twitter: [@caddyserver](https://twitter.com/caddyserver)_
|
||||
- _Author on Twitter: [@mholt6](https://twitter.com/mholt6)_
|
||||
|
||||
Caddy is a project of [ZeroSSL](https://zerossl.com), an HID Global company.
|
||||
Caddy is a project of [ZeroSSL](https://zerossl.com), a Stack Holdings company.
|
||||
|
||||
Debian package repository hosting is graciously provided by [Cloudsmith](https://cloudsmith.com). Cloudsmith is the only fully hosted, cloud-native, universal package management solution, that enables your organization to create, store and share packages in any format, to any place, with total confidence.
|
||||
Debian package repository hosting is graciously provided by [Cloudsmith](https://cloudsmith.com). Cloudsmith is the only fully hosted, cloud-native, universal package management solution, that enables your organization to create, store and share packages in any format, to any place, with total confidence.
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
"errors"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -34,36 +33,18 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/caddyserver/caddy/v2/notify"
|
||||
"github.com/caddyserver/certmagic"
|
||||
"github.com/cespare/xxhash/v2"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
// testCertMagicStorageOverride is a package-level test hook. Tests may set
|
||||
// this variable to provide a temporary certmagic.Storage so that cert
|
||||
// management in tests does not hit the real default storage on disk.
|
||||
// This must NOT be set in production code.
|
||||
var testCertMagicStorageOverride certmagic.Storage
|
||||
|
||||
func init() {
|
||||
// The hard-coded default `DefaultAdminListen` can be overridden
|
||||
// by setting the `CADDY_ADMIN` environment variable.
|
||||
// The environment variable may be used by packagers to change
|
||||
// the default admin address to something more appropriate for
|
||||
// that platform. See #5317 for discussion.
|
||||
if env, exists := os.LookupEnv("CADDY_ADMIN"); exists {
|
||||
DefaultAdminListen = env
|
||||
}
|
||||
}
|
||||
|
||||
// AdminConfig configures Caddy's API endpoint, which is used
|
||||
// to manage Caddy while it is running.
|
||||
type AdminConfig struct {
|
||||
@@ -75,14 +56,7 @@ type AdminConfig struct {
|
||||
|
||||
// The address to which the admin endpoint's listener should
|
||||
// bind itself. Can be any single network address that can be
|
||||
// parsed by Caddy. Accepts placeholders.
|
||||
// Default: the value of the `CADDY_ADMIN` environment variable,
|
||||
// or `localhost:2019` otherwise.
|
||||
//
|
||||
// Remember: When changing this value through a config reload,
|
||||
// be sure to use the `--address` CLI flag to specify the current
|
||||
// admin address if the currently-running admin endpoint is not
|
||||
// the default address.
|
||||
// parsed by Caddy. Default: localhost:2019
|
||||
Listen string `json:"listen,omitempty"`
|
||||
|
||||
// If true, CORS headers will be emitted, and requests to the
|
||||
@@ -181,7 +155,7 @@ type IdentityConfig struct {
|
||||
//
|
||||
// EXPERIMENTAL: Subject to change.
|
||||
type RemoteAdmin struct {
|
||||
// The address on which to start the secure listener. Accepts placeholders.
|
||||
// The address on which to start the secure listener.
|
||||
// Default: :2021
|
||||
Listen string `json:"listen,omitempty"`
|
||||
|
||||
@@ -220,15 +194,14 @@ type AdminPermissions struct {
|
||||
|
||||
// newAdminHandler reads admin's config and returns an http.Handler suitable
|
||||
// for use in an admin endpoint server, which will be listening on listenAddr.
|
||||
func (admin *AdminConfig) newAdminHandler(addr NetworkAddress, remote bool, _ Context) adminHandler {
|
||||
func (admin *AdminConfig) newAdminHandler(addr NetworkAddress, remote bool) adminHandler {
|
||||
muxWrap := adminHandler{mux: http.NewServeMux()}
|
||||
|
||||
// secure the local or remote endpoint respectively
|
||||
if remote {
|
||||
muxWrap.remoteControl = admin.Remote
|
||||
} else {
|
||||
// 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.enforceHost = !addr.isWildcardInterface()
|
||||
muxWrap.allowedOrigins = admin.allowedOrigins(addr)
|
||||
muxWrap.enforceOrigin = admin.EnforceOrigin
|
||||
}
|
||||
@@ -277,6 +250,7 @@ func (admin *AdminConfig) newAdminHandler(addr NetworkAddress, remote bool, _ Co
|
||||
// register third-party module endpoints
|
||||
for _, m := range GetModules("admin.api") {
|
||||
router := m.New().(AdminRouter)
|
||||
handlerLabel := m.ID.Name()
|
||||
for _, route := range router.Routes() {
|
||||
addRoute(route.Pattern, handlerLabel, route.Handler)
|
||||
}
|
||||
@@ -317,43 +291,22 @@ func (admin AdminConfig) allowedOrigins(addr NetworkAddress) []*url.URL {
|
||||
for _, o := range admin.Origins {
|
||||
uniqueOrigins[o] = struct{}{}
|
||||
}
|
||||
// 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 admin.Origins == nil {
|
||||
if addr.isLoopback() {
|
||||
uniqueOrigins[net.JoinHostPort("localhost", addr.port())] = struct{}{}
|
||||
uniqueOrigins[net.JoinHostPort("::1", addr.port())] = struct{}{}
|
||||
uniqueOrigins[net.JoinHostPort("127.0.0.1", addr.port())] = struct{}{}
|
||||
} else {
|
||||
if addr.IsUnixNetwork() {
|
||||
// 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."
|
||||
uniqueOrigins[""] = struct{}{}
|
||||
} else {
|
||||
uniqueOrigins[net.JoinHostPort("localhost", addr.port())] = struct{}{}
|
||||
uniqueOrigins[net.JoinHostPort("::1", addr.port())] = struct{}{}
|
||||
uniqueOrigins[net.JoinHostPort("127.0.0.1", addr.port())] = struct{}{}
|
||||
}
|
||||
}
|
||||
if !addr.IsUnixNetwork() {
|
||||
uniqueOrigins[addr.JoinHostPort(0)] = struct{}{}
|
||||
}
|
||||
}
|
||||
@@ -384,22 +337,18 @@ func (admin AdminConfig) allowedOrigins(addr NetworkAddress) []*url.URL {
|
||||
// for the admin endpoint exists in cfg, a default one is used, so
|
||||
// that there is always an admin server (unless it is explicitly
|
||||
// configured to be disabled).
|
||||
// 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
|
||||
func replaceLocalAdminServer(cfg *Config) error {
|
||||
// always be sure to close down the old admin endpoint
|
||||
// as gracefully as possible, even if the new one is
|
||||
// disabled -- careful to use reference to the current
|
||||
// (old) admin endpoint since it will be different
|
||||
// when the function returns
|
||||
// (* except if the new one fails to start)
|
||||
oldAdminServer := localAdminServer
|
||||
var err error
|
||||
defer func() {
|
||||
// do the shutdown asynchronously so that any
|
||||
// current API request gets a response; this
|
||||
// goroutine may last a few seconds
|
||||
if oldAdminServer != nil && err == nil {
|
||||
if oldAdminServer != nil {
|
||||
go func(oldAdminServer *http.Server) {
|
||||
err := stopAdminServer(oldAdminServer)
|
||||
if err != nil {
|
||||
@@ -428,16 +377,9 @@ func replaceLocalAdminServer(cfg *Config, ctx Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
handler := cfg.Admin.newAdminHandler(addr, false, ctx)
|
||||
handler := cfg.Admin.newAdminHandler(addr, false)
|
||||
|
||||
// run the provisioners for loaded modules to make sure local
|
||||
// state is properly re-initialized in the new admin server
|
||||
err = cfg.Admin.provisionAdminRouters(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ln, err := addr.Listen(context.TODO(), 0, net.ListenConfig{})
|
||||
ln, err := Listen(addr.Network, addr.JoinHostPort(0))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -458,7 +400,7 @@ func replaceLocalAdminServer(cfg *Config, ctx Context) error {
|
||||
serverMu.Lock()
|
||||
server := localAdminServer
|
||||
serverMu.Unlock()
|
||||
if err := server.Serve(ln.(net.Listener)); !errors.Is(err, http.ErrServerClosed) {
|
||||
if err := server.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
||||
adminLogger.Error("admin server shutdown for unknown reason", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
@@ -486,6 +428,7 @@ func manageIdentity(ctx Context, cfg *Config) error {
|
||||
// import the caddytls package -- but it works
|
||||
if cfg.Admin.Identity.IssuersRaw == nil {
|
||||
cfg.Admin.Identity.IssuersRaw = []json.RawMessage{
|
||||
json.RawMessage(`{"module": "zerossl"}`),
|
||||
json.RawMessage(`{"module": "acme"}`),
|
||||
}
|
||||
}
|
||||
@@ -496,7 +439,7 @@ func manageIdentity(ctx Context, cfg *Config) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading identity issuer modules: %s", err)
|
||||
}
|
||||
for _, issVal := range val.([]any) {
|
||||
for _, issVal := range val.([]interface{}) {
|
||||
cfg.Admin.Identity.issuers = append(cfg.Admin.Identity.issuers, issVal.(certmagic.Issuer))
|
||||
}
|
||||
}
|
||||
@@ -556,14 +499,7 @@ func replaceRemoteAdminServer(ctx Context, cfg *Config) error {
|
||||
|
||||
// make the HTTP handler but disable Host/Origin enforcement
|
||||
// because we are using TLS authentication instead
|
||||
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
|
||||
}
|
||||
handler := cfg.Admin.newAdminHandler(addr, true)
|
||||
|
||||
// create client certificate pool for TLS mutual auth, and extract public keys
|
||||
// so that we can enforce access controls at the application layer
|
||||
@@ -610,11 +546,10 @@ func replaceRemoteAdminServer(ctx Context, cfg *Config) error {
|
||||
serverMu.Unlock()
|
||||
|
||||
// start listener
|
||||
lnAny, err := addr.Listen(ctx, 0, net.ListenConfig{})
|
||||
ln, err := Listen(addr.Network, addr.JoinHostPort(0))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ln := lnAny.(net.Listener)
|
||||
ln = tls.NewListener(ln, tlsConfig)
|
||||
|
||||
go func() {
|
||||
@@ -633,25 +568,13 @@ func replaceRemoteAdminServer(ctx Context, cfg *Config) error {
|
||||
}
|
||||
|
||||
func (ident *IdentityConfig) certmagicConfig(logger *zap.Logger, makeCache bool) *certmagic.Config {
|
||||
var cmCfg *certmagic.Config
|
||||
if ident == nil {
|
||||
// user might not have configured identity; that's OK, we can still make a
|
||||
// certmagic config, although it'll be mostly useless for remote management
|
||||
ident = new(IdentityConfig)
|
||||
}
|
||||
// Choose storage: prefer the package-level test override when present,
|
||||
// otherwise use the configured DefaultStorage. Tests may set an override
|
||||
// to divert storage into a temporary location. Otherwise, in production
|
||||
// we use the DefaultStorage since we don't want to act as part of a
|
||||
// cluster; this storage is for the server's local identity only.
|
||||
var storage certmagic.Storage
|
||||
if testCertMagicStorageOverride != nil {
|
||||
storage = testCertMagicStorageOverride
|
||||
} else {
|
||||
storage = DefaultStorage
|
||||
}
|
||||
template := certmagic.Config{
|
||||
Storage: storage,
|
||||
cmCfg := &certmagic.Config{
|
||||
Storage: DefaultStorage, // do not act as part of a cluster (this is for the server's local identity)
|
||||
Logger: logger,
|
||||
Issuers: ident.issuers,
|
||||
}
|
||||
@@ -660,11 +583,9 @@ func (ident *IdentityConfig) certmagicConfig(logger *zap.Logger, makeCache bool)
|
||||
GetConfigForCert: func(certmagic.Certificate) (*certmagic.Config, error) {
|
||||
return cmCfg, nil
|
||||
},
|
||||
Logger: logger.Named("cache"),
|
||||
})
|
||||
}
|
||||
cmCfg = certmagic.New(identityCertCache, template)
|
||||
return cmCfg
|
||||
return certmagic.New(identityCertCache, *cmCfg)
|
||||
}
|
||||
|
||||
// IdentityCredentials returns this instance's configured, managed identity credentials
|
||||
@@ -705,7 +626,13 @@ func (remote RemoteAdmin) enforceAccessControls(r *http.Request) error {
|
||||
// key recognized; make sure its HTTP request is permitted
|
||||
for _, accessPerm := range adminAccess.Permissions {
|
||||
// verify method
|
||||
methodFound := accessPerm.Methods == nil || slices.Contains(accessPerm.Methods, r.Method)
|
||||
methodFound := accessPerm.Methods == nil
|
||||
for _, method := range accessPerm.Methods {
|
||||
if method == r.Method {
|
||||
methodFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !methodFound {
|
||||
return APIError{
|
||||
HTTPStatus: http.StatusForbidden,
|
||||
@@ -824,38 +751,13 @@ func (h adminHandler) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// common mitigations in browser contexts
|
||||
if strings.Contains(r.Header.Get("Upgrade"), "websocket") {
|
||||
// I've never been able demonstrate a vulnerability myself, but apparently
|
||||
// WebSocket connections originating from browsers aren't subject to CORS
|
||||
// restrictions, so we'll just be on the safe side
|
||||
h.handleError(w, r, APIError{
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
Err: errors.New("websocket connections aren't allowed"),
|
||||
Message: "WebSocket connections aren't allowed.",
|
||||
})
|
||||
h.handleError(w, r, fmt.Errorf("websocket connections aren't allowed"))
|
||||
return
|
||||
}
|
||||
if strings.Contains(r.Header.Get("Sec-Fetch-Mode"), "no-cors") {
|
||||
// turns out web pages can just disable the same-origin policy (!???!?)
|
||||
// but at least browsers let us know that's the case, holy heck
|
||||
h.handleError(w, r, APIError{
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
Err: errors.New("client attempted to make request by disabling same-origin policy using no-cors mode"),
|
||||
Message: "Disabling same-origin restrictions is not allowed.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Origin") == "null" {
|
||||
// bug in Firefox in certain cross-origin situations (yikes?)
|
||||
// (not strictly a security vuln on its own, but it's red flaggy,
|
||||
// since it seems to manifest in cross-origin contexts)
|
||||
h.handleError(w, r, APIError{
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
Err: errors.New("invalid origin 'null'"),
|
||||
Message: "Buggy browser is sending null Origin header.",
|
||||
})
|
||||
}
|
||||
|
||||
if h.enforceHost {
|
||||
// DNS rebinding mitigation
|
||||
@@ -866,9 +768,7 @@ func (h adminHandler) serveHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
_, hasOriginHeader := r.Header["Origin"]
|
||||
_, hasSecHeader := r.Header["Sec-Fetch-Mode"]
|
||||
if h.enforceOrigin || hasOriginHeader || hasSecHeader {
|
||||
if h.enforceOrigin {
|
||||
// cross-site mitigation
|
||||
origin, err := h.checkOrigin(r)
|
||||
if err != nil {
|
||||
@@ -928,9 +828,13 @@ func (h adminHandler) handleError(w http.ResponseWriter, r *http.Request, err er
|
||||
// a trustworthy/expected value. This helps to mitigate DNS
|
||||
// rebinding attacks.
|
||||
func (h adminHandler) checkHost(r *http.Request) error {
|
||||
allowed := slices.ContainsFunc(h.allowedOrigins, func(u *url.URL) bool {
|
||||
return r.Host == u.Host
|
||||
})
|
||||
var allowed bool
|
||||
for _, allowedOrigin := range h.allowedOrigins {
|
||||
if r.Host == allowedOrigin.Host {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return APIError{
|
||||
HTTPStatus: http.StatusForbidden,
|
||||
@@ -990,51 +894,16 @@ func (h adminHandler) originAllowed(origin *url.URL) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// etagHasher returns the hasher we used on the config to both
|
||||
// produce and verify ETags.
|
||||
func etagHasher() hash.Hash { return xxhash.New() }
|
||||
|
||||
// makeEtag returns an Etag header value (including quotes) for
|
||||
// the given config path and hash of contents at that path.
|
||||
func makeEtag(path string, hash hash.Hash) string {
|
||||
return fmt.Sprintf(`"%s %x"`, path, hash.Sum(nil))
|
||||
}
|
||||
|
||||
// This buffer pool is used to keep buffers for
|
||||
// reading the config file during eTag header generation
|
||||
var bufferPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
func handleConfig(w http.ResponseWriter, r *http.Request) error {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
hash := etagHasher()
|
||||
|
||||
// Read the config into a buffer instead of writing directly to
|
||||
// the response writer, as we want to set the ETag as the header,
|
||||
// not the trailer.
|
||||
buf := bufferPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
defer bufferPool.Put(buf)
|
||||
|
||||
configWriter := io.MultiWriter(buf, hash)
|
||||
err := readConfig(r.URL.Path, configWriter)
|
||||
err := readConfig(r.URL.Path, w)
|
||||
if err != nil {
|
||||
return APIError{HTTPStatus: http.StatusBadRequest, Err: err}
|
||||
}
|
||||
|
||||
// we could consider setting up a sync.Pool for the summed
|
||||
// hashes to reduce GC pressure.
|
||||
w.Header().Set("Etag", makeEtag(r.URL.Path, hash))
|
||||
_, err = w.Write(buf.Bytes())
|
||||
if err != nil {
|
||||
return APIError{HTTPStatus: http.StatusInternalServerError, Err: err}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
case http.MethodPost,
|
||||
@@ -1068,18 +937,11 @@ func handleConfig(w http.ResponseWriter, r *http.Request) error {
|
||||
|
||||
forceReload := r.Header.Get("Cache-Control") == "must-revalidate"
|
||||
|
||||
err := changeConfig(r.Method, r.URL.Path, body, r.Header.Get("If-Match"), forceReload)
|
||||
err := changeConfig(r.Method, r.URL.Path, body, forceReload)
|
||||
if err != nil && !errors.Is(err, errSameConfig) {
|
||||
return err
|
||||
}
|
||||
|
||||
// If this request changed the config, clear the last
|
||||
// config info we have stored, if it is different from
|
||||
// the original source.
|
||||
ClearLastConfigIfDifferent(
|
||||
r.Header.Get("Caddy-Config-Source-File"),
|
||||
r.Header.Get("Caddy-Config-Source-Adapter"))
|
||||
|
||||
default:
|
||||
return APIError{
|
||||
HTTPStatus: http.StatusMethodNotAllowed,
|
||||
@@ -1109,9 +971,9 @@ func handleConfigID(w http.ResponseWriter, r *http.Request) error {
|
||||
id := parts[2]
|
||||
|
||||
// map the ID to the expanded path
|
||||
rawCfgMu.RLock()
|
||||
currentCfgMu.RLock()
|
||||
expanded, ok := rawCfgIndex[id]
|
||||
rawCfgMu.RUnlock()
|
||||
defer currentCfgMu.RUnlock()
|
||||
if !ok {
|
||||
return APIError{
|
||||
HTTPStatus: http.StatusNotFound,
|
||||
@@ -1134,6 +996,10 @@ func handleStop(w http.ResponseWriter, r *http.Request) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := notify.NotifyStopping(); err != nil {
|
||||
Log().Error("unable to notify stopping to service manager", zap.Error(err))
|
||||
}
|
||||
|
||||
exitProcess(context.Background(), Log().Named("admin.api"))
|
||||
return nil
|
||||
}
|
||||
@@ -1142,11 +1008,11 @@ func handleStop(w http.ResponseWriter, r *http.Request) error {
|
||||
// the operation at path according to method, using body and out as
|
||||
// needed. This is a low-level, unsynchronized function; most callers
|
||||
// will want to use changeConfig or readConfig instead. This requires a
|
||||
// read or write lock on currentCtxMu, depending on method (GET needs
|
||||
// read or write lock on currentCfgMu, depending on method (GET needs
|
||||
// only a read lock; all others need a write lock).
|
||||
func unsyncedConfigAccess(method, path string, body []byte, out io.Writer) error {
|
||||
var err error
|
||||
var val any
|
||||
var val interface{}
|
||||
|
||||
// if there is a request body, decode it into the
|
||||
// variable that will be set in the config according
|
||||
@@ -1154,10 +1020,7 @@ func unsyncedConfigAccess(method, path string, body []byte, out io.Writer) error
|
||||
if len(body) > 0 {
|
||||
err = json.Unmarshal(body, &val)
|
||||
if err != nil {
|
||||
if jsonErr, ok := err.(*json.SyntaxError); ok {
|
||||
return fmt.Errorf("decoding request body: %w, at offset %d", jsonErr, jsonErr.Offset)
|
||||
}
|
||||
return fmt.Errorf("decoding request body: %w", err)
|
||||
return fmt.Errorf("decoding request body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1186,16 +1049,16 @@ func unsyncedConfigAccess(method, path string, body []byte, out io.Writer) error
|
||||
parts = parts[:len(parts)-1]
|
||||
}
|
||||
|
||||
var ptr any = rawCfg
|
||||
var ptr interface{} = rawCfg
|
||||
|
||||
traverseLoop:
|
||||
for i, part := range parts {
|
||||
switch v := ptr.(type) {
|
||||
case map[string]any:
|
||||
case map[string]interface{}:
|
||||
// if the next part enters a slice, and the slice is our destination,
|
||||
// handle it specially (because appending to the slice copies the slice
|
||||
// header, which does not replace the original one like we want)
|
||||
if arr, ok := v[part].([]any); ok && i == len(parts)-2 {
|
||||
if arr, ok := v[part].([]interface{}); ok && i == len(parts)-2 {
|
||||
var idx int
|
||||
if method != http.MethodPost {
|
||||
idxStr := parts[len(parts)-1]
|
||||
@@ -1204,7 +1067,7 @@ traverseLoop:
|
||||
return fmt.Errorf("[%s] invalid array index '%s': %v",
|
||||
path, idxStr, err)
|
||||
}
|
||||
if idx < 0 || (method != http.MethodPut && idx >= len(arr)) || idx > len(arr) {
|
||||
if idx < 0 || idx >= len(arr) {
|
||||
return fmt.Errorf("[%s] array index out of bounds: %s", path, idxStr)
|
||||
}
|
||||
}
|
||||
@@ -1217,7 +1080,7 @@ traverseLoop:
|
||||
}
|
||||
case http.MethodPost:
|
||||
if ellipses {
|
||||
valArray, ok := val.([]any)
|
||||
valArray, ok := val.([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("final element is not an array")
|
||||
}
|
||||
@@ -1252,9 +1115,9 @@ traverseLoop:
|
||||
case http.MethodPost:
|
||||
// if the part is an existing list, POST appends to
|
||||
// it, otherwise it just sets or creates the value
|
||||
if arr, ok := v[part].([]any); ok {
|
||||
if arr, ok := v[part].([]interface{}); ok {
|
||||
if ellipses {
|
||||
valArray, ok := val.([]any)
|
||||
valArray, ok := val.([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("final element is not an array")
|
||||
}
|
||||
@@ -1267,27 +1130,15 @@ traverseLoop:
|
||||
}
|
||||
case http.MethodPut:
|
||||
if _, ok := v[part]; ok {
|
||||
return APIError{
|
||||
HTTPStatus: http.StatusConflict,
|
||||
Err: fmt.Errorf("[%s] key already exists: %s", path, part),
|
||||
}
|
||||
return fmt.Errorf("[%s] key already exists: %s", path, part)
|
||||
}
|
||||
v[part] = val
|
||||
case http.MethodPatch:
|
||||
if _, ok := v[part]; !ok {
|
||||
return APIError{
|
||||
HTTPStatus: http.StatusNotFound,
|
||||
Err: fmt.Errorf("[%s] key does not exist: %s", path, part),
|
||||
}
|
||||
return fmt.Errorf("[%s] key does not exist: %s", path, part)
|
||||
}
|
||||
v[part] = val
|
||||
case http.MethodDelete:
|
||||
if _, ok := v[part]; !ok {
|
||||
return APIError{
|
||||
HTTPStatus: http.StatusNotFound,
|
||||
Err: fmt.Errorf("[%s] key does not exist: %s", path, part),
|
||||
}
|
||||
}
|
||||
delete(v, part)
|
||||
default:
|
||||
return fmt.Errorf("unrecognized method %s", method)
|
||||
@@ -1297,12 +1148,12 @@ traverseLoop:
|
||||
// might not exist yet; that's OK but we need to make them as
|
||||
// we go, while we still have a pointer from the level above
|
||||
if v[part] == nil && method == http.MethodPut {
|
||||
v[part] = make(map[string]any)
|
||||
v[part] = make(map[string]interface{})
|
||||
}
|
||||
ptr = v[part]
|
||||
}
|
||||
|
||||
case []any:
|
||||
case []interface{}:
|
||||
partInt, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return fmt.Errorf("[/%s] invalid array index '%s': %v",
|
||||
@@ -1324,7 +1175,7 @@ traverseLoop:
|
||||
|
||||
// RemoveMetaFields removes meta fields like "@id" from a JSON message
|
||||
// by using a simple regular expression. (An alternate way to do this
|
||||
// would be to delete them from the raw, map[string]any
|
||||
// would be to delete them from the raw, map[string]interface{}
|
||||
// representation as they are indexed, then iterate the index we made
|
||||
// and add them back after encoding as JSON, but this is simpler.)
|
||||
func RemoveMetaFields(rawJSON []byte) []byte {
|
||||
@@ -1376,10 +1227,7 @@ func (e APIError) Error() string {
|
||||
// parseAdminListenAddr extracts a singular listen address from either addr
|
||||
// or defaultAddr, returning the network and the address of the listener.
|
||||
func parseAdminListenAddr(addr string, defaultAddr string) (NetworkAddress, error) {
|
||||
input, err := NewReplacer().ReplaceOrErr(addr, true, true)
|
||||
if err != nil {
|
||||
return NetworkAddress{}, fmt.Errorf("replacing listen address: %v", err)
|
||||
}
|
||||
input := addr
|
||||
if input == "" {
|
||||
input = defaultAddr
|
||||
}
|
||||
@@ -1429,7 +1277,7 @@ var (
|
||||
// will get deleted before the process gracefully exits.
|
||||
func PIDFile(filename string) error {
|
||||
pid := []byte(strconv.Itoa(os.Getpid()) + "\n")
|
||||
err := os.WriteFile(filename, pid, 0o600)
|
||||
err := os.WriteFile(filename, pid, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1459,7 +1307,7 @@ const (
|
||||
)
|
||||
|
||||
var bufPool = sync.Pool{
|
||||
New: func() any {
|
||||
New: func() interface{} {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
+7
-815
@@ -15,22 +15,10 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/caddyserver/certmagic"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
)
|
||||
|
||||
var testCfg = []byte(`{
|
||||
@@ -85,12 +73,6 @@ func TestUnsyncedConfigAccess(t *testing.T) {
|
||||
path: "/bar/qq",
|
||||
expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c"]}`,
|
||||
},
|
||||
{
|
||||
method: "DELETE",
|
||||
path: "/bar/qq",
|
||||
expect: `{"foo": "jet", "bar": {"aa": "bb"}, "list": ["a", "b", "c"]}`,
|
||||
shouldErr: true,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/list",
|
||||
@@ -131,7 +113,7 @@ func TestUnsyncedConfigAccess(t *testing.T) {
|
||||
}
|
||||
|
||||
// decode the expected config so we can do a convenient DeepEqual
|
||||
var expectedDecoded any
|
||||
var expectedDecoded interface{}
|
||||
err = json.Unmarshal([]byte(tc.expect), &expectedDecoded)
|
||||
if err != nil {
|
||||
t.Fatalf("Test %d: Unmarshaling expected config: %v", i, err)
|
||||
@@ -151,808 +133,18 @@ func TestLoadConcurrent(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Go(func() {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
_ = Load(testCfg, true)
|
||||
})
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
type fooModule struct {
|
||||
IntField int
|
||||
StrField string
|
||||
}
|
||||
|
||||
func (fooModule) CaddyModule() ModuleInfo {
|
||||
return ModuleInfo{
|
||||
ID: "foo",
|
||||
New: func() Module { return new(fooModule) },
|
||||
}
|
||||
}
|
||||
func (fooModule) Start() error { return nil }
|
||||
func (fooModule) Stop() error { return nil }
|
||||
|
||||
func TestETags(t *testing.T) {
|
||||
RegisterModule(fooModule{})
|
||||
|
||||
if err := Load([]byte(`{"admin": {"listen": "localhost:2999"}, "apps": {"foo": {"strField": "abc", "intField": 0}}}`), true); err != nil {
|
||||
t.Fatalf("loading: %s", err)
|
||||
}
|
||||
|
||||
const key = "/" + rawConfigKey + "/apps/foo"
|
||||
|
||||
// try update the config with the wrong etag
|
||||
err := changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 1}}`), fmt.Sprintf(`"/%s not_an_etag"`, rawConfigKey), false)
|
||||
if apiErr, ok := err.(APIError); !ok || apiErr.HTTPStatus != http.StatusPreconditionFailed {
|
||||
t.Fatalf("expected precondition failed; got %v", err)
|
||||
}
|
||||
|
||||
// get the etag
|
||||
hash := etagHasher()
|
||||
if err := readConfig(key, hash); err != nil {
|
||||
t.Fatalf("reading: %s", err)
|
||||
}
|
||||
|
||||
// do the same update with the correct key
|
||||
err = changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 1}`), makeEtag(key, hash), false)
|
||||
if err != nil {
|
||||
t.Fatalf("expected update to work; got %v", err)
|
||||
}
|
||||
|
||||
// now try another update. The hash should no longer match and we should get precondition failed
|
||||
err = changeConfig(http.MethodPost, key, []byte(`{"strField": "abc", "intField": 2}`), makeEtag(key, hash), false)
|
||||
if apiErr, ok := err.(APIError); !ok || apiErr.HTTPStatus != http.StatusPreconditionFailed {
|
||||
t.Fatalf("expected precondition failed; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLoad(b *testing.B) {
|
||||
for b.Loop() {
|
||||
for i := 0; i < b.N; i++ {
|
||||
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",
|
||||
},
|
||||
}
|
||||
|
||||
// Build the admin handler directly (no listener active)
|
||||
addr, err := ParseNetworkAddress("localhost:2019")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse address: %v", err)
|
||||
}
|
||||
handler := cfg.Admin.newAdminHandler(addr, false, Context{})
|
||||
|
||||
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()
|
||||
|
||||
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-----`)
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "TestManageIdentity-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testStorage := certmagic.FileStorage{Path: tmpDir}
|
||||
// Clean up the temp dir after the test finishes. Ensure any background
|
||||
// certificate maintenance is stopped first to avoid RemoveAll races.
|
||||
t.Cleanup(func() {
|
||||
if identityCertCache != nil {
|
||||
identityCertCache.Stop()
|
||||
identityCertCache = nil
|
||||
}
|
||||
// Give goroutines a moment to exit and release file handles.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
})
|
||||
|
||||
err = testStorage.Store(context.Background(), "localhost/localhost.crt", certPEM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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: &testStorage,
|
||||
},
|
||||
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
|
||||
}
|
||||
// Ensure any cache started by manageIdentity is stopped at the end
|
||||
defer func() {
|
||||
if identityCertCache != nil {
|
||||
identityCertCache.Stop()
|
||||
identityCertCache = nil
|
||||
}
|
||||
}()
|
||||
|
||||
ctx := Context{
|
||||
Context: context.Background(),
|
||||
cfg: test.cfg,
|
||||
moduleInstances: make(map[string][]Module),
|
||||
}
|
||||
|
||||
// If this test provided a FileStorage, set the package-level
|
||||
// testCertMagicStorageOverride so certmagicConfig will use it.
|
||||
if test.cfg != nil && test.cfg.storage != nil {
|
||||
testCertMagicStorageOverride = test.cfg.storage
|
||||
defer func() { testCertMagicStorageOverride = nil }()
|
||||
}
|
||||
|
||||
err := manageIdentity(ctx, test.cfg)
|
||||
|
||||
if test.wantErr {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -73,21 +72,3 @@ func TestParseDuration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvent_CloudEvent_NilOrigin(t *testing.T) {
|
||||
ctx, _ := NewContext(Context{Context: context.Background()}) // module will be nil by default
|
||||
event, err := NewEvent(ctx, "started", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEvent() error = %v", err)
|
||||
}
|
||||
|
||||
// This should not panic
|
||||
ce := event.CloudEvent()
|
||||
|
||||
if ce.Source != "caddy" {
|
||||
t.Errorf("Expected CloudEvent Source to be 'caddy', got '%s'", ce.Source)
|
||||
}
|
||||
if ce.Type != "started" {
|
||||
t.Errorf("Expected CloudEvent Type to be 'started', got '%s'", ce.Type)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,12 +29,12 @@ type Adapter struct {
|
||||
}
|
||||
|
||||
// Adapt converts the Caddyfile config in body to Caddy JSON.
|
||||
func (a Adapter) Adapt(body []byte, options map[string]any) ([]byte, []caddyconfig.Warning, error) {
|
||||
func (a Adapter) Adapt(body []byte, options map[string]interface{}) ([]byte, []caddyconfig.Warning, error) {
|
||||
if a.ServerType == nil {
|
||||
return nil, nil, fmt.Errorf("no server type")
|
||||
}
|
||||
if options == nil {
|
||||
options = make(map[string]any)
|
||||
options = make(map[string]interface{})
|
||||
}
|
||||
|
||||
filename, _ := options["filename"].(string)
|
||||
@@ -52,9 +52,9 @@ func (a Adapter) Adapt(body []byte, options map[string]any) ([]byte, []caddyconf
|
||||
return nil, warnings, err
|
||||
}
|
||||
|
||||
// lint check: see if input was properly formatted; sometimes messy files parse
|
||||
// lint check: see if input was properly formatted; sometimes messy files files parse
|
||||
// successfully but result in logical errors (the Caddyfile is a bad format, I'm sorry)
|
||||
if warning, different := FormattingDifference(filename, body); different {
|
||||
if warning, different := formattingDifference(filename, body); different {
|
||||
warnings = append(warnings, warning)
|
||||
}
|
||||
|
||||
@@ -63,12 +63,12 @@ func (a Adapter) Adapt(body []byte, options map[string]any) ([]byte, []caddyconf
|
||||
return result, warnings, err
|
||||
}
|
||||
|
||||
// FormattingDifference returns a warning and true if the formatted version
|
||||
// formattingDifference returns a warning and true if the formatted version
|
||||
// is any different from the input; empty warning and false otherwise.
|
||||
// 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
|
||||
normalizedBody := bytes.ReplaceAll(body, []byte("\r\n"), []byte("\n"))
|
||||
normalizedBody := bytes.Replace(body, []byte("\r\n"), []byte("\n"), -1)
|
||||
|
||||
formatted := Format(normalizedBody)
|
||||
if bytes.Equal(formatted, normalizedBody) {
|
||||
@@ -88,31 +88,35 @@ func FormattingDifference(filename string, body []byte) (caddyconfig.Warning, bo
|
||||
return caddyconfig.Warning{
|
||||
File: filename,
|
||||
Line: line,
|
||||
Message: "Caddyfile input is not formatted; run 'caddy fmt --overwrite' to fix inconsistencies",
|
||||
Message: "Caddyfile input is not formatted; run the 'caddy fmt' command to fix inconsistencies",
|
||||
}, true
|
||||
}
|
||||
|
||||
// Unmarshaler is a type that can unmarshal Caddyfile tokens to
|
||||
// set itself up for a JSON encoding. The goal of an unmarshaler
|
||||
// is not to set itself up for actual use, but to set itself up for
|
||||
// being marshaled into JSON. Caddyfile-unmarshaled values will not
|
||||
// be used directly; they will be encoded as JSON and then used from
|
||||
// that. Implementations _may_ be able to support multiple segments
|
||||
// (instances of their directive or batch of tokens); typically this
|
||||
// means wrapping parsing logic in a loop: `for d.Next() { ... }`.
|
||||
// More commonly, only a single segment is supported, so a simple
|
||||
// `d.Next()` at the start should be used to consume the module
|
||||
// identifier token (directive name, etc).
|
||||
// Unmarshaler is a type that can unmarshal
|
||||
// Caddyfile tokens to set itself up for a
|
||||
// JSON encoding. The goal of an unmarshaler
|
||||
// is not to set itself up for actual use,
|
||||
// but to set itself up for being marshaled
|
||||
// into JSON. Caddyfile-unmarshaled values
|
||||
// will not be used directly; they will be
|
||||
// encoded as JSON and then used from that.
|
||||
// Implementations must be able to support
|
||||
// multiple segments (instances of their
|
||||
// directive or batch of tokens); typically
|
||||
// this means wrapping all token logic in
|
||||
// a loop: `for d.Next() { ... }`.
|
||||
type Unmarshaler interface {
|
||||
UnmarshalCaddyfile(d *Dispenser) error
|
||||
}
|
||||
|
||||
// ServerType is a type that can evaluate a Caddyfile and set up a caddy config.
|
||||
type ServerType interface {
|
||||
// Setup takes the server blocks which contain tokens,
|
||||
// as well as options (e.g. CLI flags) and creates a
|
||||
// Caddy config, along with any warnings or an error.
|
||||
Setup([]ServerBlock, map[string]any) (*caddy.Config, []caddyconfig.Warning, error)
|
||||
// Setup takes the server blocks which
|
||||
// contain tokens, as well as options
|
||||
// (e.g. CLI flags) and creates a Caddy
|
||||
// config, along with any warnings or
|
||||
// an error.
|
||||
Setup([]ServerBlock, map[string]interface{}) (*caddy.Config, []caddyconfig.Warning, error)
|
||||
}
|
||||
|
||||
// UnmarshalModule instantiates a module with the given ID and invokes
|
||||
|
||||
@@ -30,10 +30,6 @@ type Dispenser struct {
|
||||
tokens []Token
|
||||
cursor int
|
||||
nesting int
|
||||
|
||||
// A map of arbitrary context data that can be used
|
||||
// to pass through some information to unmarshalers.
|
||||
context map[string]any
|
||||
}
|
||||
|
||||
// NewDispenser returns a Dispenser filled with the given tokens.
|
||||
@@ -105,12 +101,12 @@ func (d *Dispenser) nextOnSameLine() bool {
|
||||
d.cursor++
|
||||
return true
|
||||
}
|
||||
if d.cursor >= len(d.tokens)-1 {
|
||||
if d.cursor >= len(d.tokens) {
|
||||
return false
|
||||
}
|
||||
curr := d.tokens[d.cursor]
|
||||
next := d.tokens[d.cursor+1]
|
||||
if !isNextOnNewLine(curr, next) {
|
||||
if d.cursor < len(d.tokens)-1 &&
|
||||
d.tokens[d.cursor].File == d.tokens[d.cursor+1].File &&
|
||||
d.tokens[d.cursor].Line+d.numLineBreaks(d.cursor) == d.tokens[d.cursor+1].Line {
|
||||
d.cursor++
|
||||
return true
|
||||
}
|
||||
@@ -126,12 +122,12 @@ func (d *Dispenser) NextLine() bool {
|
||||
d.cursor++
|
||||
return true
|
||||
}
|
||||
if d.cursor >= len(d.tokens)-1 {
|
||||
if d.cursor >= len(d.tokens) {
|
||||
return false
|
||||
}
|
||||
curr := d.tokens[d.cursor]
|
||||
next := d.tokens[d.cursor+1]
|
||||
if isNextOnNewLine(curr, next) {
|
||||
if d.cursor < len(d.tokens)-1 &&
|
||||
(d.tokens[d.cursor].File != d.tokens[d.cursor+1].File ||
|
||||
d.tokens[d.cursor].Line+d.numLineBreaks(d.cursor) < d.tokens[d.cursor+1].Line) {
|
||||
d.cursor++
|
||||
return true
|
||||
}
|
||||
@@ -150,15 +146,15 @@ func (d *Dispenser) NextLine() bool {
|
||||
//
|
||||
// Proper use of this method looks like this:
|
||||
//
|
||||
// for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
// }
|
||||
// for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
// }
|
||||
//
|
||||
// However, in simple cases where it is known that the
|
||||
// Dispenser is new and has not already traversed state
|
||||
// by a loop over NextBlock(), this will do:
|
||||
//
|
||||
// for d.NextBlock(0) {
|
||||
// }
|
||||
// for d.NextBlock(0) {
|
||||
// }
|
||||
//
|
||||
// As with other token parsing logic, a loop over
|
||||
// NextBlock() should be contained within a loop over
|
||||
@@ -207,24 +203,21 @@ func (d *Dispenser) Val() string {
|
||||
}
|
||||
|
||||
// ValRaw gets the raw text of the current token (including quotes).
|
||||
// If the token was a heredoc, then the delimiter is not included,
|
||||
// because that is not relevant to any unmarshaling logic at this time.
|
||||
// If there is no token loaded, it returns empty string.
|
||||
func (d *Dispenser) ValRaw() string {
|
||||
if d.cursor < 0 || d.cursor >= len(d.tokens) {
|
||||
return ""
|
||||
}
|
||||
quote := d.tokens[d.cursor].wasQuoted
|
||||
if quote > 0 && quote != '<' {
|
||||
// string literal
|
||||
return string(quote) + d.tokens[d.cursor].Text + string(quote)
|
||||
if quote > 0 {
|
||||
return string(quote) + d.tokens[d.cursor].Text + string(quote) // string literal
|
||||
}
|
||||
return d.tokens[d.cursor].Text
|
||||
}
|
||||
|
||||
// ScalarVal gets value of the current token, converted to the closest
|
||||
// scalar type. If there is no token loaded, it returns nil.
|
||||
func (d *Dispenser) ScalarVal() any {
|
||||
func (d *Dispenser) ScalarVal() interface{} {
|
||||
if d.cursor < 0 || d.cursor >= len(d.tokens) {
|
||||
return nil
|
||||
}
|
||||
@@ -308,9 +301,9 @@ func (d *Dispenser) CountRemainingArgs() int {
|
||||
}
|
||||
|
||||
// RemainingArgs loads any more arguments (tokens on the same line)
|
||||
// into a slice of strings and returns them. Open curly brace tokens
|
||||
// also indicate the end of arguments, and the curly brace is not
|
||||
// included in the return value nor is it loaded.
|
||||
// into a slice and returns them. Open curly brace tokens also indicate
|
||||
// the end of arguments, and the curly brace is not included in
|
||||
// the return value nor is it loaded.
|
||||
func (d *Dispenser) RemainingArgs() []string {
|
||||
var args []string
|
||||
for d.NextArg() {
|
||||
@@ -320,9 +313,9 @@ func (d *Dispenser) RemainingArgs() []string {
|
||||
}
|
||||
|
||||
// RemainingArgsRaw loads any more arguments (tokens on the same line,
|
||||
// retaining quotes) into a slice of strings and returns them.
|
||||
// Open curly brace tokens also indicate the end of arguments,
|
||||
// and the curly brace is not included in the return value nor is it loaded.
|
||||
// retaining quotes) into a slice and returns them. Open curly brace
|
||||
// tokens also indicate the end of arguments, and the curly brace is
|
||||
// not included in the return value nor is it loaded.
|
||||
func (d *Dispenser) RemainingArgsRaw() []string {
|
||||
var args []string
|
||||
for d.NextArg() {
|
||||
@@ -331,18 +324,6 @@ func (d *Dispenser) RemainingArgsRaw() []string {
|
||||
return args
|
||||
}
|
||||
|
||||
// RemainingArgsAsTokens loads any more arguments (tokens on the same line)
|
||||
// into a slice of Token-structs and returns them. Open curly brace tokens
|
||||
// also indicate the end of arguments, and the curly brace is not included
|
||||
// in the return value nor is it loaded.
|
||||
func (d *Dispenser) RemainingArgsAsTokens() []Token {
|
||||
var args []Token
|
||||
for d.NextArg() {
|
||||
args = append(args, d.Token())
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// NewFromNextSegment returns a new dispenser with a copy of
|
||||
// the tokens from the current token until the end of the
|
||||
// "directive" whether that be to the end of the line or
|
||||
@@ -407,40 +388,37 @@ func (d *Dispenser) Reset() {
|
||||
// an argument.
|
||||
func (d *Dispenser) ArgErr() error {
|
||||
if d.Val() == "{" {
|
||||
return d.Err("unexpected token '{', expecting argument")
|
||||
return d.Err("Unexpected token '{', expecting argument")
|
||||
}
|
||||
return d.Errf("wrong argument count or unexpected line ending after '%s'", d.Val())
|
||||
return d.Errf("Wrong argument count or unexpected line ending after '%s'", d.Val())
|
||||
}
|
||||
|
||||
// SyntaxErr creates a generic syntax error which explains what was
|
||||
// found and what was expected.
|
||||
func (d *Dispenser) SyntaxErr(expected string) error {
|
||||
msg := fmt.Sprintf("syntax error: unexpected token '%s', expecting '%s', at %s:%d import chain: ['%s']", d.Val(), expected, d.File(), d.Line(), strings.Join(d.Token().imports, "','"))
|
||||
msg := fmt.Sprintf("%s:%d - Syntax error: Unexpected token '%s', expecting '%s'", d.File(), d.Line(), d.Val(), expected)
|
||||
return errors.New(msg)
|
||||
}
|
||||
|
||||
// EOFErr returns an error indicating that the dispenser reached
|
||||
// the end of the input when searching for the next token.
|
||||
func (d *Dispenser) EOFErr() error {
|
||||
return d.Errf("unexpected EOF")
|
||||
return d.Errf("Unexpected EOF")
|
||||
}
|
||||
|
||||
// Err generates a custom parse-time error with a message of msg.
|
||||
func (d *Dispenser) Err(msg string) error {
|
||||
return d.WrapErr(errors.New(msg))
|
||||
return d.Errf(msg)
|
||||
}
|
||||
|
||||
// Errf is like Err, but for formatted error messages
|
||||
func (d *Dispenser) Errf(format string, args ...any) error {
|
||||
func (d *Dispenser) Errf(format string, args ...interface{}) error {
|
||||
return d.WrapErr(fmt.Errorf(format, args...))
|
||||
}
|
||||
|
||||
// WrapErr takes an existing error and adds the Caddyfile file and line number.
|
||||
func (d *Dispenser) WrapErr(err error) error {
|
||||
if len(d.Token().imports) > 0 {
|
||||
return fmt.Errorf("%w, at %s:%d import chain ['%s']", err, d.File(), d.Line(), strings.Join(d.Token().imports, "','"))
|
||||
}
|
||||
return fmt.Errorf("%w, at %s:%d", err, d.File(), d.Line())
|
||||
return fmt.Errorf("%s:%d - Error during parsing: %w", d.File(), d.Line(), err)
|
||||
}
|
||||
|
||||
// Delete deletes the current token and returns the updated slice
|
||||
@@ -460,42 +438,14 @@ func (d *Dispenser) Delete() []Token {
|
||||
return d.tokens
|
||||
}
|
||||
|
||||
// DeleteN is the same as Delete, but can delete many tokens at once.
|
||||
// If there aren't N tokens available to delete, none are deleted.
|
||||
func (d *Dispenser) DeleteN(amount int) []Token {
|
||||
if amount > 0 && d.cursor >= (amount-1) && d.cursor <= len(d.tokens)-1 {
|
||||
d.tokens = append(d.tokens[:d.cursor-(amount-1)], d.tokens[d.cursor+1:]...)
|
||||
d.cursor -= amount
|
||||
// numLineBreaks counts how many line breaks are in the token
|
||||
// value given by the token index tknIdx. It returns 0 if the
|
||||
// token does not exist or there are no line breaks.
|
||||
func (d *Dispenser) numLineBreaks(tknIdx int) int {
|
||||
if tknIdx < 0 || tknIdx >= len(d.tokens) {
|
||||
return 0
|
||||
}
|
||||
return d.tokens
|
||||
}
|
||||
|
||||
// SetContext sets a key-value pair in the context map.
|
||||
func (d *Dispenser) SetContext(key string, value any) {
|
||||
if d.context == nil {
|
||||
d.context = make(map[string]any)
|
||||
}
|
||||
d.context[key] = value
|
||||
}
|
||||
|
||||
// GetContext gets the value of a key in the context map.
|
||||
func (d *Dispenser) GetContext(key string) any {
|
||||
if d.context == nil {
|
||||
return nil
|
||||
}
|
||||
return d.context[key]
|
||||
}
|
||||
|
||||
// GetContextString gets the value of a key in the context map
|
||||
// as a string, or an empty string if the key does not exist.
|
||||
func (d *Dispenser) GetContextString(key string) string {
|
||||
if d.context == nil {
|
||||
return ""
|
||||
}
|
||||
if val, ok := d.context[key].(string); ok {
|
||||
return val
|
||||
}
|
||||
return ""
|
||||
return strings.Count(d.tokens[tknIdx].Text, "\n")
|
||||
}
|
||||
|
||||
// isNewLine determines whether the current token is on a different
|
||||
@@ -511,7 +461,25 @@ func (d *Dispenser) isNewLine() bool {
|
||||
|
||||
prev := d.tokens[d.cursor-1]
|
||||
curr := d.tokens[d.cursor]
|
||||
return isNextOnNewLine(prev, curr)
|
||||
|
||||
// If the previous token is from a different file,
|
||||
// we can assume it's from a different line
|
||||
if prev.File != curr.File {
|
||||
return true
|
||||
}
|
||||
|
||||
// The previous token may contain line breaks if
|
||||
// it was quoted and spanned multiple lines. e.g:
|
||||
//
|
||||
// dir "foo
|
||||
// bar
|
||||
// baz"
|
||||
prevLineBreaks := d.numLineBreaks(d.cursor - 1)
|
||||
|
||||
// If the previous token (incl line breaks) ends
|
||||
// on a line earlier than the current token,
|
||||
// then the current token is on a new line
|
||||
return prev.Line+prevLineBreaks < curr.Line
|
||||
}
|
||||
|
||||
// isNextOnNewLine determines whether the current token is on a different
|
||||
@@ -527,7 +495,23 @@ func (d *Dispenser) isNextOnNewLine() bool {
|
||||
|
||||
curr := d.tokens[d.cursor]
|
||||
next := d.tokens[d.cursor+1]
|
||||
return isNextOnNewLine(curr, next)
|
||||
}
|
||||
|
||||
const MatcherNameCtxKey = "matcher_name"
|
||||
// If the next token is from a different file,
|
||||
// we can assume it's from a different line
|
||||
if curr.File != next.File {
|
||||
return true
|
||||
}
|
||||
|
||||
// The current token may contain line breaks if
|
||||
// it was quoted and spanned multiple lines. e.g:
|
||||
//
|
||||
// dir "foo
|
||||
// bar
|
||||
// baz"
|
||||
currLineBreaks := d.numLineBreaks(d.cursor)
|
||||
|
||||
// If the current token (incl line breaks) ends
|
||||
// on a line earlier than the next token,
|
||||
// then the next token is on a new line
|
||||
return curr.Line+currLineBreaks < next.Line
|
||||
}
|
||||
|
||||
@@ -274,66 +274,6 @@ func TestDispenser_RemainingArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispenser_RemainingArgsAsTokens(t *testing.T) {
|
||||
input := `dir1 arg1 arg2 arg3
|
||||
dir2 arg4 arg5
|
||||
dir3 arg6 { arg7
|
||||
dir4`
|
||||
d := NewTestDispenser(input)
|
||||
|
||||
d.Next() // dir1
|
||||
|
||||
args := d.RemainingArgsAsTokens()
|
||||
|
||||
tokenTexts := make([]string, 0, len(args))
|
||||
for _, arg := range args {
|
||||
tokenTexts = append(tokenTexts, arg.Text)
|
||||
}
|
||||
|
||||
if expected := []string{"arg1", "arg2", "arg3"}; !reflect.DeepEqual(tokenTexts, expected) {
|
||||
t.Errorf("RemainingArgsAsTokens(): Expected %v, got %v", expected, tokenTexts)
|
||||
}
|
||||
|
||||
d.Next() // dir2
|
||||
|
||||
args = d.RemainingArgsAsTokens()
|
||||
|
||||
tokenTexts = tokenTexts[:0]
|
||||
for _, arg := range args {
|
||||
tokenTexts = append(tokenTexts, arg.Text)
|
||||
}
|
||||
|
||||
if expected := []string{"arg4", "arg5"}; !reflect.DeepEqual(tokenTexts, expected) {
|
||||
t.Errorf("RemainingArgsAsTokens(): Expected %v, got %v", expected, tokenTexts)
|
||||
}
|
||||
|
||||
d.Next() // dir3
|
||||
|
||||
args = d.RemainingArgsAsTokens()
|
||||
tokenTexts = tokenTexts[:0]
|
||||
for _, arg := range args {
|
||||
tokenTexts = append(tokenTexts, arg.Text)
|
||||
}
|
||||
|
||||
if expected := []string{"arg6"}; !reflect.DeepEqual(tokenTexts, expected) {
|
||||
t.Errorf("RemainingArgsAsTokens(): Expected %v, got %v", expected, tokenTexts)
|
||||
}
|
||||
|
||||
d.Next() // {
|
||||
d.Next() // arg7
|
||||
d.Next() // dir4
|
||||
|
||||
args = d.RemainingArgsAsTokens()
|
||||
tokenTexts = tokenTexts[:0]
|
||||
for _, arg := range args {
|
||||
tokenTexts = append(tokenTexts, arg.Text)
|
||||
}
|
||||
|
||||
if len(args) != 0 {
|
||||
t.Errorf("RemainingArgsAsTokens(): Expected %v, got %v", []string{}, tokenTexts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispenser_ArgErr_Err(t *testing.T) {
|
||||
input := `dir1 {
|
||||
}
|
||||
@@ -365,7 +305,7 @@ func TestDispenser_ArgErr_Err(t *testing.T) {
|
||||
t.Errorf("Expected error message with custom message in it ('foobar'); got '%v'", err)
|
||||
}
|
||||
|
||||
ErrBarIsFull := errors.New("bar is full")
|
||||
var ErrBarIsFull = errors.New("bar is full")
|
||||
bookingError := d.Errf("unable to reserve: %w", ErrBarIsFull)
|
||||
if !errors.Is(bookingError, ErrBarIsFull) {
|
||||
t.Errorf("Errf(): should be able to unwrap the error chain")
|
||||
|
||||
@@ -17,8 +17,6 @@ package caddyfile
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
@@ -33,14 +31,6 @@ func Format(input []byte) []byte {
|
||||
out := new(bytes.Buffer)
|
||||
rdr := bytes.NewReader(input)
|
||||
|
||||
type heredocState int
|
||||
|
||||
const (
|
||||
heredocClosed heredocState = 0
|
||||
heredocOpening heredocState = 1
|
||||
heredocOpened heredocState = 2
|
||||
)
|
||||
|
||||
var (
|
||||
last rune // the last character that was written to the result
|
||||
|
||||
@@ -53,14 +43,9 @@ func Format(input []byte) []byte {
|
||||
|
||||
newLines int // count of newlines consumed
|
||||
|
||||
comment bool // whether we're in a comment
|
||||
quotes string // encountered quotes ('', '`', '"', '"`', '`"')
|
||||
escaped bool // whether current char is escaped
|
||||
|
||||
heredoc heredocState // whether we're in a heredoc
|
||||
heredocEscaped bool // whether heredoc is escaped
|
||||
heredocMarker []rune
|
||||
heredocClosingMarker []rune
|
||||
comment bool // whether we're in a comment
|
||||
quoted bool // whether we're in a quoted segment
|
||||
escaped bool // whether current char is escaped
|
||||
|
||||
nesting int // indentation level
|
||||
)
|
||||
@@ -89,61 +74,6 @@ func Format(input []byte) []byte {
|
||||
}
|
||||
panic(err)
|
||||
}
|
||||
// detect whether we have the start of a heredoc
|
||||
if quotes == "" && (heredoc == heredocClosed && !heredocEscaped) &&
|
||||
space && last == '<' && ch == '<' {
|
||||
write(ch)
|
||||
heredoc = heredocOpening
|
||||
space = false
|
||||
continue
|
||||
}
|
||||
|
||||
if heredoc == heredocOpening {
|
||||
if ch == '\n' {
|
||||
if len(heredocMarker) > 0 && heredocMarkerRegexp.MatchString(string(heredocMarker)) {
|
||||
heredoc = heredocOpened
|
||||
} else {
|
||||
heredocMarker = nil
|
||||
heredoc = heredocClosed
|
||||
nextLine()
|
||||
continue
|
||||
}
|
||||
write(ch)
|
||||
continue
|
||||
}
|
||||
if unicode.IsSpace(ch) {
|
||||
// a space means it's just a regular token and not a heredoc
|
||||
heredocMarker = nil
|
||||
heredoc = heredocClosed
|
||||
} else {
|
||||
heredocMarker = append(heredocMarker, ch)
|
||||
write(ch)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// if we're in a heredoc, all characters are read&write as-is
|
||||
if heredoc == heredocOpened {
|
||||
heredocClosingMarker = append(heredocClosingMarker, ch)
|
||||
if len(heredocClosingMarker) > len(heredocMarker)+1 { // We assert that the heredocClosingMarker is followed by a unicode.Space
|
||||
heredocClosingMarker = heredocClosingMarker[1:]
|
||||
}
|
||||
// check if we're done
|
||||
if unicode.IsSpace(ch) && slices.Equal(heredocClosingMarker[:len(heredocClosingMarker)-1], heredocMarker) {
|
||||
heredocMarker = nil
|
||||
heredocClosingMarker = nil
|
||||
heredoc = heredocClosed
|
||||
} else {
|
||||
write(ch)
|
||||
if ch == '\n' {
|
||||
heredocClosingMarker = heredocClosingMarker[:0]
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if last == '<' && space {
|
||||
space = false
|
||||
}
|
||||
|
||||
if comment {
|
||||
if ch == '\n' {
|
||||
@@ -168,60 +98,25 @@ func Format(input []byte) []byte {
|
||||
}
|
||||
|
||||
if escaped {
|
||||
if ch == '<' {
|
||||
heredocEscaped = true
|
||||
}
|
||||
write(ch)
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == '`' {
|
||||
switch quotes {
|
||||
case "\"`":
|
||||
quotes = "\""
|
||||
case "`":
|
||||
quotes = ""
|
||||
case "\"":
|
||||
quotes = "\"`"
|
||||
default:
|
||||
quotes = "`"
|
||||
}
|
||||
}
|
||||
|
||||
if quotes == "\"" {
|
||||
if quoted {
|
||||
if ch == '"' {
|
||||
quotes = ""
|
||||
quoted = false
|
||||
}
|
||||
write(ch)
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == '"' {
|
||||
switch quotes {
|
||||
case "":
|
||||
if space {
|
||||
quotes = "\""
|
||||
}
|
||||
case "`\"":
|
||||
quotes = "`"
|
||||
case "\"`":
|
||||
quotes = ""
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(quotes, "`") {
|
||||
if ch == '`' && space && !beginningOfLine {
|
||||
write(' ')
|
||||
}
|
||||
write(ch)
|
||||
space = false
|
||||
continue
|
||||
if space && ch == '"' {
|
||||
quoted = true
|
||||
}
|
||||
|
||||
if unicode.IsSpace(ch) {
|
||||
space = true
|
||||
heredocEscaped = false
|
||||
if ch == '\n' {
|
||||
newLines++
|
||||
}
|
||||
@@ -251,39 +146,27 @@ func Format(input []byte) []byte {
|
||||
openBrace = false
|
||||
if beginningOfLine {
|
||||
indent()
|
||||
} else if !openBraceSpace || !unicode.IsSpace(last) {
|
||||
} else if !openBraceSpace {
|
||||
write(' ')
|
||||
}
|
||||
write('{')
|
||||
openBraceWritten = true
|
||||
nextLine()
|
||||
newLines = 0
|
||||
// prevent infinite nesting from ridiculous inputs (issue #4169)
|
||||
if nesting < 10 {
|
||||
nesting++
|
||||
}
|
||||
nesting++
|
||||
}
|
||||
|
||||
switch {
|
||||
case ch == '{':
|
||||
openBrace = true
|
||||
openBraceSpace = spacePrior && !beginningOfLine
|
||||
if openBraceSpace && newLines == 0 {
|
||||
write(' ')
|
||||
}
|
||||
openBraceWritten = false
|
||||
if quotes == "`" {
|
||||
write('{')
|
||||
openBraceWritten = true
|
||||
continue
|
||||
openBraceSpace = spacePrior && !beginningOfLine
|
||||
if openBraceSpace {
|
||||
write(' ')
|
||||
}
|
||||
continue
|
||||
|
||||
case ch == '}' && (spacePrior || !openBrace):
|
||||
if quotes == "`" {
|
||||
write('}')
|
||||
continue
|
||||
}
|
||||
if last != '\n' {
|
||||
nextLine()
|
||||
}
|
||||
@@ -319,11 +202,6 @@ func Format(input []byte) []byte {
|
||||
write('{')
|
||||
openBraceWritten = true
|
||||
}
|
||||
|
||||
if spacePrior && ch == '<' {
|
||||
space = true
|
||||
}
|
||||
|
||||
write(ch)
|
||||
|
||||
beginningOfLine = false
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
//go:build gofuzz
|
||||
// +build gofuzz
|
||||
|
||||
package caddyfile
|
||||
|
||||
|
||||
@@ -364,117 +364,6 @@ block {
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
description: "keep heredoc as-is",
|
||||
input: `block {
|
||||
heredoc <<HEREDOC
|
||||
Here's more than one space Here's more than one space
|
||||
HEREDOC
|
||||
}
|
||||
`,
|
||||
expect: `block {
|
||||
heredoc <<HEREDOC
|
||||
Here's more than one space Here's more than one space
|
||||
HEREDOC
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
description: "Mixing heredoc with regular part",
|
||||
input: `block {
|
||||
heredoc <<HEREDOC
|
||||
Here's more than one space Here's more than one space
|
||||
HEREDOC
|
||||
respond "More than one space will be eaten" 200
|
||||
}
|
||||
|
||||
block2 {
|
||||
heredoc <<HEREDOC
|
||||
Here's more than one space Here's more than one space
|
||||
HEREDOC
|
||||
respond "More than one space will be eaten" 200
|
||||
}
|
||||
`,
|
||||
expect: `block {
|
||||
heredoc <<HEREDOC
|
||||
Here's more than one space Here's more than one space
|
||||
HEREDOC
|
||||
respond "More than one space will be eaten" 200
|
||||
}
|
||||
|
||||
block2 {
|
||||
heredoc <<HEREDOC
|
||||
Here's more than one space Here's more than one space
|
||||
HEREDOC
|
||||
respond "More than one space will be eaten" 200
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
description: "Heredoc as regular token",
|
||||
input: `block {
|
||||
heredoc <<HEREDOC "More than one space will be eaten"
|
||||
}
|
||||
`,
|
||||
expect: `block {
|
||||
heredoc <<HEREDOC "More than one space will be eaten"
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
description: "Escape heredoc",
|
||||
input: `block {
|
||||
heredoc \<<HEREDOC
|
||||
respond "More than one space will be eaten" 200
|
||||
}
|
||||
`,
|
||||
expect: `block {
|
||||
heredoc \<<HEREDOC
|
||||
respond "More than one space will be eaten" 200
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
description: "Preserve braces wrapped by backquotes",
|
||||
input: "block {respond `All braces should remain: {{now | date \"2006\"}}`}",
|
||||
expect: "block {respond `All braces should remain: {{now | date \"2006\"}}`}",
|
||||
},
|
||||
{
|
||||
description: "Preserve braces wrapped by quotes",
|
||||
input: "block {respond \"All braces should remain: {{now | date `2006`}}\"}",
|
||||
expect: "block {respond \"All braces should remain: {{now | date `2006`}}\"}",
|
||||
},
|
||||
{
|
||||
description: "Preserve quoted backticks and backticked quotes",
|
||||
input: "block { respond \"`\" } block { respond `\"`}",
|
||||
expect: "block {\n\trespond \"`\"\n}\n\nblock {\n\trespond `\"`\n}",
|
||||
},
|
||||
{
|
||||
description: "No trailing space on line before env variable",
|
||||
input: `{
|
||||
a
|
||||
|
||||
{$ENV_VAR}
|
||||
}
|
||||
`,
|
||||
expect: `{
|
||||
a
|
||||
|
||||
{$ENV_VAR}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
description: "issue #7425: multiline backticked string indentation",
|
||||
input: `https://localhost:8953 {
|
||||
respond ` + "`" + `Here are some random numbers:
|
||||
|
||||
{{randNumeric 16}}
|
||||
|
||||
Hope this helps.` + "`" + `
|
||||
}`,
|
||||
expect: "https://localhost:8953 {\n\trespond `Here are some random numbers:\n\n{{randNumeric 16}}\n\nHope this helps.`\n}",
|
||||
},
|
||||
} {
|
||||
// the formatter should output a trailing newline,
|
||||
// even if the tests aren't written to expect that
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
// Copyright 2015 Matthew Holt and The Caddy Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package caddyfile
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
)
|
||||
|
||||
// parseVariadic determines if the token is a variadic placeholder,
|
||||
// and if so, determines the index range (start/end) of args to use.
|
||||
// Returns a boolean signaling whether a variadic placeholder was found,
|
||||
// and the start and end indices.
|
||||
func parseVariadic(token Token, argCount int) (bool, int, int) {
|
||||
if !strings.HasPrefix(token.Text, "{args[") {
|
||||
return false, 0, 0
|
||||
}
|
||||
if !strings.HasSuffix(token.Text, "]}") {
|
||||
return false, 0, 0
|
||||
}
|
||||
|
||||
argRange := strings.TrimSuffix(strings.TrimPrefix(token.Text, "{args["), "]}")
|
||||
if argRange == "" {
|
||||
caddy.Log().Named("caddyfile").Warn(
|
||||
"Placeholder "+token.Text+" cannot have an empty index",
|
||||
zap.String("file", token.File+":"+strconv.Itoa(token.Line)), zap.Strings("import_chain", token.imports))
|
||||
return false, 0, 0
|
||||
}
|
||||
|
||||
start, end, found := strings.Cut(argRange, ":")
|
||||
|
||||
// If no ":" delimiter is found, this is not a variadic.
|
||||
// The replacer will pick this up.
|
||||
if !found {
|
||||
return false, 0, 0
|
||||
}
|
||||
|
||||
// A valid token may contain several placeholders, and
|
||||
// they may be separated by ":". It's not variadic.
|
||||
// https://github.com/caddyserver/caddy/issues/5716
|
||||
if strings.Contains(start, "}") || strings.Contains(end, "{") {
|
||||
return false, 0, 0
|
||||
}
|
||||
|
||||
var (
|
||||
startIndex = 0
|
||||
endIndex = argCount
|
||||
err error
|
||||
)
|
||||
if start != "" {
|
||||
startIndex, err = strconv.Atoi(start)
|
||||
if err != nil {
|
||||
caddy.Log().Named("caddyfile").Warn(
|
||||
"Variadic placeholder "+token.Text+" has an invalid start index",
|
||||
zap.String("file", token.File+":"+strconv.Itoa(token.Line)), zap.Strings("import_chain", token.imports))
|
||||
return false, 0, 0
|
||||
}
|
||||
}
|
||||
if end != "" {
|
||||
endIndex, err = strconv.Atoi(end)
|
||||
if err != nil {
|
||||
caddy.Log().Named("caddyfile").Warn(
|
||||
"Variadic placeholder "+token.Text+" has an invalid end index",
|
||||
zap.String("file", token.File+":"+strconv.Itoa(token.Line)), zap.Strings("import_chain", token.imports))
|
||||
return false, 0, 0
|
||||
}
|
||||
}
|
||||
|
||||
// bound check
|
||||
if startIndex < 0 || startIndex > endIndex || endIndex > argCount {
|
||||
caddy.Log().Named("caddyfile").Warn(
|
||||
"Variadic placeholder "+token.Text+" indices are out of bounds, only "+strconv.Itoa(argCount)+" argument(s) exist",
|
||||
zap.String("file", token.File+":"+strconv.Itoa(token.Line)), zap.Strings("import_chain", token.imports))
|
||||
return false, 0, 0
|
||||
}
|
||||
return true, startIndex, endIndex
|
||||
}
|
||||
|
||||
// makeArgsReplacer prepares a Replacer which can replace
|
||||
// non-variadic args placeholders in imported tokens.
|
||||
func makeArgsReplacer(args []string) *caddy.Replacer {
|
||||
repl := caddy.NewEmptyReplacer()
|
||||
repl.Map(func(key string) (any, bool) {
|
||||
// TODO: Remove the deprecated {args.*} placeholder
|
||||
// support at some point in the future
|
||||
if matches := argsRegexpIndexDeprecated.FindStringSubmatch(key); len(matches) > 0 {
|
||||
// What's matched may be a substring of the key
|
||||
if matches[0] != key {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(matches[1])
|
||||
if err != nil {
|
||||
caddy.Log().Named("caddyfile").Warn(
|
||||
"Placeholder {args." + matches[1] + "} has an invalid index")
|
||||
return nil, false
|
||||
}
|
||||
if value >= len(args) {
|
||||
caddy.Log().Named("caddyfile").Warn(
|
||||
"Placeholder {args." + matches[1] + "} index is out of bounds, only " + strconv.Itoa(len(args)) + " argument(s) exist")
|
||||
return nil, false
|
||||
}
|
||||
caddy.Log().Named("caddyfile").Warn(
|
||||
"Placeholder {args." + matches[1] + "} deprecated, use {args[" + matches[1] + "]} instead")
|
||||
return args[value], true
|
||||
}
|
||||
|
||||
// Handle args[*] form
|
||||
if matches := argsRegexpIndex.FindStringSubmatch(key); len(matches) > 0 {
|
||||
// What's matched may be a substring of the key
|
||||
if matches[0] != key {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if strings.Contains(matches[1], ":") {
|
||||
caddy.Log().Named("caddyfile").Warn(
|
||||
"Variadic placeholder {args[" + matches[1] + "]} must be a token on its own")
|
||||
return nil, false
|
||||
}
|
||||
value, err := strconv.Atoi(matches[1])
|
||||
if err != nil {
|
||||
caddy.Log().Named("caddyfile").Warn(
|
||||
"Placeholder {args[" + matches[1] + "]} has an invalid index")
|
||||
return nil, false
|
||||
}
|
||||
if value >= len(args) {
|
||||
caddy.Log().Named("caddyfile").Warn(
|
||||
"Placeholder {args[" + matches[1] + "]} index is out of bounds, only " + strconv.Itoa(len(args)) + " argument(s) exist")
|
||||
return nil, false
|
||||
}
|
||||
return args[value], true
|
||||
}
|
||||
|
||||
// Not an args placeholder, ignore
|
||||
return nil, false
|
||||
})
|
||||
return repl
|
||||
}
|
||||
|
||||
var (
|
||||
argsRegexpIndexDeprecated = regexp.MustCompile(`args\.(.+)`)
|
||||
argsRegexpIndex = regexp.MustCompile(`args\[(.+)]`)
|
||||
)
|
||||
@@ -16,26 +16,24 @@ package caddyfile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type adjacency map[string][]string
|
||||
|
||||
type importGraph struct {
|
||||
nodes map[string]struct{}
|
||||
nodes map[string]bool
|
||||
edges adjacency
|
||||
}
|
||||
|
||||
func (i *importGraph) addNode(name string) {
|
||||
if i.nodes == nil {
|
||||
i.nodes = make(map[string]struct{})
|
||||
i.nodes = make(map[string]bool)
|
||||
}
|
||||
if _, exists := i.nodes[name]; exists {
|
||||
return
|
||||
}
|
||||
i.nodes[name] = struct{}{}
|
||||
i.nodes[name] = true
|
||||
}
|
||||
|
||||
func (i *importGraph) addNodes(names []string) {
|
||||
for _, name := range names {
|
||||
i.addNode(name)
|
||||
@@ -45,7 +43,6 @@ func (i *importGraph) addNodes(names []string) {
|
||||
func (i *importGraph) removeNode(name string) {
|
||||
delete(i.nodes, name)
|
||||
}
|
||||
|
||||
func (i *importGraph) removeNodes(names []string) {
|
||||
for _, name := range names {
|
||||
i.removeNode(name)
|
||||
@@ -67,7 +64,7 @@ func (i *importGraph) addEdge(from, to string) error {
|
||||
}
|
||||
|
||||
if i.nodes == nil {
|
||||
i.nodes = make(map[string]struct{})
|
||||
i.nodes = make(map[string]bool)
|
||||
}
|
||||
if i.edges == nil {
|
||||
i.edges = make(adjacency)
|
||||
@@ -76,7 +73,6 @@ func (i *importGraph) addEdge(from, to string) error {
|
||||
i.edges[from] = append(i.edges[from], to)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *importGraph) addEdges(from string, tos []string) error {
|
||||
for _, to := range tos {
|
||||
err := i.addEdge(from, to)
|
||||
@@ -92,7 +88,12 @@ func (i *importGraph) areConnected(from, to string) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(al, to)
|
||||
for _, v := range al {
|
||||
if v == to {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (i *importGraph) willCycle(from, to string) bool {
|
||||
|
||||
+32
-239
@@ -17,10 +17,7 @@ package caddyfile
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
@@ -38,41 +35,15 @@ type (
|
||||
|
||||
// Token represents a single parsable unit.
|
||||
Token struct {
|
||||
File string
|
||||
imports []string
|
||||
Line int
|
||||
Text string
|
||||
wasQuoted rune // enclosing quote character, if any
|
||||
heredocMarker string
|
||||
snippetName string
|
||||
File string
|
||||
Line int
|
||||
Text string
|
||||
wasQuoted rune // enclosing quote character, if any
|
||||
inSnippet bool
|
||||
snippetName string
|
||||
}
|
||||
)
|
||||
|
||||
// Tokenize takes bytes as input and lexes it into
|
||||
// a list of tokens that can be parsed as a Caddyfile.
|
||||
// Also takes a filename to fill the token's File as
|
||||
// the source of the tokens, which is important to
|
||||
// determine relative paths for `import` directives.
|
||||
func Tokenize(input []byte, filename string) ([]Token, error) {
|
||||
l := lexer{}
|
||||
if err := l.load(bytes.NewReader(input)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tokens []Token
|
||||
for {
|
||||
found, err := l.next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
break
|
||||
}
|
||||
l.token.File = filename
|
||||
tokens = append(tokens, l.token)
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// load prepares the lexer to scan an input for tokens.
|
||||
// It discards any leading byte order mark.
|
||||
func (l *lexer) load(input io.Reader) error {
|
||||
@@ -104,107 +75,28 @@ func (l *lexer) load(input io.Reader) error {
|
||||
// may be escaped. The rest of the line is skipped
|
||||
// if a "#" character is read in. Returns true if
|
||||
// a token was loaded; false otherwise.
|
||||
func (l *lexer) next() (bool, error) {
|
||||
func (l *lexer) next() bool {
|
||||
var val []rune
|
||||
var comment, quoted, btQuoted, inHeredoc, heredocEscaped, escaped bool
|
||||
var heredocMarker string
|
||||
var comment, quoted, btQuoted, escaped bool
|
||||
|
||||
makeToken := func(quoted rune) bool {
|
||||
l.token.Text = string(val)
|
||||
l.token.wasQuoted = quoted
|
||||
l.token.heredocMarker = heredocMarker
|
||||
return true
|
||||
}
|
||||
|
||||
for {
|
||||
// Read a character in; if err then if we had
|
||||
// read some characters, make a token. If we
|
||||
// reached EOF, then no more tokens to read.
|
||||
// If no EOF, then we had a problem.
|
||||
ch, _, err := l.reader.ReadRune()
|
||||
if err != nil {
|
||||
if len(val) > 0 {
|
||||
if inHeredoc {
|
||||
return false, fmt.Errorf("incomplete heredoc <<%s on line #%d, expected ending marker %s", heredocMarker, l.line+l.skippedLines, heredocMarker)
|
||||
}
|
||||
|
||||
return makeToken(0), nil
|
||||
return makeToken(0)
|
||||
}
|
||||
if err == io.EOF {
|
||||
return false, nil
|
||||
return false
|
||||
}
|
||||
return false, err
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// detect whether we have the start of a heredoc
|
||||
if (!quoted && !btQuoted) && (!inHeredoc && !heredocEscaped) &&
|
||||
len(val) > 1 && string(val[:2]) == "<<" {
|
||||
// a space means it's just a regular token and not a heredoc
|
||||
if ch == ' ' {
|
||||
return makeToken(0), nil
|
||||
}
|
||||
|
||||
// skip CR, we only care about LF
|
||||
if ch == '\r' {
|
||||
continue
|
||||
}
|
||||
|
||||
// after hitting a newline, we know that the heredoc marker
|
||||
// is the characters after the two << and the newline.
|
||||
// we reset the val because the heredoc is syntax we don't
|
||||
// want to keep.
|
||||
if ch == '\n' {
|
||||
if len(val) == 2 {
|
||||
return false, fmt.Errorf("missing opening heredoc marker on line #%d; must contain only alpha-numeric characters, dashes and underscores; got empty string", l.line)
|
||||
}
|
||||
|
||||
// check if there's too many <
|
||||
if string(val[:3]) == "<<<" {
|
||||
return false, fmt.Errorf("too many '<' for heredoc on line #%d; only use two, for example <<END", l.line)
|
||||
}
|
||||
|
||||
heredocMarker = string(val[2:])
|
||||
if !heredocMarkerRegexp.Match([]byte(heredocMarker)) {
|
||||
return false, fmt.Errorf("heredoc marker on line #%d must contain only alpha-numeric characters, dashes and underscores; got '%s'", l.line, heredocMarker)
|
||||
}
|
||||
|
||||
inHeredoc = true
|
||||
l.skippedLines++
|
||||
val = nil
|
||||
continue
|
||||
}
|
||||
val = append(val, ch)
|
||||
continue
|
||||
}
|
||||
|
||||
// if we're in a heredoc, all characters are read as-is
|
||||
if inHeredoc {
|
||||
val = append(val, ch)
|
||||
|
||||
if ch == '\n' {
|
||||
l.skippedLines++
|
||||
}
|
||||
|
||||
// check if we're done, i.e. that the last few characters are the marker
|
||||
if len(val) >= len(heredocMarker) && heredocMarker == string(val[len(val)-len(heredocMarker):]) {
|
||||
// set the final value
|
||||
val, err = l.finalizeHeredoc(val, heredocMarker)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// set the line counter, and make the token
|
||||
l.line += l.skippedLines
|
||||
l.skippedLines = 0
|
||||
return makeToken('<'), nil
|
||||
}
|
||||
|
||||
// stay in the heredoc until we find the ending marker
|
||||
continue
|
||||
}
|
||||
|
||||
// track whether we found an escape '\' for the next
|
||||
// iteration to be contextually aware
|
||||
if !escaped && !btQuoted && ch == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
@@ -219,29 +111,26 @@ func (l *lexer) next() (bool, error) {
|
||||
}
|
||||
escaped = false
|
||||
} else {
|
||||
if (quoted && ch == '"') || (btQuoted && ch == '`') {
|
||||
return makeToken(ch), nil
|
||||
if quoted && ch == '"' {
|
||||
return makeToken('"')
|
||||
}
|
||||
if btQuoted && ch == '`' {
|
||||
return makeToken('`')
|
||||
}
|
||||
}
|
||||
// allow quoted text to wrap continue on multiple lines
|
||||
if ch == '\n' {
|
||||
l.line += 1 + l.skippedLines
|
||||
l.skippedLines = 0
|
||||
}
|
||||
// collect this character as part of the quoted token
|
||||
val = append(val, ch)
|
||||
continue
|
||||
}
|
||||
|
||||
if unicode.IsSpace(ch) {
|
||||
// ignore CR altogether, we only actually care about LF (\n)
|
||||
if ch == '\r' {
|
||||
continue
|
||||
}
|
||||
// end of the line
|
||||
if ch == '\n' {
|
||||
// newlines can be escaped to chain arguments
|
||||
// onto multiple lines; else, increment the line count
|
||||
if escaped {
|
||||
l.skippedLines++
|
||||
escaped = false
|
||||
@@ -249,18 +138,14 @@ func (l *lexer) next() (bool, error) {
|
||||
l.line += 1 + l.skippedLines
|
||||
l.skippedLines = 0
|
||||
}
|
||||
// comments (#) are single-line only
|
||||
comment = false
|
||||
}
|
||||
// any kind of space means we're at the end of this token
|
||||
if len(val) > 0 {
|
||||
return makeToken(0), nil
|
||||
return makeToken(0)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// comments must be at the start of a token,
|
||||
// in other words, preceded by space or newline
|
||||
if ch == '#' && len(val) == 0 {
|
||||
comment = true
|
||||
}
|
||||
@@ -281,12 +166,7 @@ func (l *lexer) next() (bool, error) {
|
||||
}
|
||||
|
||||
if escaped {
|
||||
// allow escaping the first < to skip the heredoc syntax
|
||||
if ch == '<' {
|
||||
heredocEscaped = true
|
||||
} else {
|
||||
val = append(val, '\\')
|
||||
}
|
||||
val = append(val, '\\')
|
||||
escaped = false
|
||||
}
|
||||
|
||||
@@ -294,107 +174,20 @@ func (l *lexer) next() (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// finalizeHeredoc takes the runes read as the heredoc text and the marker,
|
||||
// and processes the text to strip leading whitespace, returning the final
|
||||
// value without the leading whitespace.
|
||||
func (l *lexer) finalizeHeredoc(val []rune, marker string) ([]rune, error) {
|
||||
stringVal := string(val)
|
||||
|
||||
// find the last newline of the heredoc, which is where the contents end
|
||||
lastNewline := strings.LastIndex(stringVal, "\n")
|
||||
|
||||
// collapse the content, then split into separate lines
|
||||
lines := strings.Split(stringVal[:lastNewline+1], "\n")
|
||||
|
||||
// figure out how much whitespace we need to strip from the front of every line
|
||||
// by getting the string that precedes the marker, on the last line
|
||||
paddingToStrip := stringVal[lastNewline+1 : len(stringVal)-len(marker)]
|
||||
|
||||
// iterate over each line and strip the whitespace from the front
|
||||
var out string
|
||||
for lineNum, lineText := range lines[:len(lines)-1] {
|
||||
if lineText == "" || lineText == "\r" {
|
||||
out += "\n"
|
||||
continue
|
||||
}
|
||||
|
||||
// find an exact match for the padding
|
||||
index := strings.Index(lineText, paddingToStrip)
|
||||
|
||||
// if the padding doesn't match exactly at the start then we can't safely strip
|
||||
if index != 0 {
|
||||
cleanLineText := strings.TrimRight(lineText, "\r\n")
|
||||
return nil, fmt.Errorf("mismatched leading whitespace in heredoc <<%s on line #%d [%s], expected whitespace [%s] to match the closing marker", marker, l.line+lineNum+1, cleanLineText, paddingToStrip)
|
||||
}
|
||||
|
||||
// strip, then append the line, with the newline, to the output.
|
||||
// also removes all "\r" because Windows.
|
||||
out += strings.ReplaceAll(lineText[len(paddingToStrip):]+"\n", "\r", "")
|
||||
// Tokenize takes bytes as input and lexes it into
|
||||
// a list of tokens that can be parsed as a Caddyfile.
|
||||
// Also takes a filename to fill the token's File as
|
||||
// the source of the tokens, which is important to
|
||||
// determine relative paths for `import` directives.
|
||||
func Tokenize(input []byte, filename string) ([]Token, error) {
|
||||
l := lexer{}
|
||||
if err := l.load(bytes.NewReader(input)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Remove the trailing newline from the loop
|
||||
if len(out) > 0 && out[len(out)-1] == '\n' {
|
||||
out = out[:len(out)-1]
|
||||
var tokens []Token
|
||||
for l.next() {
|
||||
l.token.File = filename
|
||||
tokens = append(tokens, l.token)
|
||||
}
|
||||
|
||||
// return the final value
|
||||
return []rune(out), nil
|
||||
}
|
||||
|
||||
// Quoted returns true if the token was enclosed in quotes
|
||||
// (i.e. double quotes, backticks, or heredoc).
|
||||
func (t Token) Quoted() bool {
|
||||
return t.wasQuoted > 0
|
||||
}
|
||||
|
||||
// NumLineBreaks counts how many line breaks are in the token text.
|
||||
func (t Token) NumLineBreaks() int {
|
||||
lineBreaks := strings.Count(t.Text, "\n")
|
||||
if t.wasQuoted == '<' {
|
||||
// heredocs have an extra linebreak because the opening
|
||||
// delimiter is on its own line and is not included in the
|
||||
// token Text itself, and the trailing newline is removed.
|
||||
lineBreaks += 2
|
||||
}
|
||||
return lineBreaks
|
||||
}
|
||||
|
||||
// Clone returns a deep copy of the token.
|
||||
func (t Token) Clone() Token {
|
||||
return Token{
|
||||
File: t.File,
|
||||
imports: append([]string{}, t.imports...),
|
||||
Line: t.Line,
|
||||
Text: t.Text,
|
||||
wasQuoted: t.wasQuoted,
|
||||
heredocMarker: t.heredocMarker,
|
||||
snippetName: t.snippetName,
|
||||
}
|
||||
}
|
||||
|
||||
var heredocMarkerRegexp = regexp.MustCompile("^[A-Za-z0-9_-]+$")
|
||||
|
||||
// isNextOnNewLine tests whether t2 is on a different line from t1
|
||||
func isNextOnNewLine(t1, t2 Token) bool {
|
||||
// If the second token is from a different file,
|
||||
// we can assume it's from a different line
|
||||
if t1.File != t2.File {
|
||||
return true
|
||||
}
|
||||
|
||||
// If the second token is from a different import chain,
|
||||
// we can assume it's from a different line
|
||||
if len(t1.imports) != len(t2.imports) {
|
||||
return true
|
||||
}
|
||||
for i, im := range t1.imports {
|
||||
if im != t2.imports[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// If the first token (incl line breaks) ends
|
||||
// on a line earlier than the next token,
|
||||
// then the second token is on a new line
|
||||
return t1.Line+t1.NumLineBreaks() < t2.Line
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
//go:build gofuzz
|
||||
// +build gofuzz
|
||||
|
||||
package caddyfile
|
||||
|
||||
|
||||
@@ -18,13 +18,13 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
type lexerTestCase struct {
|
||||
input []byte
|
||||
expected []Token
|
||||
}
|
||||
|
||||
func TestLexer(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input []byte
|
||||
expected []Token
|
||||
expectErr bool
|
||||
errorMessage string
|
||||
}{
|
||||
testCases := []lexerTestCase{
|
||||
{
|
||||
input: []byte(`host:123`),
|
||||
expected: []Token{
|
||||
@@ -249,273 +249,12 @@ func TestLexer(t *testing.T) {
|
||||
{Line: 1, Text: `quotes`},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`heredoc <<EOF
|
||||
content
|
||||
EOF same-line-arg
|
||||
`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: `heredoc`},
|
||||
{Line: 1, Text: "content"},
|
||||
{Line: 3, Text: `same-line-arg`},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`heredoc <<VERY-LONG-MARKER
|
||||
content
|
||||
VERY-LONG-MARKER same-line-arg
|
||||
`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: `heredoc`},
|
||||
{Line: 1, Text: "content"},
|
||||
{Line: 3, Text: `same-line-arg`},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`heredoc <<EOF
|
||||
extra-newline
|
||||
|
||||
EOF same-line-arg
|
||||
`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: `heredoc`},
|
||||
{Line: 1, Text: "extra-newline\n"},
|
||||
{Line: 4, Text: `same-line-arg`},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`heredoc <<EOF
|
||||
EOF
|
||||
HERE same-line-arg
|
||||
`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: `heredoc`},
|
||||
{Line: 1, Text: ``},
|
||||
{Line: 3, Text: `HERE`},
|
||||
{Line: 3, Text: `same-line-arg`},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`heredoc <<EOF
|
||||
EOF same-line-arg
|
||||
`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: `heredoc`},
|
||||
{Line: 1, Text: ""},
|
||||
{Line: 2, Text: `same-line-arg`},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`heredoc <<EOF
|
||||
content
|
||||
EOF same-line-arg
|
||||
`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: `heredoc`},
|
||||
{Line: 1, Text: "content"},
|
||||
{Line: 3, Text: `same-line-arg`},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`prev-line
|
||||
heredoc <<EOF
|
||||
multi
|
||||
line
|
||||
content
|
||||
EOF same-line-arg
|
||||
next-line
|
||||
`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: `prev-line`},
|
||||
{Line: 2, Text: `heredoc`},
|
||||
{Line: 2, Text: "\tmulti\n\tline\n\tcontent"},
|
||||
{Line: 6, Text: `same-line-arg`},
|
||||
{Line: 7, Text: `next-line`},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`escaped-heredoc \<< >>`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: `escaped-heredoc`},
|
||||
{Line: 1, Text: `<<`},
|
||||
{Line: 1, Text: `>>`},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`not-a-heredoc <EOF
|
||||
content
|
||||
`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: `not-a-heredoc`},
|
||||
{Line: 1, Text: `<EOF`},
|
||||
{Line: 2, Text: `content`},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`not-a-heredoc <<<EOF content`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: `not-a-heredoc`},
|
||||
{Line: 1, Text: `<<<EOF`},
|
||||
{Line: 1, Text: `content`},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`not-a-heredoc "<<" ">>"`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: `not-a-heredoc`},
|
||||
{Line: 1, Text: `<<`},
|
||||
{Line: 1, Text: `>>`},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`not-a-heredoc << >>`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: `not-a-heredoc`},
|
||||
{Line: 1, Text: `<<`},
|
||||
{Line: 1, Text: `>>`},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`not-a-heredoc <<HERE SAME LINE
|
||||
content
|
||||
HERE same-line-arg
|
||||
`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: `not-a-heredoc`},
|
||||
{Line: 1, Text: `<<HERE`},
|
||||
{Line: 1, Text: `SAME`},
|
||||
{Line: 1, Text: `LINE`},
|
||||
{Line: 2, Text: `content`},
|
||||
{Line: 3, Text: `HERE`},
|
||||
{Line: 3, Text: `same-line-arg`},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`heredoc <<s
|
||||
�
|
||||
s
|
||||
`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: `heredoc`},
|
||||
{Line: 1, Text: "�"},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte("\u000Aheredoc \u003C\u003C\u0073\u0073\u000A\u00BF\u0057\u0001\u0000\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u003D\u001F\u000A\u0073\u0073\u000A\u00BF\u0057\u0001\u0000\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u003D\u001F\u000A\u00BF\u00BF\u0057\u0001\u0000\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u003D\u001F"),
|
||||
expected: []Token{
|
||||
{
|
||||
Line: 2,
|
||||
Text: "heredoc",
|
||||
},
|
||||
{
|
||||
Line: 2,
|
||||
Text: "\u00BF\u0057\u0001\u0000\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u003D\u001F",
|
||||
},
|
||||
{
|
||||
Line: 5,
|
||||
Text: "\u00BF\u0057\u0001\u0000\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u003D\u001F",
|
||||
},
|
||||
{
|
||||
Line: 6,
|
||||
Text: "\u00BF\u00BF\u0057\u0001\u0000\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u00FF\u003D\u001F",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte("not-a-heredoc <<\n"),
|
||||
expectErr: true,
|
||||
errorMessage: "missing opening heredoc marker on line #1; must contain only alpha-numeric characters, dashes and underscores; got empty string",
|
||||
},
|
||||
{
|
||||
input: []byte(`heredoc <<<EOF
|
||||
content
|
||||
EOF same-line-arg
|
||||
`),
|
||||
expectErr: true,
|
||||
errorMessage: "too many '<' for heredoc on line #1; only use two, for example <<END",
|
||||
},
|
||||
{
|
||||
input: []byte(`heredoc <<EOF
|
||||
content
|
||||
`),
|
||||
expectErr: true,
|
||||
errorMessage: "incomplete heredoc <<EOF on line #3, expected ending marker EOF",
|
||||
},
|
||||
{
|
||||
input: []byte(`heredoc <<EOF
|
||||
content
|
||||
EOF
|
||||
`),
|
||||
expectErr: true,
|
||||
errorMessage: "mismatched leading whitespace in heredoc <<EOF on line #2 [\tcontent], expected whitespace [\t\t] to match the closing marker",
|
||||
},
|
||||
{
|
||||
input: []byte(`heredoc <<EOF
|
||||
content
|
||||
EOF
|
||||
`),
|
||||
expectErr: true,
|
||||
errorMessage: "mismatched leading whitespace in heredoc <<EOF on line #2 [ content], expected whitespace [\t\t] to match the closing marker",
|
||||
},
|
||||
{
|
||||
input: []byte(`heredoc <<EOF
|
||||
The next line is a blank line
|
||||
|
||||
The previous line is a blank line
|
||||
EOF`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: "heredoc"},
|
||||
{Line: 1, Text: "The next line is a blank line\n\nThe previous line is a blank line"},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`heredoc <<EOF
|
||||
One tab indented heredoc with blank next line
|
||||
|
||||
One tab indented heredoc with blank previous line
|
||||
EOF`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: "heredoc"},
|
||||
{Line: 1, Text: "One tab indented heredoc with blank next line\n\nOne tab indented heredoc with blank previous line"},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`heredoc <<EOF
|
||||
The next line is a blank line with one tab
|
||||
|
||||
The previous line is a blank line with one tab
|
||||
EOF`),
|
||||
expected: []Token{
|
||||
{Line: 1, Text: "heredoc"},
|
||||
{Line: 1, Text: "The next line is a blank line with one tab\n\t\nThe previous line is a blank line with one tab"},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: []byte(`heredoc <<EOF
|
||||
The next line is a blank line with one tab less than the correct indentation
|
||||
|
||||
The previous line is a blank line with one tab less than the correct indentation
|
||||
EOF`),
|
||||
expectErr: true,
|
||||
errorMessage: "mismatched leading whitespace in heredoc <<EOF on line #3 [\t], expected whitespace [\t\t] to match the closing marker",
|
||||
},
|
||||
}
|
||||
|
||||
for i, testCase := range testCases {
|
||||
actual, err := Tokenize(testCase.input, "")
|
||||
if testCase.expectErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got actual: %v", actual)
|
||||
continue
|
||||
}
|
||||
if err.Error() != testCase.errorMessage {
|
||||
t.Fatalf("expected error '%v', got: %v", testCase.errorMessage, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
lexerCompare(t, i, testCase.expected, actual)
|
||||
}
|
||||
@@ -523,17 +262,17 @@ EOF`),
|
||||
|
||||
func lexerCompare(t *testing.T, n int, expected, actual []Token) {
|
||||
if len(expected) != len(actual) {
|
||||
t.Fatalf("Test case %d: expected %d token(s) but got %d", n, len(expected), len(actual))
|
||||
t.Errorf("Test case %d: expected %d token(s) but got %d", n, len(expected), len(actual))
|
||||
}
|
||||
|
||||
for i := 0; i < len(actual) && i < len(expected); i++ {
|
||||
if actual[i].Line != expected[i].Line {
|
||||
t.Fatalf("Test case %d token %d ('%s'): expected line %d but was line %d",
|
||||
t.Errorf("Test case %d token %d ('%s'): expected line %d but was line %d",
|
||||
n, i, expected[i].Text, expected[i].Line, actual[i].Line)
|
||||
break
|
||||
}
|
||||
if actual[i].Text != expected[i].Text {
|
||||
t.Fatalf("Test case %d token %d: expected text '%s' but was '%s'",
|
||||
t.Errorf("Test case %d token %d: expected text '%s' but was '%s'",
|
||||
n, i, expected[i].Text, actual[i].Text)
|
||||
break
|
||||
}
|
||||
|
||||
+61
-233
@@ -20,11 +20,11 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Parse parses the input just enough to group tokens, in
|
||||
@@ -50,7 +50,7 @@ func Parse(filename string, input []byte) ([]ServerBlock, error) {
|
||||
p := parser{
|
||||
Dispenser: NewDispenser(tokens),
|
||||
importGraph: importGraph{
|
||||
nodes: make(map[string]struct{}),
|
||||
nodes: make(map[string]bool),
|
||||
edges: make(adjacency),
|
||||
},
|
||||
}
|
||||
@@ -61,12 +61,20 @@ func Parse(filename string, input []byte) ([]ServerBlock, error) {
|
||||
// It returns all the tokens from the input, unstructured
|
||||
// and in order. It may mutate input as it expands env vars.
|
||||
func allTokens(filename string, input []byte) ([]Token, error) {
|
||||
return Tokenize(replaceEnvVars(input), filename)
|
||||
inputCopy, err := replaceEnvVars(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens, err := Tokenize(inputCopy, filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// replaceEnvVars replaces all occurrences of environment variables.
|
||||
// It mutates the underlying array and returns the updated slice.
|
||||
func replaceEnvVars(input []byte) []byte {
|
||||
func replaceEnvVars(input []byte) ([]byte, error) {
|
||||
var offset int
|
||||
for {
|
||||
begin := bytes.Index(input[offset:], spanOpen)
|
||||
@@ -107,7 +115,7 @@ func replaceEnvVars(input []byte) []byte {
|
||||
// continue at the end of the replacement
|
||||
offset = begin + len(envVarBytes)
|
||||
}
|
||||
return input
|
||||
return input, nil
|
||||
}
|
||||
|
||||
type parser struct {
|
||||
@@ -149,6 +157,7 @@ func (p *parser) begin() error {
|
||||
}
|
||||
|
||||
err := p.addresses()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -159,25 +168,6 @@ func (p *parser) begin() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if ok, name := p.isNamedRoute(); ok {
|
||||
// we just need a dummy leading token to ease parsing later
|
||||
nameToken := p.Token()
|
||||
nameToken.Text = name
|
||||
|
||||
// named routes only have one key, the route name
|
||||
p.block.Keys = []Token{nameToken}
|
||||
p.block.IsNamedRoute = true
|
||||
|
||||
// get all the tokens from the block, including the braces
|
||||
tokens, err := p.blockTokens(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tokens = append([]Token{nameToken}, tokens...)
|
||||
p.block.Segments = []Segment{tokens}
|
||||
return nil
|
||||
}
|
||||
|
||||
if ok, name := p.isSnippet(); ok {
|
||||
if p.definedSnippets == nil {
|
||||
p.definedSnippets = map[string][]Token{}
|
||||
@@ -186,15 +176,16 @@ func (p *parser) begin() error {
|
||||
return p.Errf("redeclaration of previously declared snippet %s", name)
|
||||
}
|
||||
// consume all tokens til matched close brace
|
||||
tokens, err := p.blockTokens(false)
|
||||
tokens, err := p.snippetTokens()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Just as we need to track which file the token comes from, we need to
|
||||
// keep track of which snippet the token comes from. This is helpful
|
||||
// in tracking import cycles across files/snippets by namespacing them.
|
||||
// Without this, we end up with false-positives in cycle-detection.
|
||||
// keep track of which snippets do the tokens come from. This is helpful
|
||||
// in tracking import cycles across files/snippets by namespacing them. Without
|
||||
// this we end up with false-positives in cycle-detection.
|
||||
for k, v := range tokens {
|
||||
v.inSnippet = true
|
||||
v.snippetName = name
|
||||
tokens[k] = v
|
||||
}
|
||||
@@ -211,17 +202,11 @@ func (p *parser) addresses() error {
|
||||
var expectingAnother bool
|
||||
|
||||
for {
|
||||
value := p.Val()
|
||||
token := p.Token()
|
||||
tkn := p.Val()
|
||||
|
||||
// Reject request matchers if trying to define them globally
|
||||
if strings.HasPrefix(value, "@") {
|
||||
return p.Errf("request matchers may not be defined globally, they must be in a site block; found %s", value)
|
||||
}
|
||||
|
||||
// Special case: import directive replaces tokens during parse-time
|
||||
if value == "import" && p.isNewLine() {
|
||||
err := p.doImport(0)
|
||||
// special case: import directive replaces tokens during parse-time
|
||||
if tkn == "import" && p.isNewLine() {
|
||||
err := p.doImport()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -229,9 +214,9 @@ func (p *parser) addresses() error {
|
||||
}
|
||||
|
||||
// Open brace definitely indicates end of addresses
|
||||
if value == "{" {
|
||||
if tkn == "{" {
|
||||
if expectingAnother {
|
||||
return p.Errf("Expected another address but had '%s' - check for extra comma", value)
|
||||
return p.Errf("Expected another address but had '%s' - check for extra comma", tkn)
|
||||
}
|
||||
// Mark this server block as being defined with braces.
|
||||
// This is used to provide a better error message when
|
||||
@@ -243,15 +228,15 @@ func (p *parser) addresses() error {
|
||||
}
|
||||
|
||||
// Users commonly forget to place a space between the address and the '{'
|
||||
if strings.HasSuffix(value, "{") {
|
||||
return p.Errf("Site addresses cannot end with a curly brace: '%s' - put a space between the token and the brace", value)
|
||||
if strings.HasSuffix(tkn, "{") {
|
||||
return p.Errf("Site addresses cannot end with a curly brace: '%s' - put a space between the token and the brace", tkn)
|
||||
}
|
||||
|
||||
if value != "" { // empty token possible if user typed ""
|
||||
if tkn != "" { // empty token possible if user typed ""
|
||||
// Trailing comma indicates another address will follow, which
|
||||
// may possibly be on the next line
|
||||
if value[len(value)-1] == ',' {
|
||||
value = value[:len(value)-1]
|
||||
if tkn[len(tkn)-1] == ',' {
|
||||
tkn = tkn[:len(tkn)-1]
|
||||
expectingAnother = true
|
||||
} else {
|
||||
expectingAnother = false // but we may still see another one on this line
|
||||
@@ -260,17 +245,11 @@ func (p *parser) addresses() error {
|
||||
// If there's a comma here, it's probably because they didn't use a space
|
||||
// between their two domains, e.g. "foo.com,bar.com", which would not be
|
||||
// parsed as two separate site addresses.
|
||||
if strings.Contains(value, ",") {
|
||||
return p.Errf("Site addresses cannot contain a comma ',': '%s' - put a space after the comma to separate site addresses", value)
|
||||
if strings.Contains(tkn, ",") {
|
||||
return p.Errf("Site addresses cannot contain a comma ',': '%s' - put a space after the comma to separate site addresses", tkn)
|
||||
}
|
||||
|
||||
// After the above, a comma surrounded by spaces would result
|
||||
// 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)
|
||||
}
|
||||
p.block.Keys = append(p.block.Keys, tkn)
|
||||
}
|
||||
|
||||
// Advance token and possibly break out of loop or return error
|
||||
@@ -327,7 +306,7 @@ func (p *parser) directives() error {
|
||||
|
||||
// special case: import directive replaces tokens during parse-time
|
||||
if p.Val() == "import" {
|
||||
err := p.doImport(1)
|
||||
err := p.doImport()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -353,7 +332,7 @@ func (p *parser) directives() error {
|
||||
// is on the token before where the import directive was. In
|
||||
// other words, call Next() to access the first token that was
|
||||
// imported.
|
||||
func (p *parser) doImport(nesting int) error {
|
||||
func (p *parser) doImport() error {
|
||||
// syntax checks
|
||||
if !p.NextArg() {
|
||||
return p.ArgErr()
|
||||
@@ -366,43 +345,15 @@ func (p *parser) doImport(nesting int) error {
|
||||
// grab remaining args as placeholder replacements
|
||||
args := p.RemainingArgs()
|
||||
|
||||
// set up a replacer for non-variadic args replacement
|
||||
repl := makeArgsReplacer(args)
|
||||
|
||||
// grab all the tokens (if it exists) from within a block that follows the import
|
||||
var blockTokens []Token
|
||||
for currentNesting := p.Nesting(); p.NextBlock(currentNesting); {
|
||||
blockTokens = append(blockTokens, p.Token())
|
||||
}
|
||||
// initialize with size 1
|
||||
blockMapping := make(map[string][]Token, 1)
|
||||
if len(blockTokens) > 0 {
|
||||
// use such tokens to create a new dispenser, and then use it to parse each block
|
||||
bd := NewDispenser(blockTokens)
|
||||
|
||||
// one iteration processes one sub-block inside the import
|
||||
for bd.Next() {
|
||||
currentMappingKey := bd.Val()
|
||||
|
||||
if currentMappingKey == "{" {
|
||||
return p.Err("anonymous blocks are not supported")
|
||||
}
|
||||
|
||||
// load up all arguments (if there even are any)
|
||||
currentMappingTokens := bd.RemainingArgsAsTokens()
|
||||
|
||||
// load up the entire block
|
||||
for mappingNesting := bd.Nesting(); bd.NextBlock(mappingNesting); {
|
||||
currentMappingTokens = append(currentMappingTokens, bd.Token())
|
||||
}
|
||||
|
||||
blockMapping[currentMappingKey] = currentMappingTokens
|
||||
}
|
||||
// add args to the replacer
|
||||
repl := caddy.NewEmptyReplacer()
|
||||
for index, arg := range args {
|
||||
repl.Set("args."+strconv.Itoa(index), arg)
|
||||
}
|
||||
|
||||
// splice out the import directive and its arguments
|
||||
// (2 tokens, plus the length of args)
|
||||
tokensBefore := p.tokens[:p.cursor-1-len(args)-len(blockTokens)]
|
||||
tokensBefore := p.tokens[:p.cursor-1-len(args)]
|
||||
tokensAfter := p.tokens[p.cursor+1:]
|
||||
var importedTokens []Token
|
||||
var nodes []string
|
||||
@@ -418,7 +369,7 @@ func (p *parser) doImport(nesting int) error {
|
||||
// make path relative to the file of the _token_ being processed rather
|
||||
// than current working directory (issue #867) and then use glob to get
|
||||
// list of matching filenames
|
||||
absFile, err := caddy.FastAbs(p.Dispenser.File())
|
||||
absFile, err := filepath.Abs(p.Dispenser.File())
|
||||
if err != nil {
|
||||
return p.Errf("Failed to get absolute path of file: %s: %v", p.Dispenser.File(), err)
|
||||
}
|
||||
@@ -436,6 +387,7 @@ func (p *parser) doImport(nesting int) error {
|
||||
return p.Errf("Glob pattern may only contain one wildcard (*), but has others: %s", globPattern)
|
||||
}
|
||||
matches, err = filepath.Glob(globPattern)
|
||||
|
||||
if err != nil {
|
||||
return p.Errf("Failed to use import pattern %s: %v", importPattern, err)
|
||||
}
|
||||
@@ -445,20 +397,6 @@ func (p *parser) doImport(nesting int) error {
|
||||
} else {
|
||||
return p.Errf("File to import not found: %s", importPattern)
|
||||
}
|
||||
} else {
|
||||
// See issue #5295 - should skip any files that start with a . when iterating over them.
|
||||
sep := string(filepath.Separator)
|
||||
segGlobPattern := strings.Split(globPattern, sep)
|
||||
if strings.HasPrefix(segGlobPattern[len(segGlobPattern)-1], "*") {
|
||||
var tmpMatches []string
|
||||
for _, m := range matches {
|
||||
seg := strings.Split(m, sep)
|
||||
if !strings.HasPrefix(seg[len(seg)-1], ".") {
|
||||
tmpMatches = append(tmpMatches, m)
|
||||
}
|
||||
}
|
||||
matches = tmpMatches
|
||||
}
|
||||
}
|
||||
|
||||
// collect all the imported tokens
|
||||
@@ -473,7 +411,7 @@ func (p *parser) doImport(nesting int) error {
|
||||
}
|
||||
|
||||
nodeName := p.File()
|
||||
if p.Token().snippetName != "" {
|
||||
if p.Token().inSnippet {
|
||||
nodeName += fmt.Sprintf(":%s", p.Token().snippetName)
|
||||
}
|
||||
p.importGraph.addNode(nodeName)
|
||||
@@ -484,97 +422,19 @@ func (p *parser) doImport(nesting int) error {
|
||||
}
|
||||
|
||||
// copy the tokens so we don't overwrite p.definedSnippets
|
||||
tokensCopy := make([]Token, 0, len(importedTokens))
|
||||
|
||||
var (
|
||||
maybeSnippet bool
|
||||
maybeSnippetId bool
|
||||
index int
|
||||
)
|
||||
tokensCopy := make([]Token, len(importedTokens))
|
||||
copy(tokensCopy, importedTokens)
|
||||
|
||||
// run the argument replacer on the tokens
|
||||
// golang for range slice return a copy of value
|
||||
// similarly, append also copy value
|
||||
for i, token := range importedTokens {
|
||||
// update the token's imports to refer to import directive filename, line number and snippet name if there is one
|
||||
if token.snippetName != "" {
|
||||
token.imports = append(token.imports, fmt.Sprintf("%s:%d (import %s)", p.File(), p.Line(), token.snippetName))
|
||||
} else {
|
||||
token.imports = append(token.imports, fmt.Sprintf("%s:%d (import)", p.File(), p.Line()))
|
||||
}
|
||||
|
||||
// naive way of determine snippets, as snippets definition can only follow name + block
|
||||
// format, won't check for nesting correctness or any other error, that's what parser does.
|
||||
if !maybeSnippet && nesting == 0 {
|
||||
// first of the line
|
||||
if i == 0 || isNextOnNewLine(tokensCopy[i-1], token) {
|
||||
index = 0
|
||||
} else {
|
||||
index++
|
||||
}
|
||||
|
||||
if index == 0 && len(token.Text) >= 3 && strings.HasPrefix(token.Text, "(") && strings.HasSuffix(token.Text, ")") {
|
||||
maybeSnippetId = true
|
||||
}
|
||||
}
|
||||
|
||||
switch token.Text {
|
||||
case "{":
|
||||
nesting++
|
||||
if index == 1 && maybeSnippetId && nesting == 1 {
|
||||
maybeSnippet = true
|
||||
maybeSnippetId = false
|
||||
}
|
||||
case "}":
|
||||
nesting--
|
||||
if nesting == 0 && maybeSnippet {
|
||||
maybeSnippet = false
|
||||
}
|
||||
}
|
||||
// if it is {block}, we substitute with all tokens in the block
|
||||
// if it is {blocks.*}, we substitute with the tokens in the mapping for the *
|
||||
var tokensToAdd []Token
|
||||
foundBlockDirective := false
|
||||
switch {
|
||||
case token.Text == "{block}":
|
||||
foundBlockDirective = true
|
||||
tokensToAdd = blockTokens
|
||||
case strings.HasPrefix(token.Text, "{blocks.") && strings.HasSuffix(token.Text, "}"):
|
||||
foundBlockDirective = true
|
||||
// {blocks.foo.bar} will be extracted to key `foo.bar`
|
||||
blockKey := strings.TrimPrefix(strings.TrimSuffix(token.Text, "}"), "{blocks.")
|
||||
val, ok := blockMapping[blockKey]
|
||||
if ok {
|
||||
tokensToAdd = val
|
||||
}
|
||||
}
|
||||
|
||||
if foundBlockDirective {
|
||||
tokensCopy = append(tokensCopy, tokensToAdd...)
|
||||
continue
|
||||
}
|
||||
|
||||
if maybeSnippet {
|
||||
tokensCopy = append(tokensCopy, token)
|
||||
continue
|
||||
}
|
||||
|
||||
foundVariadic, startIndex, endIndex := parseVariadic(token, len(args))
|
||||
if foundVariadic {
|
||||
for _, arg := range args[startIndex:endIndex] {
|
||||
token.Text = arg
|
||||
tokensCopy = append(tokensCopy, token)
|
||||
}
|
||||
} else {
|
||||
token.Text = repl.ReplaceKnown(token.Text, "")
|
||||
tokensCopy = append(tokensCopy, token)
|
||||
}
|
||||
for index, token := range tokensCopy {
|
||||
token.Text = repl.ReplaceKnown(token.Text, "")
|
||||
tokensCopy[index] = token
|
||||
}
|
||||
|
||||
// splice the imported tokens in the place of the import statement
|
||||
// and rewind cursor so Next() will land on first imported token
|
||||
p.tokens = append(tokensBefore, append(tokensCopy, tokensAfter...)...)
|
||||
p.cursor -= len(args) + len(blockTokens) + 1
|
||||
p.cursor -= len(args) + 1
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -599,12 +459,6 @@ func (p *parser) doSingleImport(importFile string) ([]Token, error) {
|
||||
return nil, p.Errf("Could not read imported file %s: %v", importFile, err)
|
||||
}
|
||||
|
||||
// only warning in case of empty files
|
||||
if len(input) == 0 || len(strings.TrimSpace(string(input))) == 0 {
|
||||
caddy.Log().Warn("Import file is empty", zap.String("file", importFile))
|
||||
return []Token{}, nil
|
||||
}
|
||||
|
||||
importedTokens, err := allTokens(importFile, input)
|
||||
if err != nil {
|
||||
return nil, p.Errf("Could not read tokens while importing %s: %v", importFile, err)
|
||||
@@ -612,7 +466,7 @@ func (p *parser) doSingleImport(importFile string) ([]Token, error) {
|
||||
|
||||
// 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)
|
||||
filename, err := caddy.FastAbs(importFile)
|
||||
filename, err := filepath.Abs(importFile)
|
||||
if err != nil {
|
||||
return nil, p.Errf("Failed to get absolute path of file: %s: %v", importFile, err)
|
||||
}
|
||||
@@ -630,6 +484,7 @@ func (p *parser) doSingleImport(importFile string) ([]Token, error) {
|
||||
// are loaded into the current server block for later use
|
||||
// by directive setup functions.
|
||||
func (p *parser) directive() error {
|
||||
|
||||
// a segment is a list of tokens associated with this directive
|
||||
var segment Segment
|
||||
|
||||
@@ -642,9 +497,6 @@ func (p *parser) directive() error {
|
||||
if !p.isNextOnNewLine() && p.Token().wasQuoted == 0 {
|
||||
return p.Err("Unexpected next token after '{' on same line")
|
||||
}
|
||||
if p.isNewLine() {
|
||||
return p.Err("Unexpected '{' on a new line; did you mean to place the '{' on the previous line?")
|
||||
}
|
||||
} else if p.Val() == "{}" {
|
||||
if p.isNextOnNewLine() && p.Token().wasQuoted == 0 {
|
||||
return p.Err("Unexpected '{}' at end of line")
|
||||
@@ -657,7 +509,7 @@ func (p *parser) directive() error {
|
||||
} else if p.Val() == "}" && p.nesting == 0 {
|
||||
return p.Err("Unexpected '}' because no matching opening brace")
|
||||
} else if p.Val() == "import" && p.isNewLine() {
|
||||
if err := p.doImport(1); err != nil {
|
||||
if err := p.doImport(); err != nil {
|
||||
return err
|
||||
}
|
||||
p.cursor-- // cursor is advanced when we continue, so roll back one more
|
||||
@@ -698,43 +550,28 @@ func (p *parser) closeCurlyBrace() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *parser) isNamedRoute() (bool, string) {
|
||||
keys := p.block.Keys
|
||||
// A named route block is a single key with parens, prefixed with &.
|
||||
if len(keys) == 1 && strings.HasPrefix(keys[0].Text, "&(") && strings.HasSuffix(keys[0].Text, ")") {
|
||||
return true, strings.TrimSuffix(keys[0].Text[2:], ")")
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func (p *parser) isSnippet() (bool, string) {
|
||||
keys := p.block.Keys
|
||||
// A snippet block is a single key with parens. Nothing else qualifies.
|
||||
if len(keys) == 1 && strings.HasPrefix(keys[0].Text, "(") && strings.HasSuffix(keys[0].Text, ")") {
|
||||
return true, strings.TrimSuffix(keys[0].Text[1:], ")")
|
||||
if len(keys) == 1 && strings.HasPrefix(keys[0], "(") && strings.HasSuffix(keys[0], ")") {
|
||||
return true, strings.TrimSuffix(keys[0][1:], ")")
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// read and store everything in a block for later replay.
|
||||
func (p *parser) blockTokens(retainCurlies bool) ([]Token, error) {
|
||||
// block must have curlies.
|
||||
func (p *parser) snippetTokens() ([]Token, error) {
|
||||
// snippet must have curlies.
|
||||
err := p.openCurlyBrace()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nesting := 1 // count our own nesting
|
||||
nesting := 1 // count our own nesting in snippets
|
||||
tokens := []Token{}
|
||||
if retainCurlies {
|
||||
tokens = append(tokens, p.Token())
|
||||
}
|
||||
for p.Next() {
|
||||
if p.Val() == "}" {
|
||||
nesting--
|
||||
if nesting == 0 {
|
||||
if retainCurlies {
|
||||
tokens = append(tokens, p.Token())
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -754,18 +591,9 @@ func (p *parser) blockTokens(retainCurlies bool) ([]Token, error) {
|
||||
// head of the server block with tokens, which are
|
||||
// grouped by segments.
|
||||
type ServerBlock struct {
|
||||
HasBraces bool
|
||||
Keys []Token
|
||||
Segments []Segment
|
||||
IsNamedRoute bool
|
||||
}
|
||||
|
||||
func (sb ServerBlock) GetKeysText() []string {
|
||||
res := make([]string, 0, len(sb.Keys))
|
||||
for _, k := range sb.Keys {
|
||||
res = append(res, k.Text)
|
||||
}
|
||||
return res
|
||||
HasBraces bool
|
||||
Keys []string
|
||||
Segments []Segment
|
||||
}
|
||||
|
||||
// DispenseDirective returns a dispenser that contains
|
||||
|
||||
@@ -18,100 +18,14 @@ import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseVariadic(t *testing.T) {
|
||||
args := make([]string, 10)
|
||||
for i, tc := range []struct {
|
||||
input string
|
||||
result bool
|
||||
}{
|
||||
{
|
||||
input: "",
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
input: "{args[1",
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
input: "1]}",
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
input: "{args[:]}aaaaa",
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
input: "aaaaa{args[:]}",
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
input: "{args.}",
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
input: "{args.1}",
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
input: "{args[]}",
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
input: "{args[:]}",
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
input: "{args[:]}",
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
input: "{args[0:]}",
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
input: "{args[:0]}",
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
input: "{args[-1:]}",
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
input: "{args[:11]}",
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
input: "{args[10:0]}",
|
||||
result: false,
|
||||
},
|
||||
{
|
||||
input: "{args[0:10]}",
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
input: "{args[0]}:{args[1]}:{args[2]}",
|
||||
result: false,
|
||||
},
|
||||
} {
|
||||
token := Token{
|
||||
File: "test",
|
||||
Line: 1,
|
||||
Text: tc.input,
|
||||
}
|
||||
if v, _, _ := parseVariadic(token, len(args)); v != tc.result {
|
||||
t.Errorf("Test %d error expectation failed Expected: %t, got %t", i, tc.result, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllTokens(t *testing.T) {
|
||||
input := []byte("a b c\nd e")
|
||||
expected := []string{"a", "b", "c", "d", "e"}
|
||||
tokens, err := allTokens("TestAllTokens", input)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
@@ -149,11 +63,10 @@ func TestParseOneAndImport(t *testing.T) {
|
||||
"localhost",
|
||||
}, []int{1}},
|
||||
|
||||
{
|
||||
`localhost:1234
|
||||
{`localhost:1234
|
||||
dir1 foo bar`, false, []string{
|
||||
"localhost:1234",
|
||||
}, []int{3},
|
||||
"localhost:1234",
|
||||
}, []int{3},
|
||||
},
|
||||
|
||||
{`localhost {
|
||||
@@ -274,23 +187,6 @@ func TestParseOneAndImport(t *testing.T) {
|
||||
|
||||
{`import testdata/not_found.txt`, true, []string{}, []int{}},
|
||||
|
||||
// empty file should just log a warning, and result in no tokens
|
||||
{`import testdata/empty.txt`, false, []string{}, []int{}},
|
||||
|
||||
{`import testdata/only_white_space.txt`, false, []string{}, []int{}},
|
||||
|
||||
// import path/to/dir/* should skip any files that start with a . when iterating over them.
|
||||
{`localhost
|
||||
dir1 arg1
|
||||
import testdata/glob/*`, false, []string{
|
||||
"localhost",
|
||||
}, []int{2, 3, 1}},
|
||||
|
||||
// import path/to/dir/.* should continue to read all dotfiles in a dir.
|
||||
{`import testdata/glob/.*`, false, []string{
|
||||
"host1",
|
||||
}, []int{1, 2}},
|
||||
|
||||
{`""`, false, []string{}, []int{}},
|
||||
|
||||
{``, false, []string{}, []int{}},
|
||||
@@ -298,14 +194,6 @@ func TestParseOneAndImport(t *testing.T) {
|
||||
// Unexpected next token after '{' on same line
|
||||
{`localhost
|
||||
dir1 { a b }`, true, []string{"localhost"}, []int{}},
|
||||
|
||||
// Unexpected '{' on a new line
|
||||
{`localhost
|
||||
dir1
|
||||
{
|
||||
a b
|
||||
}`, true, []string{"localhost"}, []int{}},
|
||||
|
||||
// Workaround with quotes
|
||||
{`localhost
|
||||
dir1 "{" a b "}"`, false, []string{"localhost"}, []int{5}},
|
||||
@@ -348,7 +236,7 @@ func TestParseOneAndImport(t *testing.T) {
|
||||
i, len(test.keys), len(result.Keys))
|
||||
continue
|
||||
}
|
||||
for j, addr := range result.GetKeysText() {
|
||||
for j, addr := range result.Keys {
|
||||
if addr != test.keys[j] {
|
||||
t.Errorf("Test %d, key %d: Expected '%s', but was '%s'",
|
||||
i, j, test.keys[j], addr)
|
||||
@@ -380,9 +268,8 @@ func TestRecursiveImport(t *testing.T) {
|
||||
}
|
||||
|
||||
isExpected := func(got ServerBlock) bool {
|
||||
textKeys := got.GetKeysText()
|
||||
if len(textKeys) != 1 || textKeys[0] != "localhost" {
|
||||
t.Errorf("got keys unexpected: expect localhost, got %v", textKeys)
|
||||
if len(got.Keys) != 1 || got.Keys[0] != "localhost" {
|
||||
t.Errorf("got keys unexpected: expect localhost, got %v", got.Keys)
|
||||
return false
|
||||
}
|
||||
if len(got.Segments) != 2 {
|
||||
@@ -409,13 +296,13 @@ func TestRecursiveImport(t *testing.T) {
|
||||
err = os.WriteFile(recursiveFile1, []byte(
|
||||
`localhost
|
||||
dir1
|
||||
import recursive_import_test2`), 0o644)
|
||||
import recursive_import_test2`), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Remove(recursiveFile1)
|
||||
|
||||
err = os.WriteFile(recursiveFile2, []byte("dir2 1"), 0o644)
|
||||
err = os.WriteFile(recursiveFile2, []byte("dir2 1"), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -443,7 +330,7 @@ func TestRecursiveImport(t *testing.T) {
|
||||
err = os.WriteFile(recursiveFile1, []byte(
|
||||
`localhost
|
||||
dir1
|
||||
import `+recursiveFile2), 0o644)
|
||||
import `+recursiveFile2), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -476,9 +363,8 @@ func TestDirectiveImport(t *testing.T) {
|
||||
}
|
||||
|
||||
isExpected := func(got ServerBlock) bool {
|
||||
textKeys := got.GetKeysText()
|
||||
if len(textKeys) != 1 || textKeys[0] != "localhost" {
|
||||
t.Errorf("got keys unexpected: expect localhost, got %v", textKeys)
|
||||
if len(got.Keys) != 1 || got.Keys[0] != "localhost" {
|
||||
t.Errorf("got keys unexpected: expect localhost, got %v", got.Keys)
|
||||
return false
|
||||
}
|
||||
if len(got.Segments) != 2 {
|
||||
@@ -498,7 +384,7 @@ func TestDirectiveImport(t *testing.T) {
|
||||
}
|
||||
|
||||
err = os.WriteFile(directiveFile, []byte(`prop1 1
|
||||
prop2 2`), 0o644)
|
||||
prop2 2`), 0644)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -556,10 +442,6 @@ func TestParseAll(t *testing.T) {
|
||||
{"localhost:1234", "http://host2"},
|
||||
}},
|
||||
|
||||
{`foo.example.com , example.com`, false, [][]string{
|
||||
{"foo.example.com", "example.com"},
|
||||
}},
|
||||
|
||||
{`localhost:1234, http://host2,`, true, [][]string{}},
|
||||
|
||||
{`http://host1.com, http://host2.com {
|
||||
@@ -619,11 +501,11 @@ func TestParseAll(t *testing.T) {
|
||||
}
|
||||
for j, block := range blocks {
|
||||
if len(block.Keys) != len(test.keys[j]) {
|
||||
t.Errorf("Test %d: Expected %d keys in block %d, got %d: %v",
|
||||
i, len(test.keys[j]), j, len(block.Keys), block.Keys)
|
||||
t.Errorf("Test %d: Expected %d keys in block %d, got %d",
|
||||
i, len(test.keys[j]), j, len(block.Keys))
|
||||
continue
|
||||
}
|
||||
for k, addr := range block.GetKeysText() {
|
||||
for k, addr := range block.Keys {
|
||||
if addr != test.keys[j][k] {
|
||||
t.Errorf("Test %d, block %d, key %d: Expected '%s', but got '%s'",
|
||||
i, j, k, test.keys[j][k], addr)
|
||||
@@ -722,38 +604,11 @@ func TestEnvironmentReplacement(t *testing.T) {
|
||||
expect: "}{$",
|
||||
},
|
||||
} {
|
||||
actual := replaceEnvVars([]byte(test.input))
|
||||
if !bytes.Equal(actual, []byte(test.expect)) {
|
||||
t.Errorf("Test %d: Expected: '%s' but got '%s'", i, test.expect, actual)
|
||||
actual, err := replaceEnvVars([]byte(test.input))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportReplacementInJSONWithBrace(t *testing.T) {
|
||||
for i, test := range []struct {
|
||||
args []string
|
||||
input string
|
||||
expect string
|
||||
}{
|
||||
{
|
||||
args: []string{"123"},
|
||||
input: "{args[0]}",
|
||||
expect: "123",
|
||||
},
|
||||
{
|
||||
args: []string{"123"},
|
||||
input: `{"key":"{args[0]}"}`,
|
||||
expect: `{"key":"123"}`,
|
||||
},
|
||||
{
|
||||
args: []string{"123", "123"},
|
||||
input: `{"key":[{args[0]},{args[1]}]}`,
|
||||
expect: `{"key":[123,123]}`,
|
||||
},
|
||||
} {
|
||||
repl := makeArgsReplacer(test.args)
|
||||
actual := repl.ReplaceKnown(test.input, "")
|
||||
if actual != test.expect {
|
||||
if !bytes.Equal(actual, []byte(test.expect)) {
|
||||
t.Errorf("Test %d: Expected: '%s' but got '%s'", i, test.expect, actual)
|
||||
}
|
||||
}
|
||||
@@ -776,7 +631,7 @@ func TestSnippets(t *testing.T) {
|
||||
if len(blocks) != 1 {
|
||||
t.Fatalf("Expect exactly one server block. Got %d.", len(blocks))
|
||||
}
|
||||
if actual, expected := blocks[0].GetKeysText()[0], "http://example.com"; expected != actual {
|
||||
if actual, expected := blocks[0].Keys[0], "http://example.com"; expected != actual {
|
||||
t.Errorf("Expected server name to be '%s' but was '%s'", expected, actual)
|
||||
}
|
||||
if len(blocks[0].Segments) != 2 {
|
||||
@@ -808,7 +663,7 @@ func TestImportedFilesIgnoreNonDirectiveImportTokens(t *testing.T) {
|
||||
fileName := writeStringToTempFileOrDie(t, `
|
||||
http://example.com {
|
||||
# This isn't an import directive, it's just an arg with value 'import'
|
||||
basic_auth / import password
|
||||
basicauth / import password
|
||||
}
|
||||
`)
|
||||
// Parse the root file that imports the other one.
|
||||
@@ -819,12 +674,12 @@ func TestImportedFilesIgnoreNonDirectiveImportTokens(t *testing.T) {
|
||||
}
|
||||
auth := blocks[0].Segments[0]
|
||||
line := auth[0].Text + " " + auth[1].Text + " " + auth[2].Text + " " + auth[3].Text
|
||||
if line != "basic_auth / import password" {
|
||||
if line != "basicauth / import password" {
|
||||
// Previously, it would be changed to:
|
||||
// basic_auth / import /path/to/test/dir/password
|
||||
// basicauth / import /path/to/test/dir/password
|
||||
// referencing a file that (probably) doesn't exist and changing the
|
||||
// password!
|
||||
t.Errorf("Expected basic_auth tokens to be 'basic_auth / import password' but got %#q", line)
|
||||
t.Errorf("Expected basicauth tokens to be 'basicauth / import password' but got %#q", line)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,7 +706,7 @@ func TestSnippetAcrossMultipleFiles(t *testing.T) {
|
||||
if len(blocks) != 1 {
|
||||
t.Fatalf("Expect exactly one server block. Got %d.", len(blocks))
|
||||
}
|
||||
if actual, expected := blocks[0].GetKeysText()[0], "http://example.com"; expected != actual {
|
||||
if actual, expected := blocks[0].Keys[0], "http://example.com"; expected != actual {
|
||||
t.Errorf("Expected server name to be '%s' but was '%s'", expected, actual)
|
||||
}
|
||||
if len(blocks[0].Segments) != 1 {
|
||||
@@ -862,74 +717,6 @@ func TestSnippetAcrossMultipleFiles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsGlobalMatcher(t *testing.T) {
|
||||
p := testParser(`
|
||||
@rejected path /foo
|
||||
|
||||
(common) {
|
||||
gzip foo
|
||||
errors stderr
|
||||
}
|
||||
|
||||
http://example.com {
|
||||
import common
|
||||
}
|
||||
`)
|
||||
_, err := p.parseAll()
|
||||
if err == nil {
|
||||
t.Fatal("Expected an error, but got nil")
|
||||
}
|
||||
expected := "request matchers may not be defined globally, they must be in a site block; found @rejected, at Testfile:2"
|
||||
if err.Error() != expected {
|
||||
t.Errorf("Expected error to be '%s' but got '%v'", expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectAnonymousImportBlock(t *testing.T) {
|
||||
p := testParser(`
|
||||
(site) {
|
||||
http://{args[0]} https://{args[0]} {
|
||||
{block}
|
||||
}
|
||||
}
|
||||
|
||||
import site test.domain {
|
||||
{
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
`)
|
||||
_, err := p.parseAll()
|
||||
if err == nil {
|
||||
t.Fatal("Expected an error, but got nil")
|
||||
}
|
||||
expected := "anonymous blocks are not supported"
|
||||
if !strings.HasPrefix(err.Error(), "anonymous blocks are not supported") {
|
||||
t.Errorf("Expected error to start with '%s' but got '%v'", expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptSiteImportWithBraces(t *testing.T) {
|
||||
p := testParser(`
|
||||
(site) {
|
||||
http://{args[0]} https://{args[0]} {
|
||||
{block}
|
||||
}
|
||||
}
|
||||
|
||||
import site test.domain {
|
||||
reverse_proxy http://192.168.1.1:8080 {
|
||||
header_up Host {host}
|
||||
}
|
||||
}
|
||||
`)
|
||||
_, err := p.parseAll()
|
||||
if err != nil {
|
||||
t.Errorf("Expected error to be nil but got '%v'", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testParser(input string) parser {
|
||||
return parser{Dispenser: NewTestDispenser(input)}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
host1 {
|
||||
dir1
|
||||
dir2 arg1
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
dir2 arg1 arg2
|
||||
dir3
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{args[0]}
|
||||
{args.0}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{args[0]} {args[1]}
|
||||
{args.0} {args.1}
|
||||
@@ -1,7 +0,0 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
// Adapter is a type which can adapt a configuration to Caddy JSON.
|
||||
// It returns the results and any warnings, or an error.
|
||||
type Adapter interface {
|
||||
Adapt(body []byte, options map[string]any) ([]byte, []Warning, error)
|
||||
Adapt(body []byte, options map[string]interface{}) ([]byte, []Warning, error)
|
||||
}
|
||||
|
||||
// Warning represents a warning or notice related to conversion.
|
||||
@@ -48,7 +48,7 @@ func (w Warning) String() string {
|
||||
// are converted to warnings. This is convenient when filling config
|
||||
// structs that require a json.RawMessage, without having to worry
|
||||
// about errors.
|
||||
func JSON(val any, warnings *[]Warning) json.RawMessage {
|
||||
func JSON(val interface{}, warnings *[]Warning) json.RawMessage {
|
||||
b, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
if warnings != nil {
|
||||
@@ -64,9 +64,9 @@ func JSON(val any, warnings *[]Warning) json.RawMessage {
|
||||
// for encoding module values where the module name has to be described within
|
||||
// the object by a certain key; for example, `"handler": "file_server"` for a
|
||||
// file server HTTP handler (fieldName="handler" and fieldVal="file_server").
|
||||
// The val parameter must encode into a map[string]any (i.e. it must be
|
||||
// The val parameter must encode into a map[string]interface{} (i.e. it must be
|
||||
// a struct or map). Any errors are converted into warnings.
|
||||
func JSONModuleObject(val any, fieldName, fieldVal string, warnings *[]Warning) json.RawMessage {
|
||||
func JSONModuleObject(val interface{}, fieldName, fieldVal string, warnings *[]Warning) json.RawMessage {
|
||||
// encode to a JSON object first
|
||||
enc, err := json.Marshal(val)
|
||||
if err != nil {
|
||||
@@ -77,15 +77,11 @@ func JSONModuleObject(val any, fieldName, fieldVal string, warnings *[]Warning)
|
||||
}
|
||||
|
||||
// then decode the object
|
||||
var tmp map[string]any
|
||||
var tmp map[string]interface{}
|
||||
err = json.Unmarshal(enc, &tmp)
|
||||
if err != nil {
|
||||
if warnings != nil {
|
||||
message := err.Error()
|
||||
if jsonErr, ok := err.(*json.SyntaxError); ok {
|
||||
message = fmt.Sprintf("%v, at offset %d", jsonErr.Error(), jsonErr.Offset)
|
||||
}
|
||||
*warnings = append(*warnings, Warning{Message: message})
|
||||
*warnings = append(*warnings, Warning{Message: err.Error()})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,32 +17,30 @@ package httpcaddyfile
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/caddyserver/certmagic"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||||
"github.com/caddyserver/certmagic"
|
||||
)
|
||||
|
||||
// mapAddressToProtocolToServerBlocks returns a map of listener address to list of server
|
||||
// mapAddressToServerBlocks returns a map of listener address to list of server
|
||||
// blocks that will be served on that address. To do this, each server block is
|
||||
// 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
|
||||
// isn't repeated unnecessarily. For example, this Caddyfile:
|
||||
//
|
||||
// example.com {
|
||||
// bind 127.0.0.1
|
||||
// }
|
||||
// www.example.com, example.net/path, localhost:9999 {
|
||||
// bind 127.0.0.1 1.2.3.4
|
||||
// }
|
||||
// example.com {
|
||||
// bind 127.0.0.1
|
||||
// }
|
||||
// www.example.com, example.net/path, localhost:9999 {
|
||||
// bind 127.0.0.1 1.2.3.4
|
||||
// }
|
||||
//
|
||||
// has two server blocks to start with. But expressed in this Caddyfile are
|
||||
// actually 4 listener addresses: 127.0.0.1:443, 1.2.3.4:443, 127.0.0.1:9999,
|
||||
@@ -77,15 +75,9 @@ import (
|
||||
// repetition may be undesirable, so call consolidateAddrMappings() to map
|
||||
// multiple addresses to the same lists of server blocks (a many:many mapping).
|
||||
// (Doing this is essentially a map-reduce technique.)
|
||||
func (st *ServerType) mapAddressToProtocolToServerBlocks(originalServerBlocks []serverBlock,
|
||||
options map[string]any,
|
||||
) (map[string]map[string][]serverBlock, error) {
|
||||
addrToProtocolToServerBlocks := map[string]map[string][]serverBlock{}
|
||||
|
||||
type keyWithParsedKey struct {
|
||||
key caddyfile.Token
|
||||
parsedKey Address
|
||||
}
|
||||
func (st *ServerType) mapAddressToServerBlocks(originalServerBlocks []serverBlock,
|
||||
options map[string]interface{}) (map[string][]serverBlock, error) {
|
||||
sbmap := make(map[string][]serverBlock)
|
||||
|
||||
for i, sblock := range originalServerBlocks {
|
||||
// within a server block, we need to map all the listener addresses
|
||||
@@ -93,48 +85,27 @@ func (st *ServerType) mapAddressToProtocolToServerBlocks(originalServerBlocks []
|
||||
// will be served by them; this has the effect of treating each
|
||||
// key of a server block as its own, but without having to repeat its
|
||||
// contents in cases where multiple keys really can be served together
|
||||
addrToProtocolToKeyWithParsedKeys := map[string]map[string][]keyWithParsedKey{}
|
||||
addrToKeys := make(map[string][]string)
|
||||
for j, key := range sblock.block.Keys {
|
||||
parsedKey, err := ParseAddress(key.Text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing key: %v", err)
|
||||
}
|
||||
parsedKey = parsedKey.Normalize()
|
||||
|
||||
// a key can have multiple listener addresses if there are multiple
|
||||
// arguments to the 'bind' directive (although they will all have
|
||||
// the same port, since the port is defined by the key or is implicit
|
||||
// through automatic HTTPS)
|
||||
listeners, err := st.listenersForServerBlockAddress(sblock, parsedKey, options)
|
||||
addrs, err := st.listenerAddrsForServerBlockKey(sblock, key, options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("server block %d, key %d (%s): determining listener address: %v", i, j, key.Text, err)
|
||||
return nil, fmt.Errorf("server block %d, key %d (%s): determining listener address: %v", i, j, key, err)
|
||||
}
|
||||
|
||||
// associate this key with its protocols and each listener address served with them
|
||||
kwpk := keyWithParsedKey{key, parsedKey}
|
||||
for addr, protocols := range listeners {
|
||||
protocolToKeyWithParsedKeys, ok := addrToProtocolToKeyWithParsedKeys[addr]
|
||||
if !ok {
|
||||
protocolToKeyWithParsedKeys = map[string][]keyWithParsedKey{}
|
||||
addrToProtocolToKeyWithParsedKeys[addr] = protocolToKeyWithParsedKeys
|
||||
}
|
||||
|
||||
// an empty protocol indicates the default, a nil or empty value in the ListenProtocols array
|
||||
if len(protocols) == 0 {
|
||||
protocols[""] = struct{}{}
|
||||
}
|
||||
for prot := range protocols {
|
||||
protocolToKeyWithParsedKeys[prot] = append(
|
||||
protocolToKeyWithParsedKeys[prot],
|
||||
kwpk)
|
||||
}
|
||||
// associate this key with each listener address it is served on
|
||||
for _, addr := range addrs {
|
||||
addrToKeys[addr] = append(addrToKeys[addr], key)
|
||||
}
|
||||
}
|
||||
|
||||
// make a slice of the map keys so we can iterate in sorted order
|
||||
addrs := make([]string, 0, len(addrToProtocolToKeyWithParsedKeys))
|
||||
for addr := range addrToProtocolToKeyWithParsedKeys {
|
||||
addrs = append(addrs, addr)
|
||||
addrs := make([]string, 0, len(addrToKeys))
|
||||
for k := range addrToKeys {
|
||||
addrs = append(addrs, k)
|
||||
}
|
||||
sort.Strings(addrs)
|
||||
|
||||
@@ -144,142 +115,81 @@ func (st *ServerType) mapAddressToProtocolToServerBlocks(originalServerBlocks []
|
||||
// server block are only the ones which use the address; but
|
||||
// the contents (tokens) are of course the same
|
||||
for _, addr := range addrs {
|
||||
protocolToKeyWithParsedKeys := addrToProtocolToKeyWithParsedKeys[addr]
|
||||
|
||||
prots := make([]string, 0, len(protocolToKeyWithParsedKeys))
|
||||
for prot := range protocolToKeyWithParsedKeys {
|
||||
prots = append(prots, prot)
|
||||
}
|
||||
sort.Strings(prots)
|
||||
|
||||
protocolToServerBlocks, ok := addrToProtocolToServerBlocks[addr]
|
||||
if !ok {
|
||||
protocolToServerBlocks = map[string][]serverBlock{}
|
||||
addrToProtocolToServerBlocks[addr] = protocolToServerBlocks
|
||||
}
|
||||
|
||||
for _, prot := range prots {
|
||||
keyWithParsedKeys := protocolToKeyWithParsedKeys[prot]
|
||||
|
||||
keys := make([]caddyfile.Token, len(keyWithParsedKeys))
|
||||
parsedKeys := make([]Address, len(keyWithParsedKeys))
|
||||
|
||||
for k, keyWithParsedKey := range keyWithParsedKeys {
|
||||
keys[k] = keyWithParsedKey.key
|
||||
parsedKeys[k] = keyWithParsedKey.parsedKey
|
||||
keys := addrToKeys[addr]
|
||||
// parse keys so that we only have to do it once
|
||||
parsedKeys := make([]Address, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
addr, err := ParseAddress(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing key '%s': %v", key, err)
|
||||
}
|
||||
|
||||
protocolToServerBlocks[prot] = append(protocolToServerBlocks[prot], serverBlock{
|
||||
block: caddyfile.ServerBlock{
|
||||
Keys: keys,
|
||||
Segments: sblock.block.Segments,
|
||||
},
|
||||
pile: sblock.pile,
|
||||
parsedKeys: parsedKeys,
|
||||
})
|
||||
parsedKeys = append(parsedKeys, addr.Normalize())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return addrToProtocolToServerBlocks, nil
|
||||
}
|
||||
|
||||
// consolidateAddrMappings eliminates repetition of identical server blocks in a mapping of
|
||||
// single listener addresses to protocols to lists of server blocks. Since multiple addresses
|
||||
// may serve multiple protocols to identical sites (server block contents), this function turns
|
||||
// a 1:many mapping into a many:many mapping. Server block contents (tokens) must be
|
||||
// exactly identical so that reflect.DeepEqual returns true in order for the addresses to be combined.
|
||||
// Identical entries are deleted from the addrToServerBlocks map. Essentially, each pairing (each
|
||||
// association from multiple addresses to multiple server blocks; i.e. each element of
|
||||
// the returned slice) becomes a server definition in the output JSON.
|
||||
func (st *ServerType) consolidateAddrMappings(addrToProtocolToServerBlocks map[string]map[string][]serverBlock) []sbAddrAssociation {
|
||||
sbaddrs := make([]sbAddrAssociation, 0, len(addrToProtocolToServerBlocks))
|
||||
|
||||
addrs := make([]string, 0, len(addrToProtocolToServerBlocks))
|
||||
for addr := range addrToProtocolToServerBlocks {
|
||||
addrs = append(addrs, addr)
|
||||
}
|
||||
sort.Strings(addrs)
|
||||
|
||||
for _, addr := range addrs {
|
||||
protocolToServerBlocks := addrToProtocolToServerBlocks[addr]
|
||||
|
||||
prots := make([]string, 0, len(protocolToServerBlocks))
|
||||
for prot := range protocolToServerBlocks {
|
||||
prots = append(prots, prot)
|
||||
}
|
||||
sort.Strings(prots)
|
||||
|
||||
for _, prot := range prots {
|
||||
serverBlocks := protocolToServerBlocks[prot]
|
||||
|
||||
// now find other addresses that map to identical
|
||||
// server blocks and add them to our map of listener
|
||||
// addresses and protocols, while removing them from
|
||||
// the original map
|
||||
listeners := map[string]map[string]struct{}{}
|
||||
|
||||
for otherAddr, otherProtocolToServerBlocks := range addrToProtocolToServerBlocks {
|
||||
for otherProt, otherServerBlocks := range otherProtocolToServerBlocks {
|
||||
if addr == otherAddr && prot == otherProt || reflect.DeepEqual(serverBlocks, otherServerBlocks) {
|
||||
listener, ok := listeners[otherAddr]
|
||||
if !ok {
|
||||
listener = map[string]struct{}{}
|
||||
listeners[otherAddr] = listener
|
||||
}
|
||||
listener[otherProt] = struct{}{}
|
||||
delete(otherProtocolToServerBlocks, otherProt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addresses := make([]string, 0, len(listeners))
|
||||
for lnAddr := range listeners {
|
||||
addresses = append(addresses, lnAddr)
|
||||
}
|
||||
sort.Strings(addresses)
|
||||
|
||||
addressesWithProtocols := make([]addressWithProtocols, 0, len(listeners))
|
||||
|
||||
for _, lnAddr := range addresses {
|
||||
lnProts := listeners[lnAddr]
|
||||
prots := make([]string, 0, len(lnProts))
|
||||
for prot := range lnProts {
|
||||
prots = append(prots, prot)
|
||||
}
|
||||
sort.Strings(prots)
|
||||
|
||||
addressesWithProtocols = append(addressesWithProtocols, addressWithProtocols{
|
||||
address: lnAddr,
|
||||
protocols: prots,
|
||||
})
|
||||
}
|
||||
|
||||
sbaddrs = append(sbaddrs, sbAddrAssociation{
|
||||
addressesWithProtocols: addressesWithProtocols,
|
||||
serverBlocks: serverBlocks,
|
||||
sbmap[addr] = append(sbmap[addr], serverBlock{
|
||||
block: caddyfile.ServerBlock{
|
||||
Keys: keys,
|
||||
Segments: sblock.block.Segments,
|
||||
},
|
||||
pile: sblock.pile,
|
||||
keys: parsedKeys,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return sbmap, nil
|
||||
}
|
||||
|
||||
// consolidateAddrMappings eliminates repetition of identical server blocks in a mapping of
|
||||
// single listener addresses to lists of server blocks. Since multiple addresses may serve
|
||||
// identical sites (server block contents), this function turns a 1:many mapping into a
|
||||
// many:many mapping. Server block contents (tokens) must be exactly identical so that
|
||||
// reflect.DeepEqual returns true in order for the addresses to be combined. Identical
|
||||
// entries are deleted from the addrToServerBlocks map. Essentially, each pairing (each
|
||||
// association from multiple addresses to multiple server blocks; i.e. each element of
|
||||
// the returned slice) becomes a server definition in the output JSON.
|
||||
func (st *ServerType) consolidateAddrMappings(addrToServerBlocks map[string][]serverBlock) []sbAddrAssociation {
|
||||
sbaddrs := make([]sbAddrAssociation, 0, len(addrToServerBlocks))
|
||||
for addr, sblocks := range addrToServerBlocks {
|
||||
// we start with knowing that at least this address
|
||||
// maps to these server blocks
|
||||
a := sbAddrAssociation{
|
||||
addresses: []string{addr},
|
||||
serverBlocks: sblocks,
|
||||
}
|
||||
|
||||
// now find other addresses that map to identical
|
||||
// server blocks and add them to our list of
|
||||
// addresses, while removing them from the map
|
||||
for otherAddr, otherSblocks := range addrToServerBlocks {
|
||||
if addr == otherAddr {
|
||||
continue
|
||||
}
|
||||
if reflect.DeepEqual(sblocks, otherSblocks) {
|
||||
a.addresses = append(a.addresses, otherAddr)
|
||||
delete(addrToServerBlocks, otherAddr)
|
||||
}
|
||||
}
|
||||
sort.Strings(a.addresses)
|
||||
|
||||
sbaddrs = append(sbaddrs, a)
|
||||
}
|
||||
|
||||
// sort them by their first address (we know there will always be at least one)
|
||||
// to avoid problems with non-deterministic ordering (makes tests flaky)
|
||||
sort.Slice(sbaddrs, func(i, j int) bool {
|
||||
return sbaddrs[i].addresses[0] < sbaddrs[j].addresses[0]
|
||||
})
|
||||
|
||||
return sbaddrs
|
||||
}
|
||||
|
||||
// listenersForServerBlockAddress essentially converts the Caddyfile site addresses to a map from
|
||||
// Caddy listener addresses and the protocols to serve them with to the parsed address for each server block.
|
||||
func (st *ServerType) listenersForServerBlockAddress(sblock serverBlock, addr Address,
|
||||
options map[string]any,
|
||||
) (map[string]map[string]struct{}, error) {
|
||||
switch addr.Scheme {
|
||||
case "wss":
|
||||
return nil, fmt.Errorf("the scheme wss:// is only supported in browsers; use https:// instead")
|
||||
case "ws":
|
||||
return nil, fmt.Errorf("the scheme ws:// is only supported in browsers; use http:// instead")
|
||||
case "https", "http", "":
|
||||
// Do nothing or handle the valid schemes
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported URL scheme %s://", addr.Scheme)
|
||||
func (st *ServerType) listenerAddrsForServerBlockKey(sblock serverBlock, key string,
|
||||
options map[string]interface{}) ([]string, error) {
|
||||
addr, err := ParseAddress(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing key: %v", err)
|
||||
}
|
||||
addr = addr.Normalize()
|
||||
|
||||
// figure out the HTTP and HTTPS ports; either
|
||||
// use defaults, or override with user config
|
||||
@@ -303,58 +213,41 @@ func (st *ServerType) listenersForServerBlockAddress(sblock serverBlock, addr Ad
|
||||
|
||||
// error if scheme and port combination violate convention
|
||||
if (addr.Scheme == "http" && lnPort == httpsPort) || (addr.Scheme == "https" && lnPort == httpPort) {
|
||||
return nil, fmt.Errorf("[%s] scheme and port violate convention", addr.String())
|
||||
return nil, fmt.Errorf("[%s] scheme and port violate convention", key)
|
||||
}
|
||||
|
||||
// the bind directive specifies hosts (and potentially network), and the protocols to serve them with, but is optional
|
||||
lnCfgVals := make([]addressesWithProtocols, 0, len(sblock.pile["bind"]))
|
||||
// the bind directive specifies hosts, but is optional
|
||||
lnHosts := make([]string, 0, len(sblock.pile["bind"]))
|
||||
for _, cfgVal := range sblock.pile["bind"] {
|
||||
if val, ok := cfgVal.Value.(addressesWithProtocols); ok {
|
||||
lnCfgVals = append(lnCfgVals, val)
|
||||
}
|
||||
lnHosts = append(lnHosts, cfgVal.Value.([]string)...)
|
||||
}
|
||||
if len(lnCfgVals) == 0 {
|
||||
if defaultBindValues, ok := options["default_bind"].([]ConfigValue); ok {
|
||||
for _, defaultBindValue := range defaultBindValues {
|
||||
lnCfgVals = append(lnCfgVals, defaultBindValue.Value.(addressesWithProtocols))
|
||||
}
|
||||
if len(lnHosts) == 0 {
|
||||
if defaultBind, ok := options["default_bind"].([]string); ok {
|
||||
lnHosts = defaultBind
|
||||
} else {
|
||||
lnCfgVals = []addressesWithProtocols{{
|
||||
addresses: []string{""},
|
||||
protocols: nil,
|
||||
}}
|
||||
lnHosts = []string{""}
|
||||
}
|
||||
}
|
||||
|
||||
// use a map to prevent duplication
|
||||
listeners := map[string]map[string]struct{}{}
|
||||
for _, lnCfgVal := range lnCfgVals {
|
||||
for _, lnAddr := range lnCfgVal.addresses {
|
||||
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 {
|
||||
return nil, fmt.Errorf("parsing network address: %v", err)
|
||||
}
|
||||
if _, ok := listeners[addr.String()]; !ok {
|
||||
listeners[networkAddr.String()] = map[string]struct{}{}
|
||||
}
|
||||
for _, protocol := range lnCfgVal.protocols {
|
||||
listeners[networkAddr.String()][protocol] = struct{}{}
|
||||
}
|
||||
listeners := make(map[string]struct{})
|
||||
for _, host := range lnHosts {
|
||||
addr, err := caddy.ParseNetworkAddress(host)
|
||||
if err == nil && addr.IsUnixNetwork() {
|
||||
listeners[host] = struct{}{}
|
||||
} else {
|
||||
listeners[host+":"+lnPort] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return listeners, nil
|
||||
}
|
||||
// now turn map into list
|
||||
listenersList := make([]string, 0, len(listeners))
|
||||
for lnStr := range listeners {
|
||||
listenersList = append(listenersList, lnStr)
|
||||
}
|
||||
sort.Strings(listenersList)
|
||||
|
||||
// addressesWithProtocols associates a list of listen addresses
|
||||
// with a list of protocols to serve them with
|
||||
type addressesWithProtocols struct {
|
||||
addresses []string
|
||||
protocols []string
|
||||
return listenersList, nil
|
||||
}
|
||||
|
||||
// Address represents a site address. It contains
|
||||
@@ -457,9 +350,9 @@ func (a Address) Normalize() Address {
|
||||
|
||||
// ensure host is normalized if it's an IP address
|
||||
host := strings.TrimSpace(a.Host)
|
||||
if ip, err := netip.ParseAddr(host); err == nil {
|
||||
if ip.Is6() && !ip.Is4() && !ip.Is4In6() {
|
||||
host = ip.String()
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
if ipv6 := ip.To16(); ipv6 != nil && ipv6.DefaultMask() == nil {
|
||||
host = ipv6.String()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
//go:build gofuzz
|
||||
// +build gofuzz
|
||||
|
||||
package httpcaddyfile
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
package httpcaddyfile
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
||||
@@ -25,12 +24,11 @@ func TestLogDirectiveSyntax(t *testing.T) {
|
||||
{
|
||||
input: `:8080 {
|
||||
log {
|
||||
core mock
|
||||
output file foo.log
|
||||
}
|
||||
}
|
||||
`,
|
||||
output: `{"logging":{"logs":{"default":{"exclude":["http.log.access.log0"]},"log0":{"writer":{"filename":"foo.log","output":"file"},"core":{"module":"mock"},"include":["http.log.access.log0"]}}},"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"logs":{"default_logger_name":"log0"}}}}}}`,
|
||||
output: `{"logging":{"logs":{"default":{"exclude":["http.log.access.log0"]},"log0":{"writer":{"filename":"foo.log","output":"file"},"include":["http.log.access.log0"]}}},"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"logs":{"default_logger_name":"log0"}}}}}}`,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
@@ -53,28 +51,12 @@ func TestLogDirectiveSyntax(t *testing.T) {
|
||||
},
|
||||
{
|
||||
input: `:8080 {
|
||||
log name-override {
|
||||
core mock
|
||||
log invalid {
|
||||
output file foo.log
|
||||
}
|
||||
}
|
||||
`,
|
||||
output: `{"logging":{"logs":{"default":{"exclude":["http.log.access.name-override"]},"name-override":{"writer":{"filename":"foo.log","output":"file"},"core":{"module":"mock"},"include":["http.log.access.name-override"]}}},"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"logs":{"default_logger_name":"name-override"}}}}}}`,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
input: `:8080 {
|
||||
log {
|
||||
sampling {
|
||||
interval 2
|
||||
first 3
|
||||
thereafter 4
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
output: `{"logging":{"logs":{"default":{"exclude":["http.log.access.log0"]},"log0":{"sampling":{"interval":2,"first":3,"thereafter":4},"include":["http.log.access.log0"]}}},"apps":{"http":{"servers":{"srv0":{"listen":[":8080"],"logs":{"default_logger_name":"log0"}}}}}}`,
|
||||
expectError: false,
|
||||
expectError: true,
|
||||
},
|
||||
} {
|
||||
|
||||
@@ -231,139 +213,3 @@ func TestRedirDirectiveSyntax(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportErrorLine(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
input string
|
||||
errorFunc func(err error) bool
|
||||
}{
|
||||
{
|
||||
input: `(t1) {
|
||||
abort {args[:]}
|
||||
}
|
||||
:8080 {
|
||||
import t1
|
||||
import t1 true
|
||||
}`,
|
||||
errorFunc: func(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "Caddyfile:6 (import t1)")
|
||||
},
|
||||
},
|
||||
{
|
||||
input: `(t1) {
|
||||
abort {args[:]}
|
||||
}
|
||||
:8080 {
|
||||
import t1 true
|
||||
}`,
|
||||
errorFunc: func(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "Caddyfile:5 (import t1)")
|
||||
},
|
||||
},
|
||||
{
|
||||
input: `
|
||||
import testdata/import_variadic_snippet.txt
|
||||
:8080 {
|
||||
import t1 true
|
||||
}`,
|
||||
errorFunc: func(err error) bool {
|
||||
return err == nil
|
||||
},
|
||||
},
|
||||
{
|
||||
input: `
|
||||
import testdata/import_variadic_with_import.txt
|
||||
:8080 {
|
||||
import t1 true
|
||||
import t2 true
|
||||
}`,
|
||||
errorFunc: func(err error) bool {
|
||||
return err == nil
|
||||
},
|
||||
},
|
||||
} {
|
||||
adapter := caddyfile.Adapter{
|
||||
ServerType: ServerType{},
|
||||
}
|
||||
|
||||
_, _, err := adapter.Adapt([]byte(tc.input), nil)
|
||||
|
||||
if !tc.errorFunc(err) {
|
||||
t.Errorf("Test %d error expectation failed, got %s", i, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedImport(t *testing.T) {
|
||||
for i, tc := range []struct {
|
||||
input string
|
||||
errorFunc func(err error) bool
|
||||
}{
|
||||
{
|
||||
input: `(t1) {
|
||||
respond {args[0]} {args[1]}
|
||||
}
|
||||
|
||||
(t2) {
|
||||
import t1 {args[0]} 202
|
||||
}
|
||||
|
||||
:8080 {
|
||||
handle {
|
||||
import t2 "foobar"
|
||||
}
|
||||
}`,
|
||||
errorFunc: func(err error) bool {
|
||||
return err == nil
|
||||
},
|
||||
},
|
||||
{
|
||||
input: `(t1) {
|
||||
respond {args[:]}
|
||||
}
|
||||
|
||||
(t2) {
|
||||
import t1 {args[0]} {args[1]}
|
||||
}
|
||||
|
||||
:8080 {
|
||||
handle {
|
||||
import t2 "foobar" 202
|
||||
}
|
||||
}`,
|
||||
errorFunc: func(err error) bool {
|
||||
return err == nil
|
||||
},
|
||||
},
|
||||
{
|
||||
input: `(t1) {
|
||||
respond {args[0]} {args[1]}
|
||||
}
|
||||
|
||||
(t2) {
|
||||
import t1 {args[:]}
|
||||
}
|
||||
|
||||
:8080 {
|
||||
handle {
|
||||
import t2 "foobar" 202
|
||||
}
|
||||
}`,
|
||||
errorFunc: func(err error) bool {
|
||||
return err == nil
|
||||
},
|
||||
},
|
||||
} {
|
||||
adapter := caddyfile.Adapter{
|
||||
ServerType: ServerType{},
|
||||
}
|
||||
|
||||
_, _, err := adapter.Adapt([]byte(tc.input), nil)
|
||||
|
||||
if !tc.errorFunc(err) {
|
||||
t.Errorf("Test %d error expectation failed, got %s", i, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,7 @@ package httpcaddyfile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"net"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -29,33 +27,21 @@ import (
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||||
)
|
||||
|
||||
// defaultDirectiveOrder specifies the default order
|
||||
// to apply directives in HTTP routes. This must only
|
||||
// consist of directives that are included in Caddy's
|
||||
// standard distribution.
|
||||
// directiveOrder specifies the order
|
||||
// to apply directives in HTTP routes.
|
||||
//
|
||||
// e.g. The 'root' directive goes near the start in
|
||||
// case rewrites or redirects depend on existence of
|
||||
// files, i.e. the file matcher, which must know the
|
||||
// root first.
|
||||
// The root directive goes first in case rewrites or
|
||||
// redirects depend on existence of files, i.e. the
|
||||
// file matcher, which must know the root first.
|
||||
//
|
||||
// e.g. The 'header' directive goes before 'redir' so
|
||||
// that headers can be manipulated before doing redirects.
|
||||
//
|
||||
// e.g. The 'respond' directive is near the end because it
|
||||
// writes a response and terminates the middleware chain.
|
||||
var defaultDirectiveOrder = []string{
|
||||
// The header directive goes second so that headers
|
||||
// can be manipulated before doing redirects.
|
||||
var directiveOrder = []string{
|
||||
"tracing",
|
||||
|
||||
// set variables that may be used by other directives
|
||||
"map",
|
||||
"vars",
|
||||
"fs",
|
||||
"root",
|
||||
"log_append",
|
||||
"skip_log", // TODO: deprecated, renamed to log_skip
|
||||
"log_skip",
|
||||
"log_name",
|
||||
|
||||
"header",
|
||||
"copy_response_headers", // only in reverse_proxy's handle_response
|
||||
@@ -70,17 +56,14 @@ var defaultDirectiveOrder = []string{
|
||||
"try_files",
|
||||
|
||||
// middleware handlers; some wrap responses
|
||||
"basicauth", // TODO: deprecated, renamed to basic_auth
|
||||
"basic_auth",
|
||||
"basicauth",
|
||||
"forward_auth",
|
||||
"request_header",
|
||||
"encode",
|
||||
"push",
|
||||
"intercept",
|
||||
"templates",
|
||||
|
||||
// special routing & dispatching directives
|
||||
"invoke",
|
||||
"handle",
|
||||
"handle_path",
|
||||
"route",
|
||||
@@ -97,10 +80,16 @@ var defaultDirectiveOrder = []string{
|
||||
"acme_server",
|
||||
}
|
||||
|
||||
// directiveOrder specifies the order to apply directives
|
||||
// in HTTP routes, after being modified by either the
|
||||
// plugins or by the user via the "order" global option.
|
||||
var directiveOrder = defaultDirectiveOrder
|
||||
// directiveIsOrdered returns true if dir is
|
||||
// a known, ordered (sorted) directive.
|
||||
func directiveIsOrdered(dir string) bool {
|
||||
for _, d := range directiveOrder {
|
||||
if d == dir {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RegisterDirective registers a unique directive dir with an
|
||||
// associated unmarshaling (setup) function. When directive dir
|
||||
@@ -137,55 +126,6 @@ func RegisterHandlerDirective(dir string, setupFunc UnmarshalHandlerFunc) {
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterDirectiveOrder registers the default order for a
|
||||
// directive from a plugin.
|
||||
//
|
||||
// This is useful when a plugin has a well-understood place
|
||||
// it should run in the middleware pipeline, and it allows
|
||||
// users to avoid having to define the order themselves.
|
||||
//
|
||||
// The directive dir may be placed in the position relative
|
||||
// to ('before' or 'after') a directive included in Caddy's
|
||||
// standard distribution. It cannot be relative to another
|
||||
// plugin's directive.
|
||||
//
|
||||
// EXPERIMENTAL: This API may change or be removed.
|
||||
func RegisterDirectiveOrder(dir string, position Positional, standardDir string) {
|
||||
// check if directive was already ordered
|
||||
if slices.Contains(directiveOrder, dir) {
|
||||
panic("directive '" + dir + "' already ordered")
|
||||
}
|
||||
|
||||
if position != Before && position != After {
|
||||
panic("the 2nd argument must be either 'before' or 'after', got '" + position + "'")
|
||||
}
|
||||
|
||||
// check if directive exists in standard distribution, since
|
||||
// we can't allow plugins to depend on one another; we can't
|
||||
// guarantee the order that plugins are loaded in.
|
||||
foundStandardDir := slices.Contains(defaultDirectiveOrder, standardDir)
|
||||
if !foundStandardDir {
|
||||
panic("the 3rd argument '" + standardDir + "' must be a directive that exists in the standard distribution of Caddy")
|
||||
}
|
||||
|
||||
// insert directive into proper position
|
||||
newOrder := directiveOrder
|
||||
for i, d := range newOrder {
|
||||
if d != standardDir {
|
||||
continue
|
||||
}
|
||||
switch position {
|
||||
case Before:
|
||||
newOrder = append(newOrder[:i], append([]string{dir}, newOrder[i:]...)...)
|
||||
case After:
|
||||
newOrder = append(newOrder[:i+1], append([]string{dir}, newOrder[i+1:]...)...)
|
||||
case First, Last:
|
||||
}
|
||||
break
|
||||
}
|
||||
directiveOrder = newOrder
|
||||
}
|
||||
|
||||
// RegisterGlobalOption registers a unique global option opt with
|
||||
// an associated unmarshaling (setup) function. When the global
|
||||
// option opt is encountered in a Caddyfile, setupFunc will be
|
||||
@@ -202,8 +142,8 @@ func RegisterGlobalOption(opt string, setupFunc UnmarshalGlobalFunc) {
|
||||
type Helper struct {
|
||||
*caddyfile.Dispenser
|
||||
// State stores intermediate variables during caddyfile adaptation.
|
||||
State map[string]any
|
||||
options map[string]any
|
||||
State map[string]interface{}
|
||||
options map[string]interface{}
|
||||
warnings *[]caddyconfig.Warning
|
||||
matcherDefs map[string]caddy.ModuleMap
|
||||
parentBlock caddyfile.ServerBlock
|
||||
@@ -211,7 +151,7 @@ type Helper struct {
|
||||
}
|
||||
|
||||
// Option gets the option keyed by name.
|
||||
func (h Helper) Option(name string) any {
|
||||
func (h Helper) Option(name string) interface{} {
|
||||
return h.options[name]
|
||||
}
|
||||
|
||||
@@ -231,12 +171,11 @@ func (h Helper) Caddyfiles() []string {
|
||||
for file := range files {
|
||||
filesSlice = append(filesSlice, file)
|
||||
}
|
||||
sort.Strings(filesSlice)
|
||||
return filesSlice
|
||||
}
|
||||
|
||||
// JSON converts val into JSON. Any errors are added to warnings.
|
||||
func (h Helper) JSON(val any) json.RawMessage {
|
||||
func (h Helper) JSON(val interface{}) json.RawMessage {
|
||||
return caddyconfig.JSON(val, h.warnings)
|
||||
}
|
||||
|
||||
@@ -275,8 +214,7 @@ func (h Helper) ExtractMatcherSet() (caddy.ModuleMap, error) {
|
||||
|
||||
// NewRoute returns config values relevant to creating a new HTTP route.
|
||||
func (h Helper) NewRoute(matcherSet caddy.ModuleMap,
|
||||
handler caddyhttp.MiddlewareHandler,
|
||||
) []ConfigValue {
|
||||
handler caddyhttp.MiddlewareHandler) []ConfigValue {
|
||||
mod, err := caddy.GetModule(caddy.GetModuleID(handler))
|
||||
if err != nil {
|
||||
*h.warnings = append(*h.warnings, caddyconfig.Warning{
|
||||
@@ -328,6 +266,12 @@ func (h Helper) GroupRoutes(vals []ConfigValue) {
|
||||
}
|
||||
}
|
||||
|
||||
// NewBindAddresses returns config values relevant to adding
|
||||
// listener bind addresses to the config.
|
||||
func (h Helper) NewBindAddresses(addrs []string) []ConfigValue {
|
||||
return []ConfigValue{{Class: "bind", Value: addrs}}
|
||||
}
|
||||
|
||||
// WithDispenser returns a new instance based on d. All others Helper
|
||||
// fields are copied, so typically maps are shared with this new instance.
|
||||
func (h Helper) WithDispenser(d *caddyfile.Dispenser) Helper {
|
||||
@@ -344,7 +288,7 @@ func ParseSegmentAsSubroute(h Helper) (caddyhttp.MiddlewareHandler, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buildSubroute(allResults, h.groupCounter, true)
|
||||
return buildSubroute(allResults, h.groupCounter)
|
||||
}
|
||||
|
||||
// parseSegmentAsConfig parses the segment such that its subdirectives
|
||||
@@ -368,7 +312,9 @@ func parseSegmentAsConfig(h Helper) ([]ConfigValue, error) {
|
||||
// copy existing matcher definitions so we can augment
|
||||
// new ones that are defined only in this scope
|
||||
matcherDefs := make(map[string]caddy.ModuleMap, len(h.matcherDefs))
|
||||
maps.Copy(matcherDefs, h.matcherDefs)
|
||||
for key, val := range h.matcherDefs {
|
||||
matcherDefs[key] = val
|
||||
}
|
||||
|
||||
// find and extract any embedded matcher definitions in this scope
|
||||
for i := 0; i < len(segments); i++ {
|
||||
@@ -429,7 +375,7 @@ type ConfigValue struct {
|
||||
// The value to be used when building the config.
|
||||
// Generally its type is associated with the
|
||||
// name of the Class.
|
||||
Value any
|
||||
Value interface{}
|
||||
|
||||
directive string
|
||||
}
|
||||
@@ -460,7 +406,7 @@ func sortRoutes(routes []ConfigValue) {
|
||||
return false
|
||||
}
|
||||
|
||||
// decode the path matchers if there is just one matcher set
|
||||
// decode the path matchers, if there is just one of them
|
||||
var iPM, jPM caddyhttp.MatchPath
|
||||
if len(iRoute.MatcherSetsRaw) == 1 {
|
||||
_ = json.Unmarshal(iRoute.MatcherSetsRaw[0]["path"], &iPM)
|
||||
@@ -469,64 +415,39 @@ func sortRoutes(routes []ConfigValue) {
|
||||
_ = json.Unmarshal(jRoute.MatcherSetsRaw[0]["path"], &jPM)
|
||||
}
|
||||
|
||||
// if there is only one path in the path matcher, sort by longer path
|
||||
// (more specific) first; missing path matchers or multi-matchers are
|
||||
// treated as zero-length paths
|
||||
// sort by longer path (more specific) first; missing path
|
||||
// matchers or multi-matchers are treated as zero-length paths
|
||||
var iPathLen, jPathLen int
|
||||
if len(iPM) == 1 {
|
||||
if len(iPM) > 0 {
|
||||
iPathLen = len(iPM[0])
|
||||
}
|
||||
if len(jPM) == 1 {
|
||||
if len(jPM) > 0 {
|
||||
jPathLen = len(jPM[0])
|
||||
}
|
||||
|
||||
sortByPath := func() bool {
|
||||
// we can only confidently compare path lengths if both
|
||||
// directives have a single path to match (issue #5037)
|
||||
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,
|
||||
// sort by the shorter path first (which is more specific)
|
||||
if iPathTrimmed == jPathTrimmed {
|
||||
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
|
||||
return iPathLen > jPathLen
|
||||
// some directives involve setting values which can overwrite
|
||||
// eachother, so it makes most sense to reverse the order so
|
||||
// that the lease specific matcher is first; everything else
|
||||
// has most-specific matcher first
|
||||
if iDir == "vars" {
|
||||
// if both directives have no path matcher, use whichever one
|
||||
// has no matcher first.
|
||||
if iPathLen == 0 && jPathLen == 0 {
|
||||
return len(iRoute.MatcherSetsRaw) == 0 && len(jRoute.MatcherSetsRaw) > 0
|
||||
}
|
||||
|
||||
// if both directives don't have a single path to compare,
|
||||
// sort whichever one has a matcher first; if both have
|
||||
// a matcher, sort equally (stable sort preserves order)
|
||||
return len(iRoute.MatcherSetsRaw) > 0 && len(jRoute.MatcherSetsRaw) == 0
|
||||
}()
|
||||
// sort with the least-specific (shortest) path first
|
||||
return iPathLen < jPathLen
|
||||
} else {
|
||||
// if both directives have no path matcher, use whichever one
|
||||
// has any kind of matcher defined first.
|
||||
if iPathLen == 0 && jPathLen == 0 {
|
||||
return len(iRoute.MatcherSetsRaw) > 0 && len(jRoute.MatcherSetsRaw) == 0
|
||||
}
|
||||
|
||||
// some directives involve setting values which can overwrite
|
||||
// each other, so it makes most sense to reverse the order so
|
||||
// that the least-specific matcher is first, allowing the last
|
||||
// matching one to win
|
||||
if iDir == "vars" {
|
||||
return !sortByPath
|
||||
// sort with the most-specific (longest) path first
|
||||
return iPathLen > jPathLen
|
||||
}
|
||||
|
||||
// everything else is most-specific matcher first
|
||||
return sortByPath
|
||||
})
|
||||
}
|
||||
|
||||
@@ -534,9 +455,9 @@ func sortRoutes(routes []ConfigValue) {
|
||||
// a "pile" of config values, keyed by class name,
|
||||
// as well as its parsed keys for convenience.
|
||||
type serverBlock struct {
|
||||
block caddyfile.ServerBlock
|
||||
pile map[string][]ConfigValue // config values obtained from directives
|
||||
parsedKeys []Address
|
||||
block caddyfile.ServerBlock
|
||||
pile map[string][]ConfigValue // config values obtained from directives
|
||||
keys []Address
|
||||
}
|
||||
|
||||
// hostsFromKeys returns a list of all the non-empty hostnames found in
|
||||
@@ -553,7 +474,7 @@ type serverBlock struct {
|
||||
func (sb serverBlock) hostsFromKeys(loggerMode bool) []string {
|
||||
// ensure each entry in our list is unique
|
||||
hostMap := make(map[string]struct{})
|
||||
for _, addr := range sb.parsedKeys {
|
||||
for _, addr := range sb.keys {
|
||||
if addr.Host == "" {
|
||||
if !loggerMode {
|
||||
// server block contains a key like ":443", i.e. the host portion
|
||||
@@ -585,7 +506,7 @@ func (sb serverBlock) hostsFromKeys(loggerMode bool) []string {
|
||||
func (sb serverBlock) hostsFromKeysNotHTTP(httpPort string) []string {
|
||||
// ensure each entry in our list is unique
|
||||
hostMap := make(map[string]struct{})
|
||||
for _, addr := range sb.parsedKeys {
|
||||
for _, addr := range sb.keys {
|
||||
if addr.Host == "" {
|
||||
continue
|
||||
}
|
||||
@@ -606,29 +527,25 @@ func (sb serverBlock) hostsFromKeysNotHTTP(httpPort string) []string {
|
||||
// hasHostCatchAllKey returns true if sb has a key that
|
||||
// omits a host portion, i.e. it "catches all" hosts.
|
||||
func (sb serverBlock) hasHostCatchAllKey() bool {
|
||||
return slices.ContainsFunc(sb.parsedKeys, func(addr Address) bool {
|
||||
return addr.Host == ""
|
||||
})
|
||||
for _, addr := range sb.keys {
|
||||
if addr.Host == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isAllHTTP returns true if all sb keys explicitly specify
|
||||
// the http:// scheme
|
||||
func (sb serverBlock) isAllHTTP() bool {
|
||||
return !slices.ContainsFunc(sb.parsedKeys, func(addr Address) bool {
|
||||
return addr.Scheme != "http"
|
||||
})
|
||||
for _, addr := range sb.keys {
|
||||
if addr.Scheme != "http" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Positional are the supported modes for ordering directives.
|
||||
type Positional string
|
||||
|
||||
const (
|
||||
Before Positional = "before"
|
||||
After Positional = "after"
|
||||
First Positional = "first"
|
||||
Last Positional = "last"
|
||||
)
|
||||
|
||||
type (
|
||||
// UnmarshalFunc is a function which can unmarshal Caddyfile
|
||||
// tokens into zero or more config values using a Helper type.
|
||||
@@ -650,7 +567,7 @@ type (
|
||||
// tokens from a global option. It is passed the tokens to parse and
|
||||
// existing value from the previous instance of this global option
|
||||
// (if any). It returns the value to associate with this global option.
|
||||
UnmarshalGlobalFunc func(d *caddyfile.Dispenser, existingVal any) (any, error)
|
||||
UnmarshalGlobalFunc func(d *caddyfile.Dispenser, existingVal interface{}) (interface{}, error)
|
||||
)
|
||||
|
||||
var registeredDirectives = make(map[string]UnmarshalFunc)
|
||||
|
||||
@@ -31,23 +31,20 @@ func TestHostsFromKeys(t *testing.T) {
|
||||
[]Address{
|
||||
{Original: ":2015", Port: "2015"},
|
||||
},
|
||||
[]string{},
|
||||
[]string{},
|
||||
[]string{}, []string{},
|
||||
},
|
||||
{
|
||||
[]Address{
|
||||
{Original: ":443", Port: "443"},
|
||||
},
|
||||
[]string{},
|
||||
[]string{},
|
||||
[]string{}, []string{},
|
||||
},
|
||||
{
|
||||
[]Address{
|
||||
{Original: "foo", Host: "foo"},
|
||||
{Original: ":2015", Port: "2015"},
|
||||
},
|
||||
[]string{},
|
||||
[]string{"foo"},
|
||||
[]string{}, []string{"foo"},
|
||||
},
|
||||
{
|
||||
[]Address{
|
||||
@@ -78,7 +75,7 @@ func TestHostsFromKeys(t *testing.T) {
|
||||
[]string{"example.com:2015"},
|
||||
},
|
||||
} {
|
||||
sb := serverBlock{parsedKeys: tc.keys}
|
||||
sb := serverBlock{keys: tc.keys}
|
||||
|
||||
// test in normal mode
|
||||
actual := sb.hostsFromKeys(false)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,38 +15,30 @@
|
||||
package httpcaddyfile
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/caddyserver/certmagic"
|
||||
"github.com/libdns/libdns"
|
||||
"github.com/mholt/acmez/v3/acme"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddytls"
|
||||
"github.com/caddyserver/certmagic"
|
||||
"github.com/mholt/acmez/acme"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterGlobalOption("debug", parseOptTrue)
|
||||
RegisterGlobalOption("http_port", parseOptHTTPPort)
|
||||
RegisterGlobalOption("https_port", parseOptHTTPSPort)
|
||||
RegisterGlobalOption("default_bind", parseOptDefaultBind)
|
||||
RegisterGlobalOption("default_bind", parseOptStringList)
|
||||
RegisterGlobalOption("grace_period", parseOptDuration)
|
||||
RegisterGlobalOption("shutdown_delay", parseOptDuration)
|
||||
RegisterGlobalOption("default_sni", parseOptSingleString)
|
||||
RegisterGlobalOption("fallback_sni", parseOptSingleString)
|
||||
RegisterGlobalOption("order", parseOptOrder)
|
||||
RegisterGlobalOption("storage", parseOptStorage)
|
||||
RegisterGlobalOption("storage_check", parseStorageCheck)
|
||||
RegisterGlobalOption("storage_clean_interval", parseStorageCleanInterval)
|
||||
RegisterGlobalOption("storage_clean_interval", parseOptDuration)
|
||||
RegisterGlobalOption("renew_interval", parseOptDuration)
|
||||
RegisterGlobalOption("ocsp_interval", parseOptDuration)
|
||||
RegisterGlobalOption("acme_ca", parseOptSingleString)
|
||||
RegisterGlobalOption("acme_ca_root", parseOptSingleString)
|
||||
RegisterGlobalOption("acme_dns", parseOptDNS)
|
||||
RegisterGlobalOption("acme_dns", parseOptACMEDNS)
|
||||
RegisterGlobalOption("acme_eab", parseOptACMEEAB)
|
||||
RegisterGlobalOption("cert_issuer", parseOptCertIssuer)
|
||||
RegisterGlobalOption("skip_install_trust", parseOptTrue)
|
||||
@@ -56,126 +48,123 @@ func init() {
|
||||
RegisterGlobalOption("local_certs", parseOptTrue)
|
||||
RegisterGlobalOption("key_type", parseOptSingleString)
|
||||
RegisterGlobalOption("auto_https", parseOptAutoHTTPS)
|
||||
RegisterGlobalOption("metrics", parseMetricsOptions)
|
||||
RegisterGlobalOption("servers", parseServerOptions)
|
||||
RegisterGlobalOption("ocsp_stapling", parseOCSPStaplingOptions)
|
||||
RegisterGlobalOption("cert_lifetime", parseOptDuration)
|
||||
RegisterGlobalOption("log", parseLogOptions)
|
||||
RegisterGlobalOption("preferred_chains", parseOptPreferredChains)
|
||||
RegisterGlobalOption("persist_config", parseOptPersistConfig)
|
||||
RegisterGlobalOption("dns", parseOptDNS)
|
||||
RegisterGlobalOption("ech", parseOptECH)
|
||||
RegisterGlobalOption("renewal_window_ratio", parseOptRenewalWindowRatio)
|
||||
}
|
||||
|
||||
func parseOptTrue(d *caddyfile.Dispenser, _ any) (any, error) { return true, nil }
|
||||
func parseOptTrue(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) { return true, nil }
|
||||
|
||||
func parseOptHTTPPort(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
d.Next() // consume option name
|
||||
func parseOptHTTPPort(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) {
|
||||
var httpPort int
|
||||
var httpPortStr string
|
||||
if !d.AllArgs(&httpPortStr) {
|
||||
return 0, d.ArgErr()
|
||||
}
|
||||
var err error
|
||||
httpPort, err = strconv.Atoi(httpPortStr)
|
||||
if err != nil {
|
||||
return 0, d.Errf("converting port '%s' to integer value: %v", httpPortStr, err)
|
||||
for d.Next() {
|
||||
var httpPortStr string
|
||||
if !d.AllArgs(&httpPortStr) {
|
||||
return 0, d.ArgErr()
|
||||
}
|
||||
var err error
|
||||
httpPort, err = strconv.Atoi(httpPortStr)
|
||||
if err != nil {
|
||||
return 0, d.Errf("converting port '%s' to integer value: %v", httpPortStr, err)
|
||||
}
|
||||
}
|
||||
return httpPort, nil
|
||||
}
|
||||
|
||||
func parseOptHTTPSPort(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
d.Next() // consume option name
|
||||
func parseOptHTTPSPort(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) {
|
||||
var httpsPort int
|
||||
var httpsPortStr string
|
||||
if !d.AllArgs(&httpsPortStr) {
|
||||
return 0, d.ArgErr()
|
||||
}
|
||||
var err error
|
||||
httpsPort, err = strconv.Atoi(httpsPortStr)
|
||||
if err != nil {
|
||||
return 0, d.Errf("converting port '%s' to integer value: %v", httpsPortStr, err)
|
||||
for d.Next() {
|
||||
var httpsPortStr string
|
||||
if !d.AllArgs(&httpsPortStr) {
|
||||
return 0, d.ArgErr()
|
||||
}
|
||||
var err error
|
||||
httpsPort, err = strconv.Atoi(httpsPortStr)
|
||||
if err != nil {
|
||||
return 0, d.Errf("converting port '%s' to integer value: %v", httpsPortStr, err)
|
||||
}
|
||||
}
|
||||
return httpsPort, nil
|
||||
}
|
||||
|
||||
func parseOptOrder(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
d.Next() // consume option name
|
||||
func parseOptOrder(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) {
|
||||
newOrder := directiveOrder
|
||||
|
||||
// get directive name
|
||||
if !d.Next() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
dirName := d.Val()
|
||||
if _, ok := registeredDirectives[dirName]; !ok {
|
||||
return nil, d.Errf("%s is not a registered directive", dirName)
|
||||
}
|
||||
for d.Next() {
|
||||
// get directive name
|
||||
if !d.Next() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
dirName := d.Val()
|
||||
if _, ok := registeredDirectives[dirName]; !ok {
|
||||
return nil, d.Errf("%s is not a registered directive", dirName)
|
||||
}
|
||||
|
||||
// get positional token
|
||||
if !d.Next() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pos := Positional(d.Val())
|
||||
// get positional token
|
||||
if !d.Next() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pos := d.Val()
|
||||
|
||||
// if directive already had an order, drop it
|
||||
newOrder := slices.DeleteFunc(directiveOrder, func(d string) bool {
|
||||
return d == dirName
|
||||
})
|
||||
// if directive exists, first remove it
|
||||
for i, d := range newOrder {
|
||||
if d == dirName {
|
||||
newOrder = append(newOrder[:i], newOrder[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// act on the positional; if it's First or Last, we're done right away
|
||||
switch pos {
|
||||
case First:
|
||||
newOrder = append([]string{dirName}, newOrder...)
|
||||
// act on the positional
|
||||
switch pos {
|
||||
case "first":
|
||||
newOrder = append([]string{dirName}, newOrder...)
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
directiveOrder = newOrder
|
||||
return newOrder, nil
|
||||
case "last":
|
||||
newOrder = append(newOrder, dirName)
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
directiveOrder = newOrder
|
||||
return newOrder, nil
|
||||
case "before":
|
||||
case "after":
|
||||
default:
|
||||
return nil, d.Errf("unknown positional '%s'", pos)
|
||||
}
|
||||
|
||||
// get name of other directive
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
otherDir := d.Val()
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
directiveOrder = newOrder
|
||||
return newOrder, nil
|
||||
|
||||
case Last:
|
||||
newOrder = append(newOrder, dirName)
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
// insert directive into proper position
|
||||
for i, d := range newOrder {
|
||||
if d == otherDir {
|
||||
if pos == "before" {
|
||||
newOrder = append(newOrder[:i], append([]string{dirName}, newOrder[i:]...)...)
|
||||
} else if pos == "after" {
|
||||
newOrder = append(newOrder[:i+1], append([]string{dirName}, newOrder[i+1:]...)...)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
directiveOrder = newOrder
|
||||
return newOrder, nil
|
||||
|
||||
// if it's Before or After, continue
|
||||
case Before:
|
||||
case After:
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unknown positional '%s'", pos)
|
||||
}
|
||||
|
||||
// get name of other directive
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
otherDir := d.Val()
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
|
||||
// get the position of the target directive
|
||||
targetIndex := slices.Index(newOrder, otherDir)
|
||||
if targetIndex == -1 {
|
||||
return nil, d.Errf("directive '%s' not found", otherDir)
|
||||
}
|
||||
// if we're inserting after, we need to increment the index to go after
|
||||
if pos == After {
|
||||
targetIndex++
|
||||
}
|
||||
// insert the directive into the new order
|
||||
newOrder = slices.Insert(newOrder, targetIndex, dirName)
|
||||
|
||||
directiveOrder = newOrder
|
||||
|
||||
return newOrder, nil
|
||||
}
|
||||
|
||||
func parseOptStorage(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
func parseOptStorage(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) {
|
||||
if !d.Next() { // consume option name
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
@@ -194,41 +183,7 @@ func parseOptStorage(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
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, _ interface{}) (interface{}, error) {
|
||||
if !d.Next() { // consume option name
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
@@ -242,60 +197,78 @@ func parseOptDuration(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
return caddy.Duration(dur), nil
|
||||
}
|
||||
|
||||
func parseOptACMEEAB(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
eab := new(acme.EAB)
|
||||
d.Next() // consume option name
|
||||
if d.NextArg() {
|
||||
func parseOptACMEDNS(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) {
|
||||
if !d.Next() { // consume option name
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
for d.NextBlock(0) {
|
||||
switch d.Val() {
|
||||
case "key_id":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
eab.KeyID = d.Val()
|
||||
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.ACMEDNSProvider)
|
||||
if !ok {
|
||||
return nil, d.Errf("module %s (%T) is not a certmagic.ACMEDNSProvider", modID, unm)
|
||||
}
|
||||
return prov, nil
|
||||
}
|
||||
|
||||
case "mac_key":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
eab.MACKey = d.Val()
|
||||
func parseOptACMEEAB(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) {
|
||||
eab := new(acme.EAB)
|
||||
for d.Next() {
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
switch d.Val() {
|
||||
case "key_id":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
eab.KeyID = d.Val()
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unrecognized parameter '%s'", d.Val())
|
||||
case "mac_key":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
eab.MACKey = d.Val()
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unrecognized parameter '%s'", d.Val())
|
||||
}
|
||||
}
|
||||
}
|
||||
return eab, nil
|
||||
}
|
||||
|
||||
func parseOptCertIssuer(d *caddyfile.Dispenser, existing any) (any, error) {
|
||||
d.Next() // consume option name
|
||||
|
||||
func parseOptCertIssuer(d *caddyfile.Dispenser, existing interface{}) (interface{}, error) {
|
||||
var issuers []certmagic.Issuer
|
||||
if existing != nil {
|
||||
issuers = existing.([]certmagic.Issuer)
|
||||
}
|
||||
|
||||
// get issuer module name
|
||||
if !d.Next() {
|
||||
return nil, d.ArgErr()
|
||||
for d.Next() { // consume option name
|
||||
if !d.Next() { // get issuer module name
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
modID := "tls.issuance." + d.Val()
|
||||
unm, err := caddyfile.UnmarshalModule(d, modID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iss, ok := unm.(certmagic.Issuer)
|
||||
if !ok {
|
||||
return nil, d.Errf("module %s (%T) is not a certmagic.Issuer", modID, unm)
|
||||
}
|
||||
issuers = append(issuers, iss)
|
||||
}
|
||||
modID := "tls.issuance." + d.Val()
|
||||
unm, err := caddyfile.UnmarshalModule(d, modID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iss, ok := unm.(certmagic.Issuer)
|
||||
if !ok {
|
||||
return nil, d.Errf("module %s (%T) is not a certmagic.Issuer", modID, unm)
|
||||
}
|
||||
issuers = append(issuers, iss)
|
||||
return issuers, nil
|
||||
}
|
||||
|
||||
func parseOptSingleString(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
d.Next() // consume option name
|
||||
func parseOptSingleString(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) {
|
||||
d.Next() // consume parameter name
|
||||
if !d.Next() {
|
||||
return "", d.ArgErr()
|
||||
}
|
||||
@@ -306,62 +279,43 @@ func parseOptSingleString(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func parseOptDefaultBind(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
d.Next() // consume option name
|
||||
|
||||
var addresses, protocols []string
|
||||
addresses = d.RemainingArgs()
|
||||
|
||||
if len(addresses) == 0 {
|
||||
addresses = append(addresses, "")
|
||||
func parseOptStringList(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) {
|
||||
d.Next() // consume parameter name
|
||||
val := d.RemainingArgs()
|
||||
if len(val) == 0 {
|
||||
return "", d.ArgErr()
|
||||
}
|
||||
|
||||
for d.NextBlock(0) {
|
||||
switch d.Val() {
|
||||
case "protocols":
|
||||
protocols = d.RemainingArgs()
|
||||
if len(protocols) == 0 {
|
||||
return nil, d.Errf("protocols requires one or more arguments")
|
||||
}
|
||||
default:
|
||||
return nil, d.Errf("unknown subdirective: %s", d.Val())
|
||||
}
|
||||
}
|
||||
|
||||
return []ConfigValue{{Class: "bind", Value: addressesWithProtocols{
|
||||
addresses: addresses,
|
||||
protocols: protocols,
|
||||
}}}, nil
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func parseOptAdmin(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
d.Next() // consume option name
|
||||
|
||||
func parseOptAdmin(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) {
|
||||
adminCfg := new(caddy.AdminConfig)
|
||||
if d.NextArg() {
|
||||
listenAddress := d.Val()
|
||||
if listenAddress == "off" {
|
||||
adminCfg.Disabled = true
|
||||
if d.Next() { // Do not accept any remaining options including block
|
||||
return nil, d.Err("No more option is allowed after turning off admin config")
|
||||
}
|
||||
} else {
|
||||
adminCfg.Listen = listenAddress
|
||||
if d.NextArg() { // At most 1 arg is allowed
|
||||
return nil, d.ArgErr()
|
||||
for d.Next() {
|
||||
if d.NextArg() {
|
||||
listenAddress := d.Val()
|
||||
if listenAddress == "off" {
|
||||
adminCfg.Disabled = true
|
||||
if d.Next() { // Do not accept any remaining options including block
|
||||
return nil, d.Err("No more option is allowed after turning off admin config")
|
||||
}
|
||||
} else {
|
||||
adminCfg.Listen = listenAddress
|
||||
if d.NextArg() { // At most 1 arg is allowed
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for d.NextBlock(0) {
|
||||
switch d.Val() {
|
||||
case "enforce_origin":
|
||||
adminCfg.EnforceOrigin = true
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
switch d.Val() {
|
||||
case "enforce_origin":
|
||||
adminCfg.EnforceOrigin = true
|
||||
|
||||
case "origins":
|
||||
adminCfg.Origins = d.RemainingArgs()
|
||||
case "origins":
|
||||
adminCfg.Origins = d.RemainingArgs()
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unrecognized parameter '%s'", d.Val())
|
||||
default:
|
||||
return nil, d.Errf("unrecognized parameter '%s'", d.Val())
|
||||
}
|
||||
}
|
||||
}
|
||||
if adminCfg.Listen == "" && !adminCfg.Disabled {
|
||||
@@ -370,59 +324,58 @@ func parseOptAdmin(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
return adminCfg, nil
|
||||
}
|
||||
|
||||
func parseOptOnDemand(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
d.Next() // consume option name
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
|
||||
func parseOptOnDemand(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) {
|
||||
var ond *caddytls.OnDemandConfig
|
||||
for d.Next() {
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
switch d.Val() {
|
||||
case "ask":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
if ond == nil {
|
||||
ond = new(caddytls.OnDemandConfig)
|
||||
}
|
||||
ond.Ask = d.Val()
|
||||
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
switch d.Val() {
|
||||
case "ask":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
if ond == nil {
|
||||
ond = new(caddytls.OnDemandConfig)
|
||||
}
|
||||
if ond.PermissionRaw != nil {
|
||||
return nil, d.Err("on-demand TLS permission module (or 'ask') already specified")
|
||||
}
|
||||
perm := caddytls.PermissionByHTTP{Endpoint: d.Val()}
|
||||
ond.PermissionRaw = caddyconfig.JSONModuleObject(perm, "module", "http", nil)
|
||||
case "interval":
|
||||
if !d.NextArg() {
|
||||
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 "permission":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
if ond == nil {
|
||||
ond = new(caddytls.OnDemandConfig)
|
||||
}
|
||||
if ond.PermissionRaw != nil {
|
||||
return nil, d.Err("on-demand TLS permission module (or 'ask') already specified")
|
||||
}
|
||||
modName := d.Val()
|
||||
modID := "tls.permission." + modName
|
||||
unm, err := caddyfile.UnmarshalModule(d, modID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
perm, ok := unm.(caddytls.OnDemandPermission)
|
||||
if !ok {
|
||||
return nil, d.Errf("module %s (%T) is not an on-demand TLS permission module", modID, unm)
|
||||
}
|
||||
ond.PermissionRaw = caddyconfig.JSONModuleObject(perm, "module", modName, nil)
|
||||
case "burst":
|
||||
if !d.NextArg() {
|
||||
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
|
||||
|
||||
case "interval":
|
||||
return nil, d.Errf("the on_demand_tls 'interval' option is no longer supported, remove it from your config")
|
||||
|
||||
case "burst":
|
||||
return nil, d.Errf("the on_demand_tls 'burst' option is no longer supported, remove it from your config")
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unrecognized parameter '%s'", d.Val())
|
||||
default:
|
||||
return nil, d.Errf("unrecognized parameter '%s'", d.Val())
|
||||
}
|
||||
}
|
||||
}
|
||||
if ond == nil {
|
||||
@@ -431,8 +384,8 @@ func parseOptOnDemand(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
return ond, nil
|
||||
}
|
||||
|
||||
func parseOptPersistConfig(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
d.Next() // consume option name
|
||||
func parseOptAutoHTTPS(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) {
|
||||
d.Next() // consume parameter name
|
||||
if !d.Next() {
|
||||
return "", d.ArgErr()
|
||||
}
|
||||
@@ -440,56 +393,17 @@ func parseOptPersistConfig(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
if d.Next() {
|
||||
return "", d.ArgErr()
|
||||
}
|
||||
if val != "off" {
|
||||
return "", d.Errf("persist_config must be 'off'")
|
||||
if val != "off" && val != "disable_redirects" && val != "disable_certs" && val != "ignore_loaded_certs" {
|
||||
return "", d.Errf("auto_https must be one of 'off', 'disable_redirects', 'disable_certs', or 'ignore_loaded_certs'")
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
func parseOptAutoHTTPS(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
d.Next() // consume option name
|
||||
val := d.RemainingArgs()
|
||||
if len(val) == 0 {
|
||||
return "", d.ArgErr()
|
||||
}
|
||||
for _, v := range val {
|
||||
switch v {
|
||||
case "off":
|
||||
case "disable_redirects":
|
||||
case "disable_certs":
|
||||
case "ignore_loaded_certs":
|
||||
default:
|
||||
return "", d.Errf("auto_https must be one of 'off', 'disable_redirects', 'disable_certs', or 'ignore_loaded_certs'")
|
||||
}
|
||||
}
|
||||
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
|
||||
case "observe_catchall_hosts":
|
||||
metrics.ObserveCatchallHosts = 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, _ interface{}) (interface{}, error) {
|
||||
return unmarshalCaddyfileServerOptions(d)
|
||||
}
|
||||
|
||||
func parseOCSPStaplingOptions(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
func parseOCSPStaplingOptions(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) {
|
||||
d.Next() // consume option name
|
||||
var val string
|
||||
if !d.AllArgs(&val) {
|
||||
@@ -505,17 +419,18 @@ func parseOCSPStaplingOptions(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
|
||||
// parseLogOptions parses the global log option. Syntax:
|
||||
//
|
||||
// log [name] {
|
||||
// output <writer_module> ...
|
||||
// format <encoder_module> ...
|
||||
// level <level>
|
||||
// include <namespaces...>
|
||||
// exclude <namespaces...>
|
||||
// }
|
||||
// log [name] {
|
||||
// output <writer_module> ...
|
||||
// format <encoder_module> ...
|
||||
// level <level>
|
||||
// include <namespaces...>
|
||||
// exclude <namespaces...>
|
||||
// }
|
||||
//
|
||||
// When the name argument is unspecified, this directive modifies the default
|
||||
// logger.
|
||||
func parseLogOptions(d *caddyfile.Dispenser, existingVal any) (any, error) {
|
||||
//
|
||||
func parseLogOptions(d *caddyfile.Dispenser, existingVal interface{}) (interface{}, error) {
|
||||
currentNames := make(map[string]struct{})
|
||||
if existingVal != nil {
|
||||
innerVals, ok := existingVal.([]ConfigValue)
|
||||
@@ -550,97 +465,7 @@ func parseLogOptions(d *caddyfile.Dispenser, existingVal any) (any, error) {
|
||||
return configValues, nil
|
||||
}
|
||||
|
||||
func parseOptPreferredChains(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
func parseOptPreferredChains(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) {
|
||||
d.Next()
|
||||
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
|
||||
}
|
||||
|
||||
func parseOptRenewalWindowRatio(d *caddyfile.Dispenser, _ any) (any, error) {
|
||||
d.Next() // consume option name
|
||||
if !d.Next() {
|
||||
return 0, d.ArgErr()
|
||||
}
|
||||
val := d.Val()
|
||||
ratio, err := strconv.ParseFloat(val, 64)
|
||||
if err != nil {
|
||||
return 0, d.Errf("parsing renewal_window_ratio: %v", err)
|
||||
}
|
||||
if ratio <= 0 || ratio >= 1 {
|
||||
return 0, d.Errf("renewal_window_ratio must be between 0 and 1 (exclusive)")
|
||||
}
|
||||
if d.Next() {
|
||||
return 0, d.ArgErr()
|
||||
}
|
||||
return ratio, nil
|
||||
}
|
||||
|
||||
+124
-169
@@ -15,10 +15,6 @@
|
||||
package httpcaddyfile
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddypki"
|
||||
@@ -28,190 +24,150 @@ func init() {
|
||||
RegisterGlobalOption("pki", parsePKIApp)
|
||||
}
|
||||
|
||||
// parsePKIApp parses the global pki option. Syntax:
|
||||
// parsePKIApp parses the global log option. Syntax:
|
||||
//
|
||||
// pki {
|
||||
// ca [<id>] {
|
||||
// name <name>
|
||||
// root_cn <name>
|
||||
// intermediate_cn <name>
|
||||
// intermediate_lifetime <duration>
|
||||
// maintenance_interval <duration>
|
||||
// renewal_window_ratio <ratio>
|
||||
// root {
|
||||
// cert <path>
|
||||
// key <path>
|
||||
// format <format>
|
||||
// }
|
||||
// intermediate {
|
||||
// cert <path>
|
||||
// key <path>
|
||||
// format <format>
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// pki {
|
||||
// ca [<id>] {
|
||||
// name <name>
|
||||
// root_cn <name>
|
||||
// intermediate_cn <name>
|
||||
// root {
|
||||
// cert <path>
|
||||
// key <path>
|
||||
// format <format>
|
||||
// }
|
||||
// intermediate {
|
||||
// cert <path>
|
||||
// key <path>
|
||||
// format <format>
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// When the CA ID is unspecified, 'local' is assumed.
|
||||
func parsePKIApp(d *caddyfile.Dispenser, existingVal any) (any, error) {
|
||||
d.Next() // consume app name
|
||||
//
|
||||
func parsePKIApp(d *caddyfile.Dispenser, existingVal interface{}) (interface{}, error) {
|
||||
pki := &caddypki.PKI{CAs: make(map[string]*caddypki.CA)}
|
||||
|
||||
pki := &caddypki.PKI{
|
||||
CAs: make(map[string]*caddypki.CA),
|
||||
}
|
||||
for d.NextBlock(0) {
|
||||
switch d.Val() {
|
||||
case "ca":
|
||||
pkiCa := new(caddypki.CA)
|
||||
if d.NextArg() {
|
||||
pkiCa.ID = d.Val()
|
||||
for d.Next() {
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
switch d.Val() {
|
||||
case "ca":
|
||||
pkiCa := new(caddypki.CA)
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
pkiCa.ID = d.Val()
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
}
|
||||
}
|
||||
if pkiCa.ID == "" {
|
||||
pkiCa.ID = caddypki.DefaultCAID
|
||||
}
|
||||
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
switch d.Val() {
|
||||
case "name":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.Name = d.Val()
|
||||
|
||||
case "root_cn":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.RootCommonName = d.Val()
|
||||
|
||||
case "intermediate_cn":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.IntermediateCommonName = d.Val()
|
||||
|
||||
case "intermediate_lifetime":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkiCa.IntermediateLifetime = caddy.Duration(dur)
|
||||
|
||||
case "maintenance_interval":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pkiCa.MaintenanceInterval = caddy.Duration(dur)
|
||||
|
||||
case "renewal_window_ratio":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
ratio, err := strconv.ParseFloat(d.Val(), 64)
|
||||
if err != nil || ratio <= 0 || ratio > 1 {
|
||||
return nil, d.Errf("renewal_window_ratio must be a number in (0, 1], got %s", d.Val())
|
||||
}
|
||||
pkiCa.RenewalWindowRatio = ratio
|
||||
|
||||
case "root":
|
||||
if pkiCa.Root == nil {
|
||||
pkiCa.Root = new(caddypki.KeyPair)
|
||||
}
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
switch d.Val() {
|
||||
case "cert":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.Root.Certificate = d.Val()
|
||||
|
||||
case "key":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.Root.PrivateKey = d.Val()
|
||||
|
||||
case "format":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.Root.Format = d.Val()
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unrecognized pki ca root option '%s'", d.Val())
|
||||
}
|
||||
}
|
||||
|
||||
case "intermediate":
|
||||
if pkiCa.Intermediate == nil {
|
||||
pkiCa.Intermediate = new(caddypki.KeyPair)
|
||||
}
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
switch d.Val() {
|
||||
case "cert":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.Intermediate.Certificate = d.Val()
|
||||
|
||||
case "key":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.Intermediate.PrivateKey = d.Val()
|
||||
|
||||
case "format":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.Intermediate.Format = d.Val()
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unrecognized pki ca intermediate option '%s'", d.Val())
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unrecognized pki ca option '%s'", d.Val())
|
||||
if pkiCa.ID == "" {
|
||||
pkiCa.ID = caddypki.DefaultCAID
|
||||
}
|
||||
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
switch d.Val() {
|
||||
case "name":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.Name = d.Val()
|
||||
|
||||
case "root_cn":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.RootCommonName = d.Val()
|
||||
|
||||
case "intermediate_cn":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.IntermediateCommonName = d.Val()
|
||||
|
||||
case "root":
|
||||
if pkiCa.Root == nil {
|
||||
pkiCa.Root = new(caddypki.KeyPair)
|
||||
}
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
switch d.Val() {
|
||||
case "cert":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.Root.Certificate = d.Val()
|
||||
|
||||
case "key":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.Root.PrivateKey = d.Val()
|
||||
|
||||
case "format":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.Root.Format = d.Val()
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unrecognized pki ca root option '%s'", d.Val())
|
||||
}
|
||||
}
|
||||
|
||||
case "intermediate":
|
||||
if pkiCa.Intermediate == nil {
|
||||
pkiCa.Intermediate = new(caddypki.KeyPair)
|
||||
}
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
switch d.Val() {
|
||||
case "cert":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.Intermediate.Certificate = d.Val()
|
||||
|
||||
case "key":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.Intermediate.PrivateKey = d.Val()
|
||||
|
||||
case "format":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
pkiCa.Intermediate.Format = d.Val()
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unrecognized pki ca intermediate option '%s'", d.Val())
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unrecognized pki ca option '%s'", d.Val())
|
||||
}
|
||||
}
|
||||
|
||||
pki.CAs[pkiCa.ID] = pkiCa
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unrecognized pki option '%s'", d.Val())
|
||||
}
|
||||
|
||||
pki.CAs[pkiCa.ID] = pkiCa
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unrecognized pki option '%s'", d.Val())
|
||||
}
|
||||
}
|
||||
|
||||
return pki, nil
|
||||
}
|
||||
|
||||
func (st ServerType) buildPKIApp(
|
||||
pairings []sbAddrAssociation,
|
||||
options map[string]any,
|
||||
options map[string]interface{},
|
||||
warnings []caddyconfig.Warning,
|
||||
) (*caddypki.PKI, []caddyconfig.Warning, error) {
|
||||
|
||||
skipInstallTrust := false
|
||||
if _, ok := options["skip_install_trust"]; ok {
|
||||
skipInstallTrust = true
|
||||
}
|
||||
|
||||
// check if auto_https is off - in that case we should not create
|
||||
// any PKI infrastructure even with skip_install_trust directive
|
||||
autoHTTPS := []string{}
|
||||
if ah, ok := options["auto_https"].([]string); ok {
|
||||
autoHTTPS = ah
|
||||
}
|
||||
autoHTTPSOff := slices.Contains(autoHTTPS, "off")
|
||||
|
||||
falseBool := false
|
||||
|
||||
// Load the PKI app configured via global options
|
||||
@@ -252,8 +208,7 @@ func (st ServerType) buildPKIApp(
|
||||
// if there was no CAs defined in any of the servers,
|
||||
// and we were requested to not install trust, then
|
||||
// add one for the default/local CA to do so
|
||||
// only if auto_https is not completely disabled
|
||||
if len(pkiApp.CAs) == 0 && skipInstallTrust && !autoHTTPSOff {
|
||||
if len(pkiApp.CAs) == 0 && skipInstallTrust {
|
||||
ca := new(caddypki.CA)
|
||||
ca.ID = caddypki.DefaultCAID
|
||||
ca.InstallTrust = &falseBool
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
// Copyright 2015 Matthew Holt and The Caddy Authors
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package httpcaddyfile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
||||
)
|
||||
|
||||
func TestParsePKIApp_maintenanceIntervalAndRenewalWindowRatio(t *testing.T) {
|
||||
input := `{
|
||||
pki {
|
||||
ca local {
|
||||
maintenance_interval 5m
|
||||
renewal_window_ratio 0.15
|
||||
}
|
||||
}
|
||||
}
|
||||
:8080 {
|
||||
}
|
||||
`
|
||||
adapter := caddyfile.Adapter{ServerType: ServerType{}}
|
||||
out, _, err := adapter.Adapt([]byte(input), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Adapt failed: %v", err)
|
||||
}
|
||||
|
||||
var cfg struct {
|
||||
Apps struct {
|
||||
PKI struct {
|
||||
CertificateAuthorities map[string]struct {
|
||||
MaintenanceInterval int64 `json:"maintenance_interval,omitempty"`
|
||||
RenewalWindowRatio float64 `json:"renewal_window_ratio,omitempty"`
|
||||
} `json:"certificate_authorities,omitempty"`
|
||||
} `json:"pki,omitempty"`
|
||||
} `json:"apps"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &cfg); err != nil {
|
||||
t.Fatalf("unmarshal config: %v", err)
|
||||
}
|
||||
|
||||
ca, ok := cfg.Apps.PKI.CertificateAuthorities["local"]
|
||||
if !ok {
|
||||
t.Fatal("expected certificate_authorities.local to exist")
|
||||
}
|
||||
wantInterval := 5 * time.Minute.Nanoseconds()
|
||||
if ca.MaintenanceInterval != wantInterval {
|
||||
t.Errorf("maintenance_interval = %d, want %d (5m)", ca.MaintenanceInterval, wantInterval)
|
||||
}
|
||||
if ca.RenewalWindowRatio != 0.15 {
|
||||
t.Errorf("renewal_window_ratio = %v, want 0.15", ca.RenewalWindowRatio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePKIApp_renewalWindowRatioInvalid(t *testing.T) {
|
||||
input := `{
|
||||
pki {
|
||||
ca local {
|
||||
renewal_window_ratio 1.5
|
||||
}
|
||||
}
|
||||
}
|
||||
:8080 {
|
||||
}
|
||||
`
|
||||
adapter := caddyfile.Adapter{ServerType: ServerType{}}
|
||||
_, _, err := adapter.Adapt([]byte(input), nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for renewal_window_ratio > 1")
|
||||
}
|
||||
}
|
||||
@@ -17,15 +17,12 @@ package httpcaddyfile
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||||
"github.com/dustin/go-humanize"
|
||||
)
|
||||
|
||||
// serverOptions collects server config overrides parsed from Caddyfile global options
|
||||
@@ -36,295 +33,147 @@ type serverOptions struct {
|
||||
ListenerAddress string
|
||||
|
||||
// These will all map 1:1 to the caddyhttp.Server struct
|
||||
Name string
|
||||
ListenerWrappersRaw []json.RawMessage
|
||||
PacketConnWrappersRaw []json.RawMessage
|
||||
ReadTimeout caddy.Duration
|
||||
ReadHeaderTimeout caddy.Duration
|
||||
WriteTimeout caddy.Duration
|
||||
IdleTimeout caddy.Duration
|
||||
KeepAliveInterval caddy.Duration
|
||||
KeepAliveIdle caddy.Duration
|
||||
KeepAliveCount int
|
||||
MaxHeaderBytes int
|
||||
EnableFullDuplex bool
|
||||
Protocols []string
|
||||
StrictSNIHost *bool
|
||||
TrustedProxiesRaw json.RawMessage
|
||||
TrustedProxiesStrict int
|
||||
TrustedProxiesUnix bool
|
||||
ClientIPHeaders []string
|
||||
ShouldLogCredentials bool
|
||||
Metrics *caddyhttp.Metrics
|
||||
Trace bool // TODO: EXPERIMENTAL
|
||||
// If set, overrides whether QUIC listeners allow 0-RTT (early data).
|
||||
// If nil, the default behavior is used (currently allowed).
|
||||
Allow0RTT *bool
|
||||
ListenerWrappersRaw []json.RawMessage
|
||||
ReadTimeout caddy.Duration
|
||||
ReadHeaderTimeout caddy.Duration
|
||||
WriteTimeout caddy.Duration
|
||||
IdleTimeout caddy.Duration
|
||||
MaxHeaderBytes int
|
||||
AllowH2C bool
|
||||
ExperimentalHTTP3 bool
|
||||
StrictSNIHost *bool
|
||||
ShouldLogCredentials bool
|
||||
}
|
||||
|
||||
func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
|
||||
d.Next() // consume option name
|
||||
|
||||
func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (interface{}, error) {
|
||||
serverOpts := serverOptions{}
|
||||
if d.NextArg() {
|
||||
serverOpts.ListenerAddress = d.Val()
|
||||
for d.Next() {
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
serverOpts.ListenerAddress = d.Val()
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
}
|
||||
}
|
||||
for d.NextBlock(0) {
|
||||
switch d.Val() {
|
||||
case "name":
|
||||
if serverOpts.ListenerAddress == "" {
|
||||
return nil, d.Errf("cannot set a name for a server without a listener address")
|
||||
}
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
serverOpts.Name = d.Val()
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
switch d.Val() {
|
||||
case "listener_wrappers":
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
modID := "caddy.listeners." + d.Val()
|
||||
unm, err := caddyfile.UnmarshalModule(d, modID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
listenerWrapper, ok := unm.(caddy.ListenerWrapper)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("module %s (%T) is not a listener wrapper", modID, unm)
|
||||
}
|
||||
jsonListenerWrapper := caddyconfig.JSONModuleObject(
|
||||
listenerWrapper,
|
||||
"wrapper",
|
||||
listenerWrapper.(caddy.Module).CaddyModule().ID.Name(),
|
||||
nil,
|
||||
)
|
||||
serverOpts.ListenerWrappersRaw = append(serverOpts.ListenerWrappersRaw, jsonListenerWrapper)
|
||||
}
|
||||
|
||||
case "listener_wrappers":
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
modID := "caddy.listeners." + d.Val()
|
||||
unm, err := caddyfile.UnmarshalModule(d, modID)
|
||||
case "timeouts":
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
switch d.Val() {
|
||||
case "read_body":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing read_body timeout duration: %v", err)
|
||||
}
|
||||
serverOpts.ReadTimeout = caddy.Duration(dur)
|
||||
|
||||
case "read_header":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing read_header timeout duration: %v", err)
|
||||
}
|
||||
serverOpts.ReadHeaderTimeout = caddy.Duration(dur)
|
||||
|
||||
case "write":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing write timeout duration: %v", err)
|
||||
}
|
||||
serverOpts.WriteTimeout = caddy.Duration(dur)
|
||||
|
||||
case "idle":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing idle timeout duration: %v", err)
|
||||
}
|
||||
serverOpts.IdleTimeout = caddy.Duration(dur)
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unrecognized timeouts option '%s'", d.Val())
|
||||
}
|
||||
}
|
||||
|
||||
case "max_header_size":
|
||||
var sizeStr string
|
||||
if !d.AllArgs(&sizeStr) {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
size, err := humanize.ParseBytes(sizeStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, d.Errf("parsing max_header_size: %v", err)
|
||||
}
|
||||
listenerWrapper, ok := unm.(caddy.ListenerWrapper)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("module %s (%T) is not a listener wrapper", modID, unm)
|
||||
}
|
||||
jsonListenerWrapper := caddyconfig.JSONModuleObject(
|
||||
listenerWrapper,
|
||||
"wrapper",
|
||||
listenerWrapper.(caddy.Module).CaddyModule().ID.Name(),
|
||||
nil,
|
||||
)
|
||||
serverOpts.ListenerWrappersRaw = append(serverOpts.ListenerWrappersRaw, jsonListenerWrapper)
|
||||
}
|
||||
serverOpts.MaxHeaderBytes = int(size)
|
||||
|
||||
case "packet_conn_wrappers":
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
modID := "caddy.packetconns." + d.Val()
|
||||
unm, err := caddyfile.UnmarshalModule(d, modID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
case "log_credentials":
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
packetConnWrapper, ok := unm.(caddy.PacketConnWrapper)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("module %s (%T) is not a packet conn wrapper", modID, unm)
|
||||
}
|
||||
jsonPacketConnWrapper := caddyconfig.JSONModuleObject(
|
||||
packetConnWrapper,
|
||||
"wrapper",
|
||||
packetConnWrapper.(caddy.Module).CaddyModule().ID.Name(),
|
||||
nil,
|
||||
)
|
||||
serverOpts.PacketConnWrappersRaw = append(serverOpts.PacketConnWrappersRaw, jsonPacketConnWrapper)
|
||||
}
|
||||
serverOpts.ShouldLogCredentials = true
|
||||
|
||||
case "timeouts":
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
switch d.Val() {
|
||||
case "read_body":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
case "protocol":
|
||||
for nesting := d.Nesting(); d.NextBlock(nesting); {
|
||||
switch d.Val() {
|
||||
case "allow_h2c":
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
serverOpts.AllowH2C = true
|
||||
|
||||
case "experimental_http3":
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
serverOpts.ExperimentalHTTP3 = true
|
||||
|
||||
case "strict_sni_host":
|
||||
if d.NextArg() && d.Val() != "insecure_off" && d.Val() != "on" {
|
||||
return nil, d.Errf("strict_sni_host only supports 'on' or 'insecure_off', got '%s'", d.Val())
|
||||
}
|
||||
boolVal := true
|
||||
if d.Val() == "insecure_off" {
|
||||
boolVal = false
|
||||
}
|
||||
serverOpts.StrictSNIHost = &boolVal
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unrecognized protocol option '%s'", d.Val())
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing read_body timeout duration: %v", err)
|
||||
}
|
||||
serverOpts.ReadTimeout = caddy.Duration(dur)
|
||||
|
||||
case "read_header":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing read_header timeout duration: %v", err)
|
||||
}
|
||||
serverOpts.ReadHeaderTimeout = caddy.Duration(dur)
|
||||
|
||||
case "write":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing write timeout duration: %v", err)
|
||||
}
|
||||
serverOpts.WriteTimeout = caddy.Duration(dur)
|
||||
|
||||
case "idle":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing idle timeout duration: %v", err)
|
||||
}
|
||||
serverOpts.IdleTimeout = caddy.Duration(dur)
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unrecognized timeouts option '%s'", d.Val())
|
||||
}
|
||||
}
|
||||
|
||||
case "keepalive_interval":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
default:
|
||||
return nil, d.Errf("unrecognized servers option '%s'", d.Val())
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing keepalive interval duration: %v", err)
|
||||
}
|
||||
serverOpts.KeepAliveInterval = caddy.Duration(dur)
|
||||
|
||||
case "keepalive_idle":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
dur, err := caddy.ParseDuration(d.Val())
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing keepalive idle duration: %v", err)
|
||||
}
|
||||
serverOpts.KeepAliveIdle = caddy.Duration(dur)
|
||||
|
||||
case "keepalive_count":
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
cnt, err := strconv.ParseInt(d.Val(), 10, 32)
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing keepalive count int: %v", err)
|
||||
}
|
||||
serverOpts.KeepAliveCount = int(cnt)
|
||||
|
||||
case "max_header_size":
|
||||
var sizeStr string
|
||||
if !d.AllArgs(&sizeStr) {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
size, err := humanize.ParseBytes(sizeStr)
|
||||
if err != nil {
|
||||
return nil, d.Errf("parsing max_header_size: %v", err)
|
||||
}
|
||||
serverOpts.MaxHeaderBytes = int(size)
|
||||
|
||||
case "enable_full_duplex":
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
serverOpts.EnableFullDuplex = true
|
||||
|
||||
case "log_credentials":
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
serverOpts.ShouldLogCredentials = true
|
||||
|
||||
case "protocols":
|
||||
protos := d.RemainingArgs()
|
||||
for _, proto := range protos {
|
||||
if proto != "h1" && proto != "h2" && proto != "h2c" && proto != "h3" {
|
||||
return nil, d.Errf("unknown protocol '%s': expected h1, h2, h2c, or h3", proto)
|
||||
}
|
||||
if slices.Contains(serverOpts.Protocols, proto) {
|
||||
return nil, d.Errf("protocol %s specified more than once", proto)
|
||||
}
|
||||
serverOpts.Protocols = append(serverOpts.Protocols, proto)
|
||||
}
|
||||
if nesting := d.Nesting(); d.NextBlock(nesting) {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
|
||||
case "strict_sni_host":
|
||||
if d.NextArg() && d.Val() != "insecure_off" && d.Val() != "on" {
|
||||
return nil, d.Errf("strict_sni_host only supports 'on' or 'insecure_off', got '%s'", d.Val())
|
||||
}
|
||||
boolVal := true
|
||||
if d.Val() == "insecure_off" {
|
||||
boolVal = false
|
||||
}
|
||||
serverOpts.StrictSNIHost = &boolVal
|
||||
|
||||
case "trusted_proxies":
|
||||
if !d.NextArg() {
|
||||
return nil, d.Err("trusted_proxies expects an IP range source module name as its first argument")
|
||||
}
|
||||
modID := "http.ip_sources." + d.Val()
|
||||
unm, err := caddyfile.UnmarshalModule(d, modID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source, ok := unm.(caddyhttp.IPRangeSource)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("module %s (%T) is not an IP range source", modID, unm)
|
||||
}
|
||||
jsonSource := caddyconfig.JSONModuleObject(
|
||||
source,
|
||||
"source",
|
||||
source.(caddy.Module).CaddyModule().ID.Name(),
|
||||
nil,
|
||||
)
|
||||
serverOpts.TrustedProxiesRaw = jsonSource
|
||||
|
||||
case "trusted_proxies_strict":
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
serverOpts.TrustedProxiesStrict = 1
|
||||
|
||||
case "trusted_proxies_unix":
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
serverOpts.TrustedProxiesUnix = true
|
||||
|
||||
case "client_ip_headers":
|
||||
headers := d.RemainingArgs()
|
||||
for _, header := range headers {
|
||||
if slices.Contains(serverOpts.ClientIPHeaders, header) {
|
||||
return nil, d.Errf("client IP header %s specified more than once", header)
|
||||
}
|
||||
serverOpts.ClientIPHeaders = append(serverOpts.ClientIPHeaders, header)
|
||||
}
|
||||
if nesting := d.Nesting(); d.NextBlock(nesting) {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
|
||||
case "metrics":
|
||||
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.")
|
||||
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":
|
||||
if d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
serverOpts.Trace = true
|
||||
|
||||
case "0rtt":
|
||||
// only supports "off" for now
|
||||
if !d.NextArg() {
|
||||
return nil, d.ArgErr()
|
||||
}
|
||||
if d.Val() != "off" {
|
||||
return nil, d.Errf("unsupported 0rtt argument '%s' (only 'off' is supported)", d.Val())
|
||||
}
|
||||
boolVal := false
|
||||
serverOpts.Allow0RTT = &boolVal
|
||||
|
||||
default:
|
||||
return nil, d.Errf("unrecognized servers option '%s'", d.Val())
|
||||
}
|
||||
}
|
||||
return serverOpts, nil
|
||||
@@ -333,84 +182,62 @@ func unmarshalCaddyfileServerOptions(d *caddyfile.Dispenser) (any, error) {
|
||||
// applyServerOptions sets the server options on the appropriate servers
|
||||
func applyServerOptions(
|
||||
servers map[string]*caddyhttp.Server,
|
||||
options map[string]any,
|
||||
_ *[]caddyconfig.Warning,
|
||||
options map[string]interface{},
|
||||
warnings *[]caddyconfig.Warning,
|
||||
) error {
|
||||
// If experimental HTTP/3 is enabled, enable it on each server.
|
||||
// We already know there won't be a conflict with serverOptions because
|
||||
// we validated earlier that "experimental_http3" cannot be set at the same
|
||||
// time as "servers"
|
||||
if enableH3, ok := options["experimental_http3"].(bool); ok && enableH3 {
|
||||
*warnings = append(*warnings, caddyconfig.Warning{Message: "the 'experimental_http3' global option is deprecated, please use the 'servers > protocol > experimental_http3' option instead"})
|
||||
for _, srv := range servers {
|
||||
srv.ExperimentalHTTP3 = true
|
||||
}
|
||||
}
|
||||
|
||||
serverOpts, ok := options["servers"].([]serverOptions)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// check for duplicate names, which would clobber the config
|
||||
existingNames := map[string]bool{}
|
||||
for _, opts := range serverOpts {
|
||||
if opts.Name == "" {
|
||||
continue
|
||||
}
|
||||
if existingNames[opts.Name] {
|
||||
return fmt.Errorf("cannot use duplicate server name '%s'", opts.Name)
|
||||
}
|
||||
existingNames[opts.Name] = true
|
||||
}
|
||||
|
||||
// collect the server name overrides
|
||||
nameReplacements := map[string]string{}
|
||||
|
||||
for key, server := range servers {
|
||||
for _, server := range servers {
|
||||
// find the options that apply to this server
|
||||
optsIndex := slices.IndexFunc(serverOpts, func(s serverOptions) bool {
|
||||
return s.ListenerAddress == "" || slices.Contains(server.Listen, s.ListenerAddress)
|
||||
})
|
||||
opts := func() *serverOptions {
|
||||
for _, entry := range serverOpts {
|
||||
if entry.ListenerAddress == "" {
|
||||
return &entry
|
||||
}
|
||||
for _, listener := range server.Listen {
|
||||
if entry.ListenerAddress == listener {
|
||||
return &entry
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
// if none apply, then move to the next server
|
||||
if optsIndex == -1 {
|
||||
if opts == nil {
|
||||
continue
|
||||
}
|
||||
opts := serverOpts[optsIndex]
|
||||
|
||||
// set all the options
|
||||
server.ListenerWrappersRaw = opts.ListenerWrappersRaw
|
||||
server.PacketConnWrappersRaw = opts.PacketConnWrappersRaw
|
||||
server.ReadTimeout = opts.ReadTimeout
|
||||
server.ReadHeaderTimeout = opts.ReadHeaderTimeout
|
||||
server.WriteTimeout = opts.WriteTimeout
|
||||
server.IdleTimeout = opts.IdleTimeout
|
||||
server.KeepAliveInterval = opts.KeepAliveInterval
|
||||
server.KeepAliveIdle = opts.KeepAliveIdle
|
||||
server.KeepAliveCount = opts.KeepAliveCount
|
||||
server.MaxHeaderBytes = opts.MaxHeaderBytes
|
||||
server.EnableFullDuplex = opts.EnableFullDuplex
|
||||
server.Protocols = opts.Protocols
|
||||
server.AllowH2C = opts.AllowH2C
|
||||
server.ExperimentalHTTP3 = opts.ExperimentalHTTP3
|
||||
server.StrictSNIHost = opts.StrictSNIHost
|
||||
server.TrustedProxiesRaw = opts.TrustedProxiesRaw
|
||||
server.ClientIPHeaders = opts.ClientIPHeaders
|
||||
server.TrustedProxiesStrict = opts.TrustedProxiesStrict
|
||||
server.TrustedProxiesUnix = opts.TrustedProxiesUnix
|
||||
server.Metrics = opts.Metrics
|
||||
server.Allow0RTT = opts.Allow0RTT
|
||||
if opts.ShouldLogCredentials {
|
||||
if server.Logs == nil {
|
||||
server.Logs = new(caddyhttp.ServerLogConfig)
|
||||
server.Logs = &caddyhttp.ServerLogConfig{}
|
||||
}
|
||||
server.Logs.ShouldLogCredentials = opts.ShouldLogCredentials
|
||||
}
|
||||
if opts.Trace {
|
||||
// TODO: THIS IS EXPERIMENTAL (MAY 2024)
|
||||
if server.Logs == nil {
|
||||
server.Logs = new(caddyhttp.ServerLogConfig)
|
||||
}
|
||||
server.Logs.Trace = opts.Trace
|
||||
}
|
||||
|
||||
if opts.Name != "" {
|
||||
nameReplacements[key] = opts.Name
|
||||
}
|
||||
}
|
||||
|
||||
// rename the servers if marked to do so
|
||||
for old, new := range nameReplacements {
|
||||
servers[new] = servers[old]
|
||||
delete(servers, old)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package httpcaddyfile
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
||||
)
|
||||
|
||||
type ComplexShorthandReplacer struct {
|
||||
search *regexp.Regexp
|
||||
replace string
|
||||
}
|
||||
|
||||
type ShorthandReplacer struct {
|
||||
complex []ComplexShorthandReplacer
|
||||
simple *strings.Replacer
|
||||
}
|
||||
|
||||
func NewShorthandReplacer() ShorthandReplacer {
|
||||
// replace shorthand placeholders (which are convenient
|
||||
// when writing a Caddyfile) with their actual placeholder
|
||||
// identifiers or variable names
|
||||
replacer := strings.NewReplacer(placeholderShorthands()...)
|
||||
|
||||
// these are placeholders that allow a user-defined final
|
||||
// parameters, but we still want to provide a shorthand
|
||||
// for those, so we use a regexp to replace
|
||||
regexpReplacements := []ComplexShorthandReplacer{
|
||||
{regexp.MustCompile(`{header\.([\w-]*)}`), "{http.request.header.$1}"},
|
||||
{regexp.MustCompile(`{cookie\.([\w-]*)}`), "{http.request.cookie.$1}"},
|
||||
{regexp.MustCompile(`{labels\.([\w-]*)}`), "{http.request.host.labels.$1}"},
|
||||
{regexp.MustCompile(`{path\.([\w-]*)}`), "{http.request.uri.path.$1}"},
|
||||
{regexp.MustCompile(`{file\.([\w-]*)}`), "{http.request.uri.path.file.$1}"},
|
||||
{regexp.MustCompile(`{query\.([\w-]*)}`), "{http.request.uri.query.$1}"},
|
||||
{regexp.MustCompile(`{re\.([\w-\.]*)}`), "{http.regexp.$1}"},
|
||||
{regexp.MustCompile(`{vars\.([\w-]*)}`), "{http.vars.$1}"},
|
||||
{regexp.MustCompile(`{rp\.([\w-\.]*)}`), "{http.reverse_proxy.$1}"},
|
||||
{regexp.MustCompile(`{resp\.([\w-\.]*)}`), "{http.intercept.$1}"},
|
||||
{regexp.MustCompile(`{err\.([\w-\.]*)}`), "{http.error.$1}"},
|
||||
{regexp.MustCompile(`{file_match\.([\w-]*)}`), "{http.matchers.file.$1}"},
|
||||
}
|
||||
|
||||
return ShorthandReplacer{
|
||||
complex: regexpReplacements,
|
||||
simple: replacer,
|
||||
}
|
||||
}
|
||||
|
||||
// placeholderShorthands returns a slice of old-new string pairs,
|
||||
// where the left of the pair is a placeholder shorthand that may
|
||||
// be used in the Caddyfile, and the right is the replacement.
|
||||
func placeholderShorthands() []string {
|
||||
return []string{
|
||||
"{host}", "{http.request.host}",
|
||||
"{hostport}", "{http.request.hostport}",
|
||||
"{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}",
|
||||
"{uri}", "{http.request.uri}",
|
||||
"{%uri}", "{http.request.uri_escaped}",
|
||||
"{path}", "{http.request.uri.path}",
|
||||
"{%path}", "{http.request.uri.path_escaped}",
|
||||
"{dir}", "{http.request.uri.path.dir}",
|
||||
"{file}", "{http.request.uri.path.file}",
|
||||
"{query}", "{http.request.uri.query}",
|
||||
"{%query}", "{http.request.uri.query_escaped}",
|
||||
"{?query}", "{http.request.uri.prefixed_query}",
|
||||
"{remote}", "{http.request.remote}",
|
||||
"{remote_host}", "{http.request.remote.host}",
|
||||
"{remote_port}", "{http.request.remote.port}",
|
||||
"{scheme}", "{http.request.scheme}",
|
||||
"{uuid}", "{http.request.uuid}",
|
||||
"{tls_cipher}", "{http.request.tls.cipher_suite}",
|
||||
"{tls_version}", "{http.request.tls.version}",
|
||||
"{tls_client_fingerprint}", "{http.request.tls.client.fingerprint}",
|
||||
"{tls_client_issuer}", "{http.request.tls.client.issuer}",
|
||||
"{tls_client_serial}", "{http.request.tls.client.serial}",
|
||||
"{tls_client_subject}", "{http.request.tls.client.subject}",
|
||||
"{tls_client_certificate_pem}", "{http.request.tls.client.certificate_pem}",
|
||||
"{tls_client_certificate_der_base64}", "{http.request.tls.client.certificate_der_base64}",
|
||||
"{upstream_hostport}", "{http.reverse_proxy.upstream.hostport}",
|
||||
"{client_ip}", "{http.vars.client_ip}",
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyToSegment replaces shorthand placeholder to its full placeholder, understandable by Caddy.
|
||||
func (s ShorthandReplacer) ApplyToSegment(segment *caddyfile.Segment) {
|
||||
if segment != nil {
|
||||
for i := 0; i < len(*segment); i++ {
|
||||
// simple string replacements
|
||||
(*segment)[i].Text = s.simple.Replace((*segment)[i].Text)
|
||||
// complex regexp replacements
|
||||
for _, r := range s.complex {
|
||||
(*segment)[i].Text = r.search.ReplaceAllString((*segment)[i].Text, r.replace)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
(t2) {
|
||||
respond 200 {
|
||||
body {args[:]}
|
||||
}
|
||||
}
|
||||
|
||||
:8082 {
|
||||
import t2 false
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
(t1) {
|
||||
respond 200 {
|
||||
body {args[:]}
|
||||
}
|
||||
}
|
||||
|
||||
:8081 {
|
||||
import t1 false
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
(t1) {
|
||||
respond 200 {
|
||||
body {args[:]}
|
||||
}
|
||||
}
|
||||
|
||||
:8081 {
|
||||
import t1 false
|
||||
}
|
||||
|
||||
import import_variadic.txt
|
||||
|
||||
:8083 {
|
||||
import t2 true
|
||||
}
|
||||
@@ -19,25 +19,24 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/caddyserver/certmagic"
|
||||
"github.com/mholt/acmez/v3/acme"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
|
||||
"github.com/caddyserver/caddy/v2/modules/caddytls"
|
||||
"github.com/caddyserver/certmagic"
|
||||
"github.com/mholt/acmez/acme"
|
||||
)
|
||||
|
||||
func (st ServerType) buildTLSApp(
|
||||
pairings []sbAddrAssociation,
|
||||
options map[string]any,
|
||||
options map[string]interface{},
|
||||
warnings []caddyconfig.Warning,
|
||||
) (*caddytls.TLS, []caddyconfig.Warning, error) {
|
||||
|
||||
tlsApp := &caddytls.TLS{CertificatesRaw: make(caddy.ModuleMap)}
|
||||
var certLoaders []caddytls.CertificateLoader
|
||||
|
||||
@@ -45,26 +44,29 @@ func (st ServerType) buildTLSApp(
|
||||
if hp, ok := options["http_port"].(int); ok {
|
||||
httpPort = strconv.Itoa(hp)
|
||||
}
|
||||
autoHTTPS := []string{}
|
||||
if ah, ok := options["auto_https"].([]string); ok {
|
||||
autoHTTPS = ah
|
||||
httpsPort := strconv.Itoa(caddyhttp.DefaultHTTPSPort)
|
||||
if hsp, ok := options["https_port"].(int); ok {
|
||||
httpsPort = strconv.Itoa(hsp)
|
||||
}
|
||||
|
||||
// find all hosts that share a server block with a hostless
|
||||
// key, so that they don't get forgotten/omitted by auto-HTTPS
|
||||
// (since they won't appear in route matchers)
|
||||
// count how many server blocks have a TLS-enabled key with
|
||||
// no host, and find all hosts that share a server block with
|
||||
// a hostless key, so that they don't get forgotten/omitted
|
||||
// by auto-HTTPS (since they won't appear in route matchers)
|
||||
var serverBlocksWithTLSHostlessKey int
|
||||
httpsHostsSharedWithHostlessKey := make(map[string]struct{})
|
||||
if !slices.Contains(autoHTTPS, "off") {
|
||||
for _, pair := range pairings {
|
||||
for _, sb := range pair.serverBlocks {
|
||||
for _, addr := range sb.parsedKeys {
|
||||
if addr.Host != "" {
|
||||
continue
|
||||
for _, pair := range pairings {
|
||||
for _, sb := range pair.serverBlocks {
|
||||
for _, addr := range sb.keys {
|
||||
if addr.Host == "" {
|
||||
// this address has no hostname, but if it's explicitly set
|
||||
// to HTTPS, then we need to count it as being TLS-enabled
|
||||
if addr.Scheme == "https" || addr.Port == httpsPort {
|
||||
serverBlocksWithTLSHostlessKey++
|
||||
}
|
||||
|
||||
// this server block has a hostless key, now
|
||||
// go through and add all the hosts to the set
|
||||
for _, otherAddr := range sb.parsedKeys {
|
||||
for _, otherAddr := range sb.keys {
|
||||
if otherAddr.Original == addr.Original {
|
||||
continue
|
||||
}
|
||||
@@ -92,15 +94,9 @@ func (st ServerType) buildTLSApp(
|
||||
tlsApp.Automation.Policies = append(tlsApp.Automation.Policies, catchAllAP)
|
||||
}
|
||||
|
||||
forcedAutomatedNames := make(map[string]struct{}) // explicitly configured to be automated, even if covered by a wildcard
|
||||
|
||||
for _, p := range pairings {
|
||||
// avoid setting up TLS automation policies for a server that is HTTP-only
|
||||
var addresses []string
|
||||
for _, addressWithProtocols := range p.addressesWithProtocols {
|
||||
addresses = append(addresses, addressWithProtocols.address)
|
||||
}
|
||||
if !listenersUseAnyPortOtherThan(addresses, httpPort) {
|
||||
if !listenersUseAnyPortOtherThan(p.addresses, httpPort) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -127,28 +123,10 @@ func (st ServerType) buildTLSApp(
|
||||
ap.OnDemand = true
|
||||
}
|
||||
|
||||
// collect hosts that are forced to have certs automated for their specific name
|
||||
if _, ok := sblock.pile["tls.force_automate"]; ok {
|
||||
for _, host := range sblockHosts {
|
||||
forcedAutomatedNames[host] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// reuse private keys tls
|
||||
if _, ok := sblock.pile["tls.reuse_private_keys"]; ok {
|
||||
ap.ReusePrivateKeys = true
|
||||
}
|
||||
|
||||
if keyTypeVals, ok := sblock.pile["tls.key_type"]; ok {
|
||||
ap.KeyType = keyTypeVals[0].Value.(string)
|
||||
}
|
||||
|
||||
if renewalWindowRatioVals, ok := sblock.pile["tls.renewal_window_ratio"]; ok {
|
||||
ap.RenewalWindowRatio = renewalWindowRatioVals[0].Value.(float64)
|
||||
} else if globalRenewalWindowRatio, ok := options["renewal_window_ratio"]; ok {
|
||||
ap.RenewalWindowRatio = globalRenewalWindowRatio.(float64)
|
||||
}
|
||||
|
||||
// certificate issuers
|
||||
if issuerVals, ok := sblock.pile["tls.cert_issuer"]; ok {
|
||||
var issuers []certmagic.Issuer
|
||||
@@ -156,19 +134,6 @@ func (st ServerType) buildTLSApp(
|
||||
issuers = append(issuers, issuerVal.Value.(certmagic.Issuer))
|
||||
}
|
||||
if ap == catchAllAP && !reflect.DeepEqual(ap.Issuers, issuers) {
|
||||
// this more correctly implements an error check that was removed
|
||||
// below; try it with this config:
|
||||
//
|
||||
// :443 {
|
||||
// bind 127.0.0.1
|
||||
// }
|
||||
//
|
||||
// :443 {
|
||||
// bind ::1
|
||||
// tls {
|
||||
// issuer acme
|
||||
// }
|
||||
// }
|
||||
return nil, warnings, fmt.Errorf("automation policy from site block is also default/catch-all policy because of key without hostname, and the two are in conflict: %#v != %#v", ap.Issuers, issuers)
|
||||
}
|
||||
ap.Issuers = issuers
|
||||
@@ -203,40 +168,42 @@ func (st ServerType) buildTLSApp(
|
||||
if acmeIssuer.Challenges.BindHost == "" {
|
||||
// only binding to one host is supported
|
||||
var bindHost string
|
||||
if asserted, ok := cfgVal.Value.(addressesWithProtocols); ok && len(asserted.addresses) > 0 {
|
||||
bindHost = asserted.addresses[0]
|
||||
if bindHosts, ok := cfgVal.Value.([]string); ok && len(bindHosts) > 0 {
|
||||
bindHost = bindHosts[0]
|
||||
}
|
||||
acmeIssuer.Challenges.BindHost = bindHost
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// we used to ensure this block is allowed to create an automation policy;
|
||||
// doing so was forbidden if it has a key with no host (i.e. ":443")
|
||||
// first make sure this block is allowed to create an automation policy;
|
||||
// doing so is forbidden if it has a key with no host (i.e. ":443")
|
||||
// and if there is a different server block that also has a key with no
|
||||
// host -- since a key with no host matches any host, we need its
|
||||
// associated automation policy to have an empty Subjects list, i.e. no
|
||||
// host filter, which is indistinguishable between the two server blocks
|
||||
// because automation is not done in the context of a particular server...
|
||||
// this is an example of a poor mapping from Caddyfile to JSON but that's
|
||||
// the least-leaky abstraction I could figure out -- however, this check
|
||||
// was preventing certain listeners, like those provided by plugins, from
|
||||
// being used as desired (see the Tailscale listener plugin), so I removed
|
||||
// the check: and I think since I originally wrote the check I added a new
|
||||
// check above which *properly* detects this ambiguity without breaking the
|
||||
// listener plugin; see the check above with a commented example config
|
||||
if len(sblockHosts) == 0 && catchAllAP == nil {
|
||||
// this server block has a key with no hosts, but there is not yet
|
||||
// a catch-all automation policy (probably because no global options
|
||||
// were set), so this one becomes it
|
||||
catchAllAP = ap
|
||||
// the least-leaky abstraction I could figure out
|
||||
if len(sblockHosts) == 0 {
|
||||
if serverBlocksWithTLSHostlessKey > 1 {
|
||||
// this server block and at least one other has a key with no host,
|
||||
// making the two indistinguishable; it is misleading to define such
|
||||
// a policy within one server block since it actually will apply to
|
||||
// others as well
|
||||
return nil, warnings, fmt.Errorf("cannot make a TLS automation policy from a server block that has a host-less address when there are other TLS-enabled server block addresses lacking a host")
|
||||
}
|
||||
if catchAllAP == nil {
|
||||
// this server block has a key with no hosts, but there is not yet
|
||||
// a catch-all automation policy (probably because no global options
|
||||
// were set), so this one becomes it
|
||||
catchAllAP = ap
|
||||
}
|
||||
}
|
||||
|
||||
hostsNotHTTP := sblock.hostsFromKeysNotHTTP(httpPort)
|
||||
sort.Strings(hostsNotHTTP) // solely for deterministic test results
|
||||
|
||||
// associate our new automation policy with this server block's hosts
|
||||
ap.SubjectsRaw = hostsNotHTTP
|
||||
ap.Subjects = sblock.hostsFromKeysNotHTTP(httpPort)
|
||||
sort.Strings(ap.Subjects) // solely for deterministic test results
|
||||
|
||||
// if a combination of public and internal names were given
|
||||
// for this same server block and no issuer was specified, we
|
||||
@@ -246,11 +213,7 @@ func (st ServerType) buildTLSApp(
|
||||
var ap2 *caddytls.AutomationPolicy
|
||||
if len(ap.Issuers) == 0 {
|
||||
var internal, external []string
|
||||
for _, s := range ap.SubjectsRaw {
|
||||
// do not create Issuers for Tailscale domains; they will be given a Manager instead
|
||||
if isTailscaleDomain(s) {
|
||||
continue
|
||||
}
|
||||
for _, s := range ap.Subjects {
|
||||
if !certmagic.SubjectQualifiesForCert(s) {
|
||||
return nil, warnings, fmt.Errorf("subject does not qualify for certificate: '%s'", s)
|
||||
}
|
||||
@@ -268,14 +231,13 @@ func (st ServerType) buildTLSApp(
|
||||
}
|
||||
}
|
||||
if len(external) > 0 && len(internal) > 0 {
|
||||
ap.SubjectsRaw = external
|
||||
ap.Subjects = external
|
||||
apCopy := *ap
|
||||
ap2 = &apCopy
|
||||
ap2.SubjectsRaw = internal
|
||||
ap2.Subjects = internal
|
||||
ap2.IssuersRaw = []json.RawMessage{caddyconfig.JSONModuleObject(caddytls.InternalIssuer{}, "module", "internal", &warnings)}
|
||||
}
|
||||
}
|
||||
|
||||
if tlsApp.Automation == nil {
|
||||
tlsApp.Automation = new(caddytls.AutomationConfig)
|
||||
}
|
||||
@@ -310,7 +272,7 @@ func (st ServerType) buildTLSApp(
|
||||
combined = reflect.New(reflect.TypeOf(cl)).Elem()
|
||||
}
|
||||
clVal := reflect.ValueOf(cl)
|
||||
for i := range clVal.Len() {
|
||||
for i := 0; i < clVal.Len(); i++ {
|
||||
combined = reflect.Append(combined, clVal.Index(i))
|
||||
}
|
||||
loadersByName[name] = combined.Interface().(caddytls.CertificateLoader)
|
||||
@@ -329,42 +291,6 @@ func (st ServerType) buildTLSApp(
|
||||
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
|
||||
if storageCleanInterval, ok := options["storage_clean_interval"].(caddy.Duration); ok {
|
||||
if tlsApp.Automation == nil {
|
||||
@@ -381,14 +307,6 @@ func (st ServerType) buildTLSApp(
|
||||
tlsApp.Automation.RenewCheckInterval = renewCheckInterval
|
||||
}
|
||||
|
||||
// set the OCSP check interval if configured
|
||||
if ocspCheckInterval, ok := options["ocsp_interval"].(caddy.Duration); ok {
|
||||
if tlsApp.Automation == nil {
|
||||
tlsApp.Automation = new(caddytls.AutomationConfig)
|
||||
}
|
||||
tlsApp.Automation.OCSPCheckInterval = ocspCheckInterval
|
||||
}
|
||||
|
||||
// set whether OCSP stapling should be disabled for manually-managed certificates
|
||||
if ocspConfig, ok := options["ocsp_stapling"].(certmagic.OCSPConfig); ok {
|
||||
tlsApp.DisableOCSPStapling = ocspConfig.DisableStapling
|
||||
@@ -405,25 +323,16 @@ func (st ServerType) buildTLSApp(
|
||||
internalAP := &caddytls.AutomationPolicy{
|
||||
IssuersRaw: []json.RawMessage{json.RawMessage(`{"module":"internal"}`)},
|
||||
}
|
||||
if !slices.Contains(autoHTTPS, "off") && !slices.Contains(autoHTTPS, "disable_certs") {
|
||||
for h := range httpsHostsSharedWithHostlessKey {
|
||||
al = append(al, h)
|
||||
if !certmagic.SubjectQualifiesForPublicCert(h) {
|
||||
internalAP.SubjectsRaw = append(internalAP.SubjectsRaw, h)
|
||||
}
|
||||
for h := range httpsHostsSharedWithHostlessKey {
|
||||
al = append(al, h)
|
||||
if !certmagic.SubjectQualifiesForPublicCert(h) {
|
||||
internalAP.Subjects = append(internalAP.Subjects, h)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
tlsApp.CertificatesRaw["automate"] = caddyconfig.JSON(al, &warnings)
|
||||
}
|
||||
if len(internalAP.SubjectsRaw) > 0 {
|
||||
if len(internalAP.Subjects) > 0 {
|
||||
if tlsApp.Automation == nil {
|
||||
tlsApp.Automation = new(caddytls.AutomationConfig)
|
||||
}
|
||||
@@ -436,22 +345,25 @@ func (st ServerType) buildTLSApp(
|
||||
globalEmail := options["email"]
|
||||
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
|
||||
globalACMEDNS := options["acme_dns"]
|
||||
globalACMEEAB := options["acme_eab"]
|
||||
globalPreferredChains := options["preferred_chains"]
|
||||
hasGlobalACMEDefaults := globalEmail != nil || globalACMECA != nil || globalACMECARoot != nil || globalACMEDNS || globalACMEEAB != nil || globalPreferredChains != nil
|
||||
hasGlobalACMEDefaults := globalEmail != nil || globalACMECA != nil || globalACMECARoot != nil || globalACMEDNS != nil || globalACMEEAB != nil || globalPreferredChains != nil
|
||||
if hasGlobalACMEDefaults {
|
||||
for i := range tlsApp.Automation.Policies {
|
||||
for i := 0; i < len(tlsApp.Automation.Policies); i++ {
|
||||
ap := tlsApp.Automation.Policies[i]
|
||||
if len(ap.Issuers) == 0 && automationPolicyHasAllPublicNames(ap) {
|
||||
// for public names, create default issuers which will later be filled in with configured global defaults
|
||||
// (internal names will implicitly use the internal issuer at auto-https time)
|
||||
emailStr, _ := globalEmail.(string)
|
||||
ap.Issuers = caddytls.DefaultIssuers(emailStr)
|
||||
ap.Issuers = caddytls.DefaultIssuers()
|
||||
|
||||
// if a specific endpoint is configured, can't use multiple default issuers
|
||||
if globalACMECA != nil {
|
||||
ap.Issuers = []certmagic.Issuer{new(caddytls.ACMEIssuer)}
|
||||
if strings.Contains(globalACMECA.(string), "zerossl") {
|
||||
ap.Issuers = []certmagic.Issuer{&caddytls.ZeroSSLIssuer{ACMEIssuer: new(caddytls.ACMEIssuer)}}
|
||||
} else {
|
||||
ap.Issuers = []certmagic.Issuer{new(caddytls.ACMEIssuer)}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -486,7 +398,7 @@ func (st ServerType) buildTLSApp(
|
||||
// for convenience)
|
||||
automationHostSet := make(map[string]struct{})
|
||||
for _, ap := range tlsApp.Automation.Policies {
|
||||
for _, s := range ap.SubjectsRaw {
|
||||
for _, s := range ap.Subjects {
|
||||
if _, ok := automationHostSet[s]; ok {
|
||||
return nil, warnings, fmt.Errorf("hostname appears in more than one automation policy, making certificate management ambiguous: %s", s)
|
||||
}
|
||||
@@ -508,7 +420,7 @@ func (st ServerType) buildTLSApp(
|
||||
|
||||
type acmeCapable interface{ GetACMEIssuer() *caddytls.ACMEIssuer }
|
||||
|
||||
func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) error {
|
||||
func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]interface{}) error {
|
||||
acmeWrapper, ok := issuer.(acmeCapable)
|
||||
if !ok {
|
||||
return nil
|
||||
@@ -521,12 +433,9 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
|
||||
globalEmail := options["email"]
|
||||
globalACMECA := options["acme_ca"]
|
||||
globalACMECARoot := options["acme_ca_root"]
|
||||
globalACMEDNS, globalACMEDNSok := options["acme_dns"] // can be set to nil (to use globally-defined "dns" value instead), but it is still set
|
||||
globalACMEDNS := options["acme_dns"]
|
||||
globalACMEEAB := options["acme_eab"]
|
||||
globalPreferredChains := options["preferred_chains"]
|
||||
globalCertLifetime := options["cert_lifetime"]
|
||||
globalHTTPPort, globalHTTPSPort := options["http_port"], options["https_port"]
|
||||
globalDefaultBind := options["default_bind"]
|
||||
|
||||
if globalEmail != nil && acmeIssuer.Email == "" {
|
||||
acmeIssuer.Email = globalEmail.(string)
|
||||
@@ -534,23 +443,14 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
|
||||
if globalACMECA != nil && acmeIssuer.CA == "" {
|
||||
acmeIssuer.CA = globalACMECA.(string)
|
||||
}
|
||||
if globalACMECARoot != nil && !slices.Contains(acmeIssuer.TrustedRootsPEMFiles, globalACMECARoot.(string)) {
|
||||
if globalACMECARoot != nil && !sliceContains(acmeIssuer.TrustedRootsPEMFiles, globalACMECARoot.(string)) {
|
||||
acmeIssuer.TrustedRootsPEMFiles = append(acmeIssuer.TrustedRootsPEMFiles, globalACMECARoot.(string))
|
||||
}
|
||||
if globalACMEDNSok && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.DNS == nil || acmeIssuer.Challenges.DNS.ProviderRaw == nil) {
|
||||
globalDNS := options["dns"]
|
||||
if globalDNS == nil && globalACMEDNS == nil {
|
||||
return fmt.Errorf("acme_dns specified without DNS provider config, but no provider specified with 'dns' global option")
|
||||
}
|
||||
if acmeIssuer.Challenges == nil {
|
||||
acmeIssuer.Challenges = new(caddytls.ChallengesConfig)
|
||||
}
|
||||
if acmeIssuer.Challenges.DNS == nil {
|
||||
acmeIssuer.Challenges.DNS = new(caddytls.DNSChallengeConfig)
|
||||
}
|
||||
if globalACMEDNS != nil && acmeIssuer.Challenges.DNS.ProviderRaw == nil {
|
||||
// Set a global DNS provider if `acme_dns` is set
|
||||
acmeIssuer.Challenges.DNS.ProviderRaw = caddyconfig.JSONModuleObject(globalACMEDNS, "name", globalACMEDNS.(caddy.Module).CaddyModule().ID.Name(), nil)
|
||||
if globalACMEDNS != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.DNS == nil) {
|
||||
acmeIssuer.Challenges = &caddytls.ChallengesConfig{
|
||||
DNS: &caddytls.DNSChallengeConfig{
|
||||
ProviderRaw: caddyconfig.JSONModuleObject(globalACMEDNS, "name", globalACMEDNS.(caddy.Module).CaddyModule().ID.Name(), nil),
|
||||
},
|
||||
}
|
||||
}
|
||||
if globalACMEEAB != nil && acmeIssuer.ExternalAccount == nil {
|
||||
@@ -559,42 +459,6 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
|
||||
if globalPreferredChains != nil && acmeIssuer.PreferredChains == nil {
|
||||
acmeIssuer.PreferredChains = globalPreferredChains.(*caddytls.ChainPreference)
|
||||
}
|
||||
// only configure alt HTTP and TLS-ALPN ports if the DNS challenge is not enabled (wouldn't hurt, but isn't necessary since the DNS challenge is exclusive of others)
|
||||
if globalHTTPPort != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.DNS == nil) && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.HTTP == nil || acmeIssuer.Challenges.HTTP.AlternatePort == 0) {
|
||||
if acmeIssuer.Challenges == nil {
|
||||
acmeIssuer.Challenges = new(caddytls.ChallengesConfig)
|
||||
}
|
||||
if acmeIssuer.Challenges.HTTP == nil {
|
||||
acmeIssuer.Challenges.HTTP = new(caddytls.HTTPChallengeConfig)
|
||||
}
|
||||
acmeIssuer.Challenges.HTTP.AlternatePort = globalHTTPPort.(int)
|
||||
}
|
||||
if globalHTTPSPort != nil && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.DNS == nil) && (acmeIssuer.Challenges == nil || acmeIssuer.Challenges.TLSALPN == nil || acmeIssuer.Challenges.TLSALPN.AlternatePort == 0) {
|
||||
if acmeIssuer.Challenges == nil {
|
||||
acmeIssuer.Challenges = new(caddytls.ChallengesConfig)
|
||||
}
|
||||
if acmeIssuer.Challenges.TLSALPN == nil {
|
||||
acmeIssuer.Challenges.TLSALPN = new(caddytls.TLSALPNChallengeConfig)
|
||||
}
|
||||
acmeIssuer.Challenges.TLSALPN.AlternatePort = globalHTTPSPort.(int)
|
||||
}
|
||||
// If BindHost is still unset, fall back to the first default_bind address if set
|
||||
// This avoids binding the automation policy to the wildcard socket, which is unexpected behavior when a more selective socket is specified via default_bind
|
||||
// In BSD it is valid to bind to the wildcard socket even though a more selective socket is already open (still unexpected behavior by the caller though)
|
||||
// In Linux the same call will error with EADDRINUSE whenever the listener for the automation policy is opened
|
||||
if acmeIssuer.Challenges == nil || (acmeIssuer.Challenges.DNS == nil && acmeIssuer.Challenges.BindHost == "") {
|
||||
if defBinds, ok := globalDefaultBind.([]ConfigValue); ok && len(defBinds) > 0 {
|
||||
if abp, ok := defBinds[0].Value.(addressesWithProtocols); ok && len(abp.addresses) > 0 {
|
||||
if acmeIssuer.Challenges == nil {
|
||||
acmeIssuer.Challenges = new(caddytls.ChallengesConfig)
|
||||
}
|
||||
acmeIssuer.Challenges.BindHost = abp.addresses[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
if globalCertLifetime != nil && acmeIssuer.CertificateLifetime == 0 {
|
||||
acmeIssuer.CertificateLifetime = globalCertLifetime.(caddy.Duration)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -603,28 +467,17 @@ func fillInGlobalACMEDefaults(issuer certmagic.Issuer, options map[string]any) e
|
||||
// for any other automation policies. A nil policy (and no error) will be
|
||||
// returned if there are no default/global options. However, if always is
|
||||
// true, a non-nil value will always be returned (unless there is an error).
|
||||
func newBaseAutomationPolicy(
|
||||
options map[string]any,
|
||||
_ []caddyconfig.Warning,
|
||||
always bool,
|
||||
) (*caddytls.AutomationPolicy, error) {
|
||||
func newBaseAutomationPolicy(options map[string]interface{}, warnings []caddyconfig.Warning, always bool) (*caddytls.AutomationPolicy, error) {
|
||||
issuers, hasIssuers := options["cert_issuer"]
|
||||
_, hasLocalCerts := options["local_certs"]
|
||||
keyType, hasKeyType := options["key_type"]
|
||||
ocspStapling, hasOCSPStapling := options["ocsp_stapling"]
|
||||
renewalWindowRatio, hasRenewalWindowRatio := options["renewal_window_ratio"]
|
||||
hasGlobalAutomationOpts := hasIssuers || hasLocalCerts || hasKeyType || hasOCSPStapling || hasRenewalWindowRatio
|
||||
|
||||
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
|
||||
hasGlobalAutomationOpts := hasIssuers || hasLocalCerts || hasKeyType || hasOCSPStapling
|
||||
|
||||
// if there are no global options related to automation policies
|
||||
// set, then we can just return right away
|
||||
if !hasGlobalAutomationOpts && !hasGlobalACMEDefaults {
|
||||
if !hasGlobalAutomationOpts {
|
||||
if always {
|
||||
return new(caddytls.AutomationPolicy), nil
|
||||
}
|
||||
@@ -646,24 +499,12 @@ func newBaseAutomationPolicy(
|
||||
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 {
|
||||
ocspConfig := ocspStapling.(certmagic.OCSPConfig)
|
||||
ap.DisableOCSPStapling = ocspConfig.DisableStapling
|
||||
ap.OCSPOverrides = ocspConfig.ResponderOverrides
|
||||
}
|
||||
|
||||
if hasRenewalWindowRatio {
|
||||
ap.RenewalWindowRatio = renewalWindowRatio.(float64)
|
||||
}
|
||||
|
||||
return ap, nil
|
||||
}
|
||||
|
||||
@@ -678,7 +519,7 @@ func consolidateAutomationPolicies(aps []*caddytls.AutomationPolicy) []*caddytls
|
||||
if automationPolicyIsSubset(aps[j], aps[i]) {
|
||||
return false
|
||||
}
|
||||
return len(aps[i].SubjectsRaw) > len(aps[j].SubjectsRaw)
|
||||
return len(aps[i].Subjects) > len(aps[j].Subjects)
|
||||
})
|
||||
|
||||
emptyAPCount := 0
|
||||
@@ -686,13 +527,13 @@ func consolidateAutomationPolicies(aps []*caddytls.AutomationPolicy) []*caddytls
|
||||
// compute the number of empty policies (disregarding subjects) - see #4128
|
||||
emptyAP := new(caddytls.AutomationPolicy)
|
||||
for i := 0; i < len(aps); i++ {
|
||||
emptyAP.SubjectsRaw = aps[i].SubjectsRaw
|
||||
emptyAP.Subjects = aps[i].Subjects
|
||||
if reflect.DeepEqual(aps[i], emptyAP) {
|
||||
emptyAPCount++
|
||||
if !automationPolicyHasAllPublicNames(aps[i]) {
|
||||
// if this automation policy has internal names, we might as well remove it
|
||||
// so auto-https can implicitly use the internal issuer
|
||||
aps = slices.Delete(aps, i, i+1)
|
||||
aps = append(aps[:i], aps[i+1:]...)
|
||||
i--
|
||||
}
|
||||
}
|
||||
@@ -709,7 +550,7 @@ outer:
|
||||
for j := i + 1; j < len(aps); j++ {
|
||||
// if they're exactly equal in every way, just keep one of them
|
||||
if reflect.DeepEqual(aps[i], aps[j]) {
|
||||
aps = slices.Delete(aps, j, j+1)
|
||||
aps = append(aps[:j], aps[j+1:]...)
|
||||
// must re-evaluate current i against next j; can't skip it!
|
||||
// even if i decrements to -1, will be incremented to 0 immediately
|
||||
i--
|
||||
@@ -723,14 +564,12 @@ outer:
|
||||
// eaten up by the one with subjects; and if both have subjects, we
|
||||
// need to combine their lists
|
||||
if reflect.DeepEqual(aps[i].IssuersRaw, aps[j].IssuersRaw) &&
|
||||
reflect.DeepEqual(aps[i].ManagersRaw, aps[j].ManagersRaw) &&
|
||||
bytes.Equal(aps[i].StorageRaw, aps[j].StorageRaw) &&
|
||||
aps[i].MustStaple == aps[j].MustStaple &&
|
||||
aps[i].KeyType == aps[j].KeyType &&
|
||||
aps[i].OnDemand == aps[j].OnDemand &&
|
||||
aps[i].ReusePrivateKeys == aps[j].ReusePrivateKeys &&
|
||||
aps[i].RenewalWindowRatio == aps[j].RenewalWindowRatio {
|
||||
if len(aps[i].SubjectsRaw) > 0 && len(aps[j].SubjectsRaw) == 0 {
|
||||
if len(aps[i].Subjects) > 0 && len(aps[j].Subjects) == 0 {
|
||||
// later policy (at j) has no subjects ("catch-all"), so we can
|
||||
// remove the identical-but-more-specific policy that comes first
|
||||
// AS LONG AS it is not shadowed by another policy before it; e.g.
|
||||
@@ -739,18 +578,18 @@ outer:
|
||||
// cause example.com to be served by the less specific policy for
|
||||
// '*.com', which might be different (yes we've seen this happen)
|
||||
if automationPolicyShadows(i, aps) >= j {
|
||||
aps = slices.Delete(aps, i, i+1)
|
||||
aps = append(aps[:i], aps[i+1:]...)
|
||||
i--
|
||||
continue outer
|
||||
}
|
||||
} else {
|
||||
// avoid repeated subjects
|
||||
for _, subj := range aps[j].SubjectsRaw {
|
||||
if !slices.Contains(aps[i].SubjectsRaw, subj) {
|
||||
aps[i].SubjectsRaw = append(aps[i].SubjectsRaw, subj)
|
||||
for _, subj := range aps[j].Subjects {
|
||||
if !sliceContains(aps[i].Subjects, subj) {
|
||||
aps[i].Subjects = append(aps[i].Subjects, subj)
|
||||
}
|
||||
}
|
||||
aps = slices.Delete(aps, j, j+1)
|
||||
aps = append(aps[:j], aps[j+1:]...)
|
||||
j--
|
||||
}
|
||||
}
|
||||
@@ -763,16 +602,20 @@ outer:
|
||||
// automationPolicyIsSubset returns true if a's subjects are a subset
|
||||
// of b's subjects.
|
||||
func automationPolicyIsSubset(a, b *caddytls.AutomationPolicy) bool {
|
||||
if len(b.SubjectsRaw) == 0 {
|
||||
if len(b.Subjects) == 0 {
|
||||
return true
|
||||
}
|
||||
if len(a.SubjectsRaw) == 0 {
|
||||
if len(a.Subjects) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, aSubj := range a.SubjectsRaw {
|
||||
inSuperset := slices.ContainsFunc(b.SubjectsRaw, func(bSubj string) bool {
|
||||
return certmagic.MatchWildcard(aSubj, bSubj)
|
||||
})
|
||||
for _, aSubj := range a.Subjects {
|
||||
var inSuperset bool
|
||||
for _, bSubj := range b.Subjects {
|
||||
if certmagic.MatchWildcard(aSubj, bSubj) {
|
||||
inSuperset = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inSuperset {
|
||||
return false
|
||||
}
|
||||
@@ -798,30 +641,17 @@ func automationPolicyShadows(i int, aps []*caddytls.AutomationPolicy) int {
|
||||
// subjectQualifiesForPublicCert is like certmagic.SubjectQualifiesForPublicCert() except
|
||||
// that this allows domains with multiple wildcard levels like '*.*.example.com' to qualify
|
||||
// if the automation policy has OnDemand enabled (i.e. this function is more lenient).
|
||||
//
|
||||
// IP subjects are considered as non-qualifying for public certs. Technically, there are
|
||||
// now public ACME CAs as well as non-ACME CAs that issue IP certificates. But this function
|
||||
// is used solely for implicit automation (defaults), where it gets really complicated to
|
||||
// keep track of which issuers support IP certificates in which circumstances. Currently,
|
||||
// issuers that support IP certificates are very few, and all require some sort of config
|
||||
// from the user anyway (such as an account credential). Since we cannot implicitly and
|
||||
// automatically get public IP certs without configuration from the user, we treat IPs as
|
||||
// not qualifying for public certificates. Users should expressly configure an issuer
|
||||
// that supports IP certs for that purpose.
|
||||
func subjectQualifiesForPublicCert(ap *caddytls.AutomationPolicy, subj string) bool {
|
||||
return !certmagic.SubjectIsIP(subj) &&
|
||||
!certmagic.SubjectIsInternal(subj) &&
|
||||
(strings.Count(subj, "*.") < 2 || ap.OnDemand)
|
||||
}
|
||||
|
||||
// automationPolicyHasAllPublicNames returns true if all the names on the policy
|
||||
// do NOT qualify for public certs OR are tailscale domains.
|
||||
func automationPolicyHasAllPublicNames(ap *caddytls.AutomationPolicy) bool {
|
||||
return !slices.ContainsFunc(ap.SubjectsRaw, func(i string) bool {
|
||||
return !subjectQualifiesForPublicCert(ap, i) || isTailscaleDomain(i)
|
||||
})
|
||||
}
|
||||
|
||||
func isTailscaleDomain(name string) bool {
|
||||
return strings.HasSuffix(strings.ToLower(name), ".ts.net")
|
||||
for _, subj := range ap.Subjects {
|
||||
if !subjectQualifiesForPublicCert(ap, subj) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -47,8 +47,8 @@ func TestAutomationPolicyIsSubset(t *testing.T) {
|
||||
expect: false,
|
||||
},
|
||||
} {
|
||||
apA := &caddytls.AutomationPolicy{SubjectsRaw: test.a}
|
||||
apB := &caddytls.AutomationPolicy{SubjectsRaw: test.b}
|
||||
apA := &caddytls.AutomationPolicy{Subjects: test.a}
|
||||
apB := &caddytls.AutomationPolicy{Subjects: test.b}
|
||||
if actual := automationPolicyIsSubset(apA, apB); actual != test.expect {
|
||||
t.Errorf("Test %d: Expected %t but got %t (A: %v B: %v)", i, test.expect, actual, test.a, test.b)
|
||||
}
|
||||
|
||||
+14
-59
@@ -30,14 +30,8 @@ func init() {
|
||||
caddy.RegisterModule(HTTPLoader{})
|
||||
}
|
||||
|
||||
// HTTPLoader can load Caddy configs over HTTP(S).
|
||||
//
|
||||
// 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
|
||||
// returned in the HTTP response from the server. The Content-Type header is
|
||||
// read just like the admin API's `/load` endpoint. If you don't have control
|
||||
// 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.
|
||||
// HTTPLoader can load Caddy configs over HTTP(S). It can adapt the config
|
||||
// based on the Content-Type header of the HTTP response.
|
||||
type HTTPLoader struct {
|
||||
// The method for the request. Default: GET
|
||||
Method string `json:"method,omitempty"`
|
||||
@@ -51,11 +45,6 @@ type HTTPLoader struct {
|
||||
// Maximum time allowed for a complete connection and request.
|
||||
Timeout caddy.Duration `json:"timeout,omitempty"`
|
||||
|
||||
// The name of the config adapter to use, if any. Only needed
|
||||
// if the HTTP response is not a JSON config and if the server's
|
||||
// Content-Type header is missing or incorrect.
|
||||
Adapter string `json:"adapter,omitempty"`
|
||||
|
||||
TLS *struct {
|
||||
// Present this instance's managed remote identity credentials to the server.
|
||||
UseServerIdentity bool `json:"use_server_identity,omitempty"`
|
||||
@@ -105,7 +94,7 @@ func (hl HTTPLoader) LoadConfig(ctx caddy.Context) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := doHttpCallWithRetries(ctx, client, req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -119,54 +108,17 @@ func (hl HTTPLoader) LoadConfig(ctx caddy.Context) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// adapt the config based on either manually-configured adapter or server's response header
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if hl.Adapter != "" {
|
||||
ct = "text/" + hl.Adapter
|
||||
}
|
||||
result, warnings, err := adaptByContentType(ct, body)
|
||||
result, warnings, err := adaptByContentType(resp.Header.Get("Content-Type"), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, warn := range warnings {
|
||||
ctx.Logger().Warn(warn.String())
|
||||
ctx.Logger(hl).Warn(warn.String())
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func attemptHttpCall(client *http.Client, request *http.Request) (*http.Response, error) {
|
||||
resp, err := client.Do(request) //nolint:gosec // no SSRF; comes from trusted config
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("problem calling http loader url: %v", err)
|
||||
} else if resp.StatusCode < 200 || resp.StatusCode > 499 {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("bad response status code from http loader url: %v", resp.StatusCode)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func doHttpCallWithRetries(ctx caddy.Context, client *http.Client, request *http.Request) (*http.Response, error) {
|
||||
var resp *http.Response
|
||||
var err error
|
||||
const maxAttempts = 10
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
resp, err = attemptHttpCall(client, request)
|
||||
if err != nil && i < maxAttempts-1 {
|
||||
select {
|
||||
case <-time.After(time.Millisecond * 500):
|
||||
case <-ctx.Done():
|
||||
return resp, ctx.Err()
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (hl HTTPLoader) makeClient(ctx caddy.Context) (*http.Client, error) {
|
||||
client := &http.Client{
|
||||
Timeout: time.Duration(hl.Timeout),
|
||||
@@ -177,20 +129,23 @@ func (hl HTTPLoader) makeClient(ctx caddy.Context) (*http.Client, error) {
|
||||
|
||||
// client authentication
|
||||
if hl.TLS.UseServerIdentity {
|
||||
certs, err := ctx.IdentityCredentials(ctx.Logger())
|
||||
certs, err := ctx.IdentityCredentials(ctx.Logger(hl))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting server identity credentials: %v", err)
|
||||
}
|
||||
// See https://github.com/securego/gosec/issues/1054#issuecomment-2072235199
|
||||
//nolint:gosec
|
||||
tlsConfig = &tls.Config{Certificates: certs}
|
||||
if tlsConfig == nil {
|
||||
tlsConfig = new(tls.Config)
|
||||
}
|
||||
tlsConfig.Certificates = certs
|
||||
} else if hl.TLS.ClientCertificateFile != "" && hl.TLS.ClientCertificateKeyFile != "" {
|
||||
cert, err := tls.LoadX509KeyPair(hl.TLS.ClientCertificateFile, hl.TLS.ClientCertificateKeyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
//nolint:gosec
|
||||
tlsConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
if tlsConfig == nil {
|
||||
tlsConfig = new(tls.Config)
|
||||
}
|
||||
tlsConfig.Certificates = []tls.Certificate{cert}
|
||||
}
|
||||
|
||||
// trusted server certs
|
||||
|
||||
+6
-57
@@ -58,10 +58,6 @@ func (al adminLoad) Routes() []caddy.AdminRoute {
|
||||
Pattern: "/load",
|
||||
Handler: caddy.AdminHandlerFunc(al.handleLoad),
|
||||
},
|
||||
{
|
||||
Pattern: "/adapt",
|
||||
Handler: caddy.AdminHandlerFunc(al.handleAdapt),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +102,7 @@ func (adminLoad) handleLoad(w http.ResponseWriter, r *http.Request) error {
|
||||
if err != nil {
|
||||
caddy.Log().Named("admin.api.load").Error(err.Error())
|
||||
}
|
||||
_, _ = w.Write(respBody) //nolint:gosec // false positive: no XSS here
|
||||
_, _ = w.Write(respBody)
|
||||
}
|
||||
body = result
|
||||
}
|
||||
@@ -121,60 +117,12 @@ func (adminLoad) handleLoad(w http.ResponseWriter, r *http.Request) error {
|
||||
}
|
||||
}
|
||||
|
||||
// If this request changed the config, clear the last
|
||||
// config info we have stored, if it is different from
|
||||
// the original source.
|
||||
caddy.ClearLastConfigIfDifferent(
|
||||
r.Header.Get("Caddy-Config-Source-File"),
|
||||
r.Header.Get("Caddy-Config-Source-Adapter"))
|
||||
|
||||
caddy.Log().Named("admin.api").Info("load complete")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleAdapt adapts the given Caddy config to JSON and responds with the result.
|
||||
func (adminLoad) handleAdapt(w http.ResponseWriter, r *http.Request) error {
|
||||
if r.Method != http.MethodPost {
|
||||
return caddy.APIError{
|
||||
HTTPStatus: http.StatusMethodNotAllowed,
|
||||
Err: fmt.Errorf("method not allowed"),
|
||||
}
|
||||
}
|
||||
|
||||
buf := bufPool.Get().(*bytes.Buffer)
|
||||
buf.Reset()
|
||||
defer bufPool.Put(buf)
|
||||
|
||||
_, err := io.Copy(buf, r.Body)
|
||||
if err != nil {
|
||||
return caddy.APIError{
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
Err: fmt.Errorf("reading request body: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
result, warnings, err := adaptByContentType(r.Header.Get("Content-Type"), buf.Bytes())
|
||||
if err != nil {
|
||||
return caddy.APIError{
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
out := struct {
|
||||
Warnings []Warning `json:"warnings,omitempty"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
}{
|
||||
Warnings: warnings,
|
||||
Result: result,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
|
||||
// adaptByContentType adapts body to Caddy JSON using the adapter specified by contentType.
|
||||
// adaptByContentType adapts body to Caddy JSON using the adapter specified by contenType.
|
||||
// If contentType is empty or ends with "/json", the input will be returned, as a no-op.
|
||||
func adaptByContentType(contentType string, body []byte) ([]byte, []Warning, error) {
|
||||
// assume JSON as the default
|
||||
@@ -196,11 +144,12 @@ func adaptByContentType(contentType string, body []byte) ([]byte, []Warning, err
|
||||
}
|
||||
|
||||
// adapter name should be suffix of MIME type
|
||||
_, adapterName, slashFound := strings.Cut(ct, "/")
|
||||
if !slashFound {
|
||||
slashIdx := strings.Index(ct, "/")
|
||||
if slashIdx < 0 {
|
||||
return nil, nil, fmt.Errorf("malformed Content-Type")
|
||||
}
|
||||
|
||||
adapterName := ct[slashIdx+1:]
|
||||
cfgAdapter := GetAdapter(adapterName)
|
||||
if cfgAdapter == nil {
|
||||
return nil, nil, fmt.Errorf("unrecognized config adapter '%s'", adapterName)
|
||||
@@ -215,7 +164,7 @@ func adaptByContentType(contentType string, body []byte) ([]byte, []Warning, err
|
||||
}
|
||||
|
||||
var bufPool = sync.Pool{
|
||||
New: func() any {
|
||||
New: func() interface{} {
|
||||
return new(bytes.Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
+47
-106
@@ -8,7 +8,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -23,20 +22,19 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/aryann/difflib"
|
||||
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig"
|
||||
caddycmd "github.com/caddyserver/caddy/v2/cmd"
|
||||
|
||||
"github.com/caddyserver/caddy/v2/caddyconfig"
|
||||
// plug in Caddy modules here
|
||||
_ "github.com/caddyserver/caddy/v2/modules/standard"
|
||||
)
|
||||
|
||||
// Config store any configuration required to make the tests run
|
||||
type Config struct {
|
||||
// Defaults store any configuration required to make the tests run
|
||||
type Defaults struct {
|
||||
// Port we expect caddy to listening on
|
||||
AdminPort int
|
||||
// Certificates we expect to be loaded before attempting to run the tests
|
||||
Certificates []string
|
||||
Certifcates []string
|
||||
// TestRequestTimeout is the time to wait for a http request to
|
||||
TestRequestTimeout time.Duration
|
||||
// LoadRequestTimeout is the time to wait for the config to be loaded against the caddy server
|
||||
@@ -44,9 +42,9 @@ type Config struct {
|
||||
}
|
||||
|
||||
// Default testing values
|
||||
var Default = Config{
|
||||
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"},
|
||||
var Default = Defaults{
|
||||
AdminPort: 2019,
|
||||
Certifcates: []string{"/caddy.localhost.crt", "/caddy.localhost.key"},
|
||||
TestRequestTimeout: 5 * time.Second,
|
||||
LoadRequestTimeout: 5 * time.Second,
|
||||
}
|
||||
@@ -60,12 +58,12 @@ var (
|
||||
type Tester struct {
|
||||
Client *http.Client
|
||||
configLoaded bool
|
||||
t testing.TB
|
||||
config Config
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
// NewTester will create a new testing client with an attached cookie jar
|
||||
func NewTester(t testing.TB) *Tester {
|
||||
func NewTester(t *testing.T) *Tester {
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create cookiejar: %s", err)
|
||||
@@ -79,29 +77,9 @@ func NewTester(t testing.TB) *Tester {
|
||||
},
|
||||
configLoaded: false,
|
||||
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 {
|
||||
Response string
|
||||
}
|
||||
@@ -116,12 +94,13 @@ func timeElapsed(start time.Time, name string) {
|
||||
// InitServer this will configure the server with a configurion of a specific
|
||||
// type. The configType must be either "json" or the adapter type.
|
||||
func (tc *Tester) InitServer(rawConfig string, configType string) {
|
||||
|
||||
if err := tc.initServer(rawConfig, configType); err != nil {
|
||||
tc.t.Logf("failed to load config: %s", err)
|
||||
tc.t.Fail()
|
||||
}
|
||||
if err := tc.ensureConfigRunning(rawConfig, configType); err != nil {
|
||||
tc.t.Logf("failed ensuring config is running: %s", err)
|
||||
tc.t.Logf("failed ensurng config is running: %s", err)
|
||||
tc.t.Fail()
|
||||
}
|
||||
}
|
||||
@@ -129,12 +108,13 @@ func (tc *Tester) InitServer(rawConfig string, configType string) {
|
||||
// InitServer this will configure the server with a configurion of a specific
|
||||
// type. The configType must be either "json" or the adapter type.
|
||||
func (tc *Tester) initServer(rawConfig string, configType string) error {
|
||||
|
||||
if testing.Short() {
|
||||
tc.t.SkipNow()
|
||||
return nil
|
||||
}
|
||||
|
||||
err := validateTestPrerequisites(tc)
|
||||
err := validateTestPrerequisites()
|
||||
if err != nil {
|
||||
tc.t.Skipf("skipping tests as failed integration prerequisites. %s", err)
|
||||
return nil
|
||||
@@ -142,7 +122,8 @@ func (tc *Tester) initServer(rawConfig string, configType string) error {
|
||||
|
||||
tc.t.Cleanup(func() {
|
||||
if tc.t.Failed() && tc.configLoaded {
|
||||
res, err := http.Get(fmt.Sprintf("http://localhost:%d/config/", tc.config.AdminPort))
|
||||
|
||||
res, err := http.Get(fmt.Sprintf("http://localhost:%d/config/", Default.AdminPort))
|
||||
if err != nil {
|
||||
tc.t.Log("unable to read the current config")
|
||||
return
|
||||
@@ -157,25 +138,11 @@ func (tc *Tester) initServer(rawConfig string, configType string) error {
|
||||
})
|
||||
|
||||
rawConfig = prependCaddyFilePath(rawConfig)
|
||||
// normalize JSON config
|
||||
if configType == "json" {
|
||||
tc.t.Logf("Before: %s", rawConfig)
|
||||
var conf any
|
||||
if err := json.Unmarshal([]byte(rawConfig), &conf); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := json.Marshal(conf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawConfig = string(c)
|
||||
tc.t.Logf("After: %s", rawConfig)
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: tc.config.LoadRequestTimeout,
|
||||
Timeout: Default.LoadRequestTimeout,
|
||||
}
|
||||
start := time.Now()
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/load", tc.config.AdminPort), strings.NewReader(rawConfig))
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("http://localhost:%d/load", Default.AdminPort), strings.NewReader(rawConfig))
|
||||
if err != nil {
|
||||
tc.t.Errorf("failed to create request. %s", err)
|
||||
return err
|
||||
@@ -187,7 +154,7 @@ func (tc *Tester) initServer(rawConfig string, configType string) error {
|
||||
req.Header.Add("Content-Type", "text/"+configType)
|
||||
}
|
||||
|
||||
res, err := client.Do(req) //nolint:gosec // no SSRF because URL is hard-coded to localhost, and port comes from config
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
tc.t.Errorf("unable to contact caddy server. %s", err)
|
||||
return err
|
||||
@@ -219,18 +186,18 @@ func (tc *Tester) ensureConfigRunning(rawConfig string, configType string) error
|
||||
expectedBytes, _, _ = adapter.Adapt([]byte(rawConfig), nil)
|
||||
}
|
||||
|
||||
var expected any
|
||||
var expected interface{}
|
||||
err := json.Unmarshal(expectedBytes, &expected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: tc.config.LoadRequestTimeout,
|
||||
Timeout: Default.LoadRequestTimeout,
|
||||
}
|
||||
|
||||
fetchConfig := func(client *http.Client) any {
|
||||
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", tc.config.AdminPort))
|
||||
fetchConfig := func(client *http.Client) interface{} {
|
||||
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", Default.AdminPort))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -239,7 +206,7 @@ func (tc *Tester) ensureConfigRunning(rawConfig string, configType string) error
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var actual any
|
||||
var actual interface{}
|
||||
err = json.Unmarshal(actualBytes, &actual)
|
||||
if err != nil {
|
||||
return nil
|
||||
@@ -247,68 +214,52 @@ func (tc *Tester) ensureConfigRunning(rawConfig string, configType string) error
|
||||
return actual
|
||||
}
|
||||
|
||||
for retries := 10; retries > 0; retries-- {
|
||||
for retries := 4; retries > 0; retries-- {
|
||||
if reflect.DeepEqual(expected, fetchConfig(client)) {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(1 * time.Second)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
tc.t.Errorf("POSTed configuration isn't active")
|
||||
return errors.New("EnsureConfigRunning: POSTed configuration isn't active")
|
||||
}
|
||||
|
||||
const initConfig = `{
|
||||
admin localhost:%d
|
||||
}
|
||||
`
|
||||
|
||||
// validateTestPrerequisites ensures the certificates are available in the
|
||||
// designated path and Caddy sub-process is running.
|
||||
func validateTestPrerequisites(tc *Tester) error {
|
||||
func validateTestPrerequisites() error {
|
||||
|
||||
// check certificates are found
|
||||
for _, certName := range tc.config.Certificates {
|
||||
if _, err := os.Stat(getIntegrationDir() + certName); errors.Is(err, fs.ErrNotExist) {
|
||||
for _, certName := range Default.Certifcates {
|
||||
if _, err := os.Stat(getIntegrationDir() + certName); os.IsNotExist(err) {
|
||||
return fmt.Errorf("caddy integration test certificates (%s) not found", certName)
|
||||
}
|
||||
}
|
||||
|
||||
if isCaddyAdminRunning(tc) != nil {
|
||||
// setup the init config file, and set the cleanup afterwards
|
||||
f, err := os.CreateTemp("", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tc.t.Cleanup(func() {
|
||||
os.Remove(f.Name()) //nolint:gosec // false positive, filename comes from std lib, no path traversal
|
||||
})
|
||||
if _, err := fmt.Fprintf(f, initConfig, tc.config.AdminPort); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if isCaddyAdminRunning() != nil {
|
||||
// start inprocess caddy server
|
||||
os.Args = []string{"caddy", "run", "--config", f.Name(), "--adapter", "caddyfile"}
|
||||
os.Args = []string{"caddy", "run"}
|
||||
go func() {
|
||||
caddycmd.Main()
|
||||
}()
|
||||
|
||||
// wait for caddy to start serving the initial config
|
||||
for retries := 10; retries > 0 && isCaddyAdminRunning(tc) != nil; retries-- {
|
||||
time.Sleep(1 * time.Second)
|
||||
for retries := 4; retries > 0 && isCaddyAdminRunning() != nil; retries-- {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// one more time to return the error
|
||||
return isCaddyAdminRunning(tc)
|
||||
return isCaddyAdminRunning()
|
||||
}
|
||||
|
||||
func isCaddyAdminRunning(tc *Tester) error {
|
||||
func isCaddyAdminRunning() error {
|
||||
// assert that caddy is running
|
||||
client := &http.Client{
|
||||
Timeout: tc.config.LoadRequestTimeout,
|
||||
Timeout: Default.LoadRequestTimeout,
|
||||
}
|
||||
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", tc.config.AdminPort))
|
||||
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/config/", Default.AdminPort))
|
||||
if err != nil {
|
||||
return fmt.Errorf("caddy integration test caddy server not running. Expected to be listening on localhost:%d", tc.config.AdminPort)
|
||||
return fmt.Errorf("caddy integration test caddy server not running. Expected to be listening on localhost:%d", Default.AdminPort)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
@@ -316,6 +267,7 @@ func isCaddyAdminRunning(tc *Tester) error {
|
||||
}
|
||||
|
||||
func getIntegrationDir() string {
|
||||
|
||||
_, filename, _, ok := runtime.Caller(1)
|
||||
if !ok {
|
||||
panic("unable to determine the current file path")
|
||||
@@ -335,6 +287,7 @@ func prependCaddyFilePath(rawConfig string) string {
|
||||
|
||||
// CreateTestingTransport creates a testing transport that forces call dialing connections to happen locally
|
||||
func CreateTestingTransport() *http.Transport {
|
||||
|
||||
dialer := net.Dialer{
|
||||
Timeout: 5 * time.Second,
|
||||
KeepAlive: 5 * time.Second,
|
||||
@@ -362,7 +315,6 @@ func CreateTestingTransport() *http.Transport {
|
||||
|
||||
// AssertLoadError will load a config and expect an error
|
||||
func AssertLoadError(t *testing.T, rawConfig string, configType string, expectedError string) {
|
||||
t.Helper()
|
||||
|
||||
tc := NewTester(t)
|
||||
|
||||
@@ -374,7 +326,6 @@ func AssertLoadError(t *testing.T, rawConfig string, configType string, expected
|
||||
|
||||
// AssertRedirect makes a request and asserts the redirection happens
|
||||
func (tc *Tester) AssertRedirect(requestURI string, expectedToLocation string, expectedStatusCode int) *http.Response {
|
||||
tc.t.Helper()
|
||||
|
||||
redirectPolicyFunc := func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
@@ -412,8 +363,7 @@ func (tc *Tester) AssertRedirect(requestURI string, expectedToLocation string, e
|
||||
}
|
||||
|
||||
// CompareAdapt adapts a config and then compares it against an expected result
|
||||
func CompareAdapt(t testing.TB, filename, rawConfig string, adapterName string, expectedResponse string) bool {
|
||||
t.Helper()
|
||||
func CompareAdapt(t *testing.T, filename, rawConfig string, adapterName string, expectedResponse string) bool {
|
||||
|
||||
cfgAdapter := caddyconfig.GetAdapter(adapterName)
|
||||
if cfgAdapter == nil {
|
||||
@@ -421,7 +371,7 @@ func CompareAdapt(t testing.TB, filename, rawConfig string, adapterName string,
|
||||
return false
|
||||
}
|
||||
|
||||
options := make(map[string]any)
|
||||
options := make(map[string]interface{})
|
||||
|
||||
result, warnings, err := cfgAdapter.Adapt([]byte(rawConfig), options)
|
||||
if err != nil {
|
||||
@@ -473,9 +423,7 @@ func CompareAdapt(t testing.TB, filename, rawConfig string, adapterName string,
|
||||
}
|
||||
|
||||
// AssertAdapt adapts a config and then tests it against an expected result
|
||||
func AssertAdapt(t testing.TB, rawConfig string, adapterName string, expectedResponse string) {
|
||||
t.Helper()
|
||||
|
||||
func AssertAdapt(t *testing.T, rawConfig string, adapterName string, expectedResponse string) {
|
||||
ok := CompareAdapt(t, "Caddyfile", rawConfig, adapterName, expectedResponse)
|
||||
if !ok {
|
||||
t.Fail()
|
||||
@@ -484,7 +432,7 @@ func AssertAdapt(t testing.TB, rawConfig string, adapterName string, expectedRes
|
||||
|
||||
// Generic request functions
|
||||
|
||||
func applyHeaders(t testing.TB, req *http.Request, requestHeaders []string) {
|
||||
func applyHeaders(t *testing.T, req *http.Request, requestHeaders []string) {
|
||||
requestContentType := ""
|
||||
for _, requestHeader := range requestHeaders {
|
||||
arr := strings.SplitAfterN(requestHeader, ":", 2)
|
||||
@@ -504,15 +452,14 @@ func applyHeaders(t testing.TB, req *http.Request, requestHeaders []string) {
|
||||
|
||||
// AssertResponseCode will execute the request and verify the status code, returns a response for additional assertions
|
||||
func (tc *Tester) AssertResponseCode(req *http.Request, expectedStatusCode int) *http.Response {
|
||||
tc.t.Helper()
|
||||
|
||||
resp, err := tc.Client.Do(req) //nolint:gosec // no SSRFs demonstrated
|
||||
resp, err := tc.Client.Do(req)
|
||||
if err != nil {
|
||||
tc.t.Fatalf("failed to call server %s", err)
|
||||
}
|
||||
|
||||
if expectedStatusCode != resp.StatusCode {
|
||||
tc.t.Errorf("requesting \"%s\" expected status code: %d but got %d", req.URL.RequestURI(), expectedStatusCode, resp.StatusCode)
|
||||
tc.t.Errorf("requesting \"%s\" expected status code: %d but got %d", req.RequestURI, expectedStatusCode, resp.StatusCode)
|
||||
}
|
||||
|
||||
return resp
|
||||
@@ -520,7 +467,6 @@ func (tc *Tester) AssertResponseCode(req *http.Request, expectedStatusCode int)
|
||||
|
||||
// AssertResponse request a URI and assert the status code and the body contains a string
|
||||
func (tc *Tester) AssertResponse(req *http.Request, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
||||
tc.t.Helper()
|
||||
|
||||
resp := tc.AssertResponseCode(req, expectedStatusCode)
|
||||
|
||||
@@ -543,7 +489,6 @@ func (tc *Tester) AssertResponse(req *http.Request, expectedStatusCode int, expe
|
||||
|
||||
// AssertGetResponse GET a URI and expect a statusCode and body text
|
||||
func (tc *Tester) AssertGetResponse(requestURI string, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
||||
tc.t.Helper()
|
||||
|
||||
req, err := http.NewRequest("GET", requestURI, nil)
|
||||
if err != nil {
|
||||
@@ -555,7 +500,6 @@ func (tc *Tester) AssertGetResponse(requestURI string, expectedStatusCode int, e
|
||||
|
||||
// AssertDeleteResponse request a URI and expect a statusCode and body text
|
||||
func (tc *Tester) AssertDeleteResponse(requestURI string, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
||||
tc.t.Helper()
|
||||
|
||||
req, err := http.NewRequest("DELETE", requestURI, nil)
|
||||
if err != nil {
|
||||
@@ -567,7 +511,6 @@ func (tc *Tester) AssertDeleteResponse(requestURI string, expectedStatusCode int
|
||||
|
||||
// AssertPostResponseBody POST to a URI and assert the response code and body
|
||||
func (tc *Tester) AssertPostResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
||||
tc.t.Helper()
|
||||
|
||||
req, err := http.NewRequest("POST", requestURI, requestBody)
|
||||
if err != nil {
|
||||
@@ -582,7 +525,6 @@ func (tc *Tester) AssertPostResponseBody(requestURI string, requestHeaders []str
|
||||
|
||||
// AssertPutResponseBody PUT to a URI and assert the response code and body
|
||||
func (tc *Tester) AssertPutResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
||||
tc.t.Helper()
|
||||
|
||||
req, err := http.NewRequest("PUT", requestURI, requestBody)
|
||||
if err != nil {
|
||||
@@ -597,7 +539,6 @@ func (tc *Tester) AssertPutResponseBody(requestURI string, requestHeaders []stri
|
||||
|
||||
// AssertPatchResponseBody PATCH to a URI and assert the response code and body
|
||||
func (tc *Tester) AssertPatchResponseBody(requestURI string, requestHeaders []string, requestBody *bytes.Buffer, expectedStatusCode int, expectedBody string) (*http.Response, string) {
|
||||
tc.t.Helper()
|
||||
|
||||
req, err := http.NewRequest("PATCH", requestURI, requestBody)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package caddytest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -32,97 +31,3 @@ func TestReplaceCertificatePaths(t *testing.T) {
|
||||
t.Error("expected redirect uri to be unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadUnorderedJSON(t *testing.T) {
|
||||
tester := NewTester(t)
|
||||
tester.InitServer(`
|
||||
{
|
||||
"logging": {
|
||||
"logs": {
|
||||
"default": {
|
||||
"level": "DEBUG",
|
||||
"writer": {
|
||||
"output": "stdout"
|
||||
}
|
||||
},
|
||||
"sStdOutLogs": {
|
||||
"level": "DEBUG",
|
||||
"writer": {
|
||||
"output": "stdout"
|
||||
},
|
||||
"include": [
|
||||
"http.*",
|
||||
"admin.*"
|
||||
]
|
||||
},
|
||||
"sFileLogs": {
|
||||
"level": "DEBUG",
|
||||
"writer": {
|
||||
"output": "stdout"
|
||||
},
|
||||
"include": [
|
||||
"http.*",
|
||||
"admin.*"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"listen": "localhost:2999"
|
||||
},
|
||||
"apps": {
|
||||
"pki": {
|
||||
"certificate_authorities" : {
|
||||
"local" : {
|
||||
"install_trust": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"http_port": 9080,
|
||||
"https_port": 9443,
|
||||
"servers": {
|
||||
"s_server": {
|
||||
"listen": [
|
||||
":9080"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "static_response",
|
||||
"body": "Hello"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"localhost",
|
||||
"127.0.0.1"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"logs": {
|
||||
"default_logger_name": "sStdOutLogs",
|
||||
"logger_names": {
|
||||
"localhost": "sStdOutLogs",
|
||||
"127.0.0.1": "sFileLogs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, "json")
|
||||
req, err := http.NewRequest(http.MethodGet, "http://localhost:9080/", nil)
|
||||
if err != nil {
|
||||
t.Fail()
|
||||
return
|
||||
}
|
||||
tester.AssertResponseCode(req, 200)
|
||||
}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mholt/acmez/v3"
|
||||
"github.com/mholt/acmez/v3/acme"
|
||||
smallstepacme "github.com/smallstep/certificates/acme"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/exp/zapslog"
|
||||
|
||||
"github.com/caddyserver/caddy/v2"
|
||||
"github.com/caddyserver/caddy/v2/caddytest"
|
||||
)
|
||||
|
||||
const acmeChallengePort = 9081
|
||||
|
||||
// Test the basic functionality of Caddy's ACME server
|
||||
func TestACMEServerWithDefaults(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
tester := caddytest.NewTester(t)
|
||||
tester.InitServer(`
|
||||
{
|
||||
skip_install_trust
|
||||
admin localhost:2999
|
||||
http_port 9080
|
||||
https_port 9443
|
||||
local_certs
|
||||
}
|
||||
acme.localhost {
|
||||
acme_server
|
||||
}
|
||||
`, "caddyfile")
|
||||
|
||||
client := acmez.Client{
|
||||
Client: &acme.Client{
|
||||
Directory: "https://acme.localhost:9443/acme/local/directory",
|
||||
HTTPClient: tester.Client,
|
||||
Logger: slog.New(zapslog.NewHandler(logger.Core())),
|
||||
},
|
||||
ChallengeSolvers: map[string]acmez.Solver{
|
||||
acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},
|
||||
},
|
||||
}
|
||||
|
||||
accountPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Errorf("generating account key: %v", err)
|
||||
}
|
||||
account := acme.Account{
|
||||
Contact: []string{"mailto:you@example.com"},
|
||||
TermsOfServiceAgreed: true,
|
||||
PrivateKey: accountPrivateKey,
|
||||
}
|
||||
account, err = client.NewAccount(ctx, account)
|
||||
if err != nil {
|
||||
t.Errorf("new account: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Every certificate needs a key.
|
||||
certPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Errorf("generating certificate key: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
certs, err := client.ObtainCertificateForSANs(ctx, account, certPrivateKey, []string{"localhost"})
|
||||
if err != nil {
|
||||
t.Errorf("obtaining certificate: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// ACME servers should usually give you the entire certificate chain
|
||||
// in PEM format, and sometimes even alternate chains! It's up to you
|
||||
// which one(s) to store and use, but whatever you do, be sure to
|
||||
// store the certificate and key somewhere safe and secure, i.e. don't
|
||||
// lose them!
|
||||
for _, cert := range certs {
|
||||
t.Logf("Certificate %q:\n%s\n\n", cert.URL, cert.ChainPEM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACMEServerWithMismatchedChallenges(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := caddy.Log().Named("acmez")
|
||||
|
||||
tester := caddytest.NewTester(t)
|
||||
tester.InitServer(`
|
||||
{
|
||||
skip_install_trust
|
||||
admin localhost:2999
|
||||
http_port 9080
|
||||
https_port 9443
|
||||
local_certs
|
||||
}
|
||||
acme.localhost {
|
||||
acme_server {
|
||||
challenges tls-alpn-01
|
||||
}
|
||||
}
|
||||
`, "caddyfile")
|
||||
|
||||
client := acmez.Client{
|
||||
Client: &acme.Client{
|
||||
Directory: "https://acme.localhost:9443/acme/local/directory",
|
||||
HTTPClient: tester.Client,
|
||||
Logger: slog.New(zapslog.NewHandler(logger.Core())),
|
||||
},
|
||||
ChallengeSolvers: map[string]acmez.Solver{
|
||||
acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},
|
||||
},
|
||||
}
|
||||
|
||||
accountPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Errorf("generating account key: %v", err)
|
||||
}
|
||||
account := acme.Account{
|
||||
Contact: []string{"mailto:you@example.com"},
|
||||
TermsOfServiceAgreed: true,
|
||||
PrivateKey: accountPrivateKey,
|
||||
}
|
||||
account, err = client.NewAccount(ctx, account)
|
||||
if err != nil {
|
||||
t.Errorf("new account: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Every certificate needs a key.
|
||||
certPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Errorf("generating certificate key: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
certs, err := client.ObtainCertificateForSANs(ctx, account, certPrivateKey, []string{"localhost"})
|
||||
if len(certs) > 0 {
|
||||
t.Errorf("expected '0' certificates, but received '%d'", len(certs))
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("expected errors, but received none")
|
||||
}
|
||||
const expectedErrMsg = "no solvers available for remaining challenges (configured=[http-01] offered=[tls-alpn-01] remaining=[tls-alpn-01])"
|
||||
if !strings.Contains(err.Error(), expectedErrMsg) {
|
||||
t.Errorf(`received error message does not match expectation: expected="%s" received="%s"`, expectedErrMsg, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// naiveHTTPSolver is a no-op acmez.Solver for example purposes only.
|
||||
type naiveHTTPSolver struct {
|
||||
srv *http.Server
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func (s *naiveHTTPSolver) Present(ctx context.Context, challenge acme.Challenge) error {
|
||||
smallstepacme.InsecurePortHTTP01 = acmeChallengePort
|
||||
s.srv = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", acmeChallengePort),
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host, _, err := net.SplitHostPort(r.Host)
|
||||
if err != nil {
|
||||
host = r.Host
|
||||
}
|
||||
s.logger.Info("received request on challenge server", zap.String("path", r.URL.Path))
|
||||
if r.Method == "GET" && r.URL.Path == challenge.HTTP01ResourcePath() && strings.EqualFold(host, challenge.Identifier.Value) {
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
w.Write([]byte(challenge.KeyAuthorization))
|
||||
r.Close = true
|
||||
s.logger.Info("served key authentication",
|
||||
zap.String("identifier", challenge.Identifier.Value),
|
||||
zap.String("challenge", "http-01"),
|
||||
zap.String("remote", r.RemoteAddr),
|
||||
)
|
||||
}
|
||||
}),
|
||||
}
|
||||
l, err := net.Listen("tcp", fmt.Sprintf(":%d", acmeChallengePort))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.Info("present challenge", zap.Any("challenge", challenge))
|
||||
go s.srv.Serve(l)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s naiveHTTPSolver) CleanUp(ctx context.Context, challenge acme.Challenge) error {
|
||||
smallstepacme.InsecurePortHTTP01 = 0
|
||||
s.logger.Info("cleanup", zap.Any("challenge", challenge))
|
||||
if s.srv != nil {
|
||||
s.srv.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mholt/acmez/v3"
|
||||
"github.com/mholt/acmez/v3/acme"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/exp/zapslog"
|
||||
|
||||
"github.com/caddyserver/caddy/v2/caddytest"
|
||||
)
|
||||
|
||||
func TestACMEServerDirectory(t *testing.T) {
|
||||
tester := caddytest.NewTester(t)
|
||||
tester.InitServer(`
|
||||
{
|
||||
skip_install_trust
|
||||
local_certs
|
||||
admin localhost:2999
|
||||
http_port 9080
|
||||
https_port 9443
|
||||
pki {
|
||||
ca local {
|
||||
name "Caddy Local Authority"
|
||||
}
|
||||
}
|
||||
}
|
||||
acme.localhost:9443 {
|
||||
acme_server
|
||||
}
|
||||
`, "caddyfile")
|
||||
tester.AssertGetResponse(
|
||||
"https://acme.localhost:9443/acme/local/directory",
|
||||
200,
|
||||
`{"newNonce":"https://acme.localhost:9443/acme/local/new-nonce","newAccount":"https://acme.localhost:9443/acme/local/new-account","newOrder":"https://acme.localhost:9443/acme/local/new-order","revokeCert":"https://acme.localhost:9443/acme/local/revoke-cert","keyChange":"https://acme.localhost:9443/acme/local/key-change"}
|
||||
`)
|
||||
}
|
||||
|
||||
func TestACMEServerAllowPolicy(t *testing.T) {
|
||||
tester := caddytest.NewTester(t)
|
||||
tester.InitServer(`
|
||||
{
|
||||
skip_install_trust
|
||||
local_certs
|
||||
admin localhost:2999
|
||||
http_port 9080
|
||||
https_port 9443
|
||||
pki {
|
||||
ca local {
|
||||
name "Caddy Local Authority"
|
||||
}
|
||||
}
|
||||
}
|
||||
acme.localhost {
|
||||
acme_server {
|
||||
challenges http-01
|
||||
allow {
|
||||
domains localhost
|
||||
}
|
||||
}
|
||||
}
|
||||
`, "caddyfile")
|
||||
|
||||
ctx := context.Background()
|
||||
logger, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
client := acmez.Client{
|
||||
Client: &acme.Client{
|
||||
Directory: "https://acme.localhost:9443/acme/local/directory",
|
||||
HTTPClient: tester.Client,
|
||||
Logger: slog.New(zapslog.NewHandler(logger.Core())),
|
||||
},
|
||||
ChallengeSolvers: map[string]acmez.Solver{
|
||||
acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},
|
||||
},
|
||||
}
|
||||
|
||||
accountPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Errorf("generating account key: %v", err)
|
||||
}
|
||||
account := acme.Account{
|
||||
Contact: []string{"mailto:you@example.com"},
|
||||
TermsOfServiceAgreed: true,
|
||||
PrivateKey: accountPrivateKey,
|
||||
}
|
||||
account, err = client.NewAccount(ctx, account)
|
||||
if err != nil {
|
||||
t.Errorf("new account: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Every certificate needs a key.
|
||||
certPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Errorf("generating certificate key: %v", err)
|
||||
return
|
||||
}
|
||||
{
|
||||
certs, err := client.ObtainCertificateForSANs(ctx, account, certPrivateKey, []string{"localhost"})
|
||||
if err != nil {
|
||||
t.Errorf("obtaining certificate for allowed domain: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// ACME servers should usually give you the entire certificate chain
|
||||
// in PEM format, and sometimes even alternate chains! It's up to you
|
||||
// which one(s) to store and use, but whatever you do, be sure to
|
||||
// store the certificate and key somewhere safe and secure, i.e. don't
|
||||
// lose them!
|
||||
for _, cert := range certs {
|
||||
t.Logf("Certificate %q:\n%s\n\n", cert.URL, cert.ChainPEM)
|
||||
}
|
||||
}
|
||||
{
|
||||
_, err := client.ObtainCertificateForSANs(ctx, account, certPrivateKey, []string{"not-matching.localhost"})
|
||||
if err == nil {
|
||||
t.Errorf("obtaining certificate for 'not-matching.localhost' domain")
|
||||
} else if !strings.Contains(err.Error(), "urn:ietf:params:acme:error:rejectedIdentifier") {
|
||||
t.Logf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestACMEServerDenyPolicy(t *testing.T) {
|
||||
tester := caddytest.NewTester(t)
|
||||
tester.InitServer(`
|
||||
{
|
||||
skip_install_trust
|
||||
local_certs
|
||||
admin localhost:2999
|
||||
http_port 9080
|
||||
https_port 9443
|
||||
pki {
|
||||
ca local {
|
||||
name "Caddy Local Authority"
|
||||
}
|
||||
}
|
||||
}
|
||||
acme.localhost {
|
||||
acme_server {
|
||||
deny {
|
||||
domains deny.localhost
|
||||
}
|
||||
}
|
||||
}
|
||||
`, "caddyfile")
|
||||
|
||||
ctx := context.Background()
|
||||
logger, err := zap.NewDevelopment()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
client := acmez.Client{
|
||||
Client: &acme.Client{
|
||||
Directory: "https://acme.localhost:9443/acme/local/directory",
|
||||
HTTPClient: tester.Client,
|
||||
Logger: slog.New(zapslog.NewHandler(logger.Core())),
|
||||
},
|
||||
ChallengeSolvers: map[string]acmez.Solver{
|
||||
acme.ChallengeTypeHTTP01: &naiveHTTPSolver{logger: logger},
|
||||
},
|
||||
}
|
||||
|
||||
accountPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Errorf("generating account key: %v", err)
|
||||
}
|
||||
account := acme.Account{
|
||||
Contact: []string{"mailto:you@example.com"},
|
||||
TermsOfServiceAgreed: true,
|
||||
PrivateKey: accountPrivateKey,
|
||||
}
|
||||
account, err = client.NewAccount(ctx, account)
|
||||
if err != nil {
|
||||
t.Errorf("new account: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Every certificate needs a key.
|
||||
certPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Errorf("generating certificate key: %v", err)
|
||||
return
|
||||
}
|
||||
{
|
||||
_, err := client.ObtainCertificateForSANs(ctx, account, certPrivateKey, []string{"deny.localhost"})
|
||||
if err == nil {
|
||||
t.Errorf("obtaining certificate for 'deny.localhost' domain")
|
||||
} else if !strings.Contains(err.Error(), "urn:ietf:params:acme:error:rejectedIdentifier") {
|
||||
t.Logf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,6 @@ func TestAutoHTTPtoHTTPSRedirectsImplicitPort(t *testing.T) {
|
||||
tester := caddytest.NewTester(t)
|
||||
tester.InitServer(`
|
||||
{
|
||||
admin localhost:2999
|
||||
skip_install_trust
|
||||
http_port 9080
|
||||
https_port 9443
|
||||
}
|
||||
@@ -27,8 +25,6 @@ func TestAutoHTTPtoHTTPSRedirectsExplicitPortSameAsHTTPSPort(t *testing.T) {
|
||||
tester := caddytest.NewTester(t)
|
||||
tester.InitServer(`
|
||||
{
|
||||
skip_install_trust
|
||||
admin localhost:2999
|
||||
http_port 9080
|
||||
https_port 9443
|
||||
}
|
||||
@@ -43,8 +39,6 @@ func TestAutoHTTPtoHTTPSRedirectsExplicitPortDifferentFromHTTPSPort(t *testing.T
|
||||
tester := caddytest.NewTester(t)
|
||||
tester.InitServer(`
|
||||
{
|
||||
skip_install_trust
|
||||
admin localhost:2999
|
||||
http_port 9080
|
||||
https_port 9443
|
||||
}
|
||||
@@ -59,9 +53,6 @@ func TestAutoHTTPRedirectsWithHTTPListenerFirstInAddresses(t *testing.T) {
|
||||
tester := caddytest.NewTester(t)
|
||||
tester.InitServer(`
|
||||
{
|
||||
"admin": {
|
||||
"listen": "localhost:2999"
|
||||
},
|
||||
"apps": {
|
||||
"http": {
|
||||
"http_port": 9080,
|
||||
@@ -83,14 +74,7 @@ func TestAutoHTTPRedirectsWithHTTPListenerFirstInAddresses(t *testing.T) {
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"pki": {
|
||||
"certificate_authorities": {
|
||||
"local": {
|
||||
"install_trust": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, "json")
|
||||
@@ -101,8 +85,6 @@ func TestAutoHTTPRedirectsInsertedBeforeUserDefinedCatchAll(t *testing.T) {
|
||||
tester := caddytest.NewTester(t)
|
||||
tester.InitServer(`
|
||||
{
|
||||
skip_install_trust
|
||||
admin localhost:2999
|
||||
http_port 9080
|
||||
https_port 9443
|
||||
local_certs
|
||||
@@ -126,8 +108,6 @@ func TestAutoHTTPRedirectsInsertedBeforeUserDefinedCatchAllWithNoExplicitHTTPSit
|
||||
tester := caddytest.NewTester(t)
|
||||
tester.InitServer(`
|
||||
{
|
||||
skip_install_trust
|
||||
admin localhost:2999
|
||||
http_port 9080
|
||||
https_port 9443
|
||||
local_certs
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
{
|
||||
acme_dns mock foo
|
||||
}
|
||||
|
||||
example.com {
|
||||
respond "Hello World"
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"example.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "Hello World",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tls": {
|
||||
"automation": {
|
||||
"policies": [
|
||||
{
|
||||
"issuers": [
|
||||
{
|
||||
"challenges": {
|
||||
"dns": {
|
||||
"provider": {
|
||||
"argument": "foo",
|
||||
"name": "mock"
|
||||
}
|
||||
}
|
||||
},
|
||||
"module": "acme"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
dns mock
|
||||
acme_dns
|
||||
}
|
||||
|
||||
example.com {
|
||||
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"example.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tls": {
|
||||
"automation": {
|
||||
"policies": [
|
||||
{
|
||||
"issuers": [
|
||||
{
|
||||
"challenges": {
|
||||
"dns": {}
|
||||
},
|
||||
"module": "acme"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"dns": {
|
||||
"name": "mock"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
acme_dns
|
||||
}
|
||||
|
||||
example.com {
|
||||
respond "Hello World"
|
||||
}
|
||||
----------
|
||||
acme_dns specified without DNS provider config, but no provider specified with 'dns' global option
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
pki {
|
||||
ca custom-ca {
|
||||
name "Custom CA"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
acme.example.com {
|
||||
acme_server {
|
||||
ca custom-ca
|
||||
challenges dns-01
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"acme.example.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"ca": "custom-ca",
|
||||
"challenges": [
|
||||
"dns-01"
|
||||
],
|
||||
"handler": "acme_server"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"pki": {
|
||||
"certificate_authorities": {
|
||||
"custom-ca": {
|
||||
"name": "Custom CA"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
{
|
||||
pki {
|
||||
ca custom-ca {
|
||||
name "Custom CA"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
acme.example.com {
|
||||
acme_server {
|
||||
ca custom-ca
|
||||
challenges
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"acme.example.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"ca": "custom-ca",
|
||||
"handler": "acme_server"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"pki": {
|
||||
"certificate_authorities": {
|
||||
"custom-ca": {
|
||||
"name": "Custom CA"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
{
|
||||
pki {
|
||||
ca internal {
|
||||
name "Internal"
|
||||
root_cn "Internal Root Cert"
|
||||
intermediate_cn "Internal Intermediate Cert"
|
||||
}
|
||||
ca internal-long-lived {
|
||||
name "Long-lived"
|
||||
root_cn "Internal Root Cert 2"
|
||||
intermediate_cn "Internal Intermediate Cert 2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
acme-internal.example.com {
|
||||
acme_server {
|
||||
ca internal
|
||||
}
|
||||
}
|
||||
|
||||
acme-long-lived.example.com {
|
||||
acme_server {
|
||||
ca internal-long-lived
|
||||
lifetime 7d
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"acme-long-lived.example.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"ca": "internal-long-lived",
|
||||
"handler": "acme_server",
|
||||
"lifetime": 604800000000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
},
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"acme-internal.example.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"ca": "internal",
|
||||
"handler": "acme_server"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"pki": {
|
||||
"certificate_authorities": {
|
||||
"internal": {
|
||||
"name": "Internal",
|
||||
"root_common_name": "Internal Root Cert",
|
||||
"intermediate_common_name": "Internal Intermediate Cert"
|
||||
},
|
||||
"internal-long-lived": {
|
||||
"name": "Long-lived",
|
||||
"root_common_name": "Internal Root Cert 2",
|
||||
"intermediate_common_name": "Internal Intermediate Cert 2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
{
|
||||
pki {
|
||||
ca custom-ca {
|
||||
name "Custom CA"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
acme.example.com {
|
||||
acme_server {
|
||||
ca custom-ca
|
||||
challenges dns-01 http-01
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"acme.example.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"ca": "custom-ca",
|
||||
"challenges": [
|
||||
"dns-01",
|
||||
"http-01"
|
||||
],
|
||||
"handler": "acme_server"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"pki": {
|
||||
"certificate_authorities": {
|
||||
"custom-ca": {
|
||||
"name": "Custom CA"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
pki {
|
||||
ca custom-ca {
|
||||
name "Custom CA"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
acme.example.com {
|
||||
acme_server {
|
||||
ca custom-ca
|
||||
allow {
|
||||
domains host-1.internal.example.com host-2.internal.example.com
|
||||
}
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"acme.example.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"ca": "custom-ca",
|
||||
"handler": "acme_server",
|
||||
"policy": {
|
||||
"allow": {
|
||||
"domains": [
|
||||
"host-1.internal.example.com",
|
||||
"host-2.internal.example.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"pki": {
|
||||
"certificate_authorities": {
|
||||
"custom-ca": {
|
||||
"name": "Custom CA"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
{
|
||||
pki {
|
||||
ca custom-ca {
|
||||
name "Custom CA"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
acme.example.com {
|
||||
acme_server {
|
||||
ca custom-ca
|
||||
allow {
|
||||
domains host-1.internal.example.com host-2.internal.example.com
|
||||
}
|
||||
deny {
|
||||
domains dc.internal.example.com
|
||||
}
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"acme.example.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"ca": "custom-ca",
|
||||
"handler": "acme_server",
|
||||
"policy": {
|
||||
"allow": {
|
||||
"domains": [
|
||||
"host-1.internal.example.com",
|
||||
"host-2.internal.example.com"
|
||||
]
|
||||
},
|
||||
"deny": {
|
||||
"domains": [
|
||||
"dc.internal.example.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"pki": {
|
||||
"certificate_authorities": {
|
||||
"custom-ca": {
|
||||
"name": "Custom CA"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
{
|
||||
pki {
|
||||
ca custom-ca {
|
||||
name "Custom CA"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
acme.example.com {
|
||||
acme_server {
|
||||
ca custom-ca
|
||||
deny {
|
||||
domains dc.internal.example.com
|
||||
}
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"acme.example.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"ca": "custom-ca",
|
||||
"handler": "acme_server",
|
||||
"policy": {
|
||||
"deny": {
|
||||
"domains": [
|
||||
"dc.internal.example.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"pki": {
|
||||
"certificate_authorities": {
|
||||
"custom-ca": {
|
||||
"name": "Custom CA"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
{
|
||||
pki {
|
||||
ca internal {
|
||||
name "Internal"
|
||||
root_cn "Internal Root Cert"
|
||||
intermediate_cn "Internal Intermediate Cert"
|
||||
}
|
||||
}
|
||||
}
|
||||
acme.example.com {
|
||||
acme_server {
|
||||
ca internal
|
||||
sign_with_root
|
||||
}
|
||||
}
|
||||
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"acme.example.com"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"ca": "internal",
|
||||
"handler": "acme_server",
|
||||
"sign_with_root": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"pki": {
|
||||
"certificate_authorities": {
|
||||
"internal": {
|
||||
"name": "Internal",
|
||||
"root_common_name": "Internal Root Cert",
|
||||
"intermediate_common_name": "Internal Intermediate Cert"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
example.com
|
||||
handle {
|
||||
respond "one"
|
||||
}
|
||||
|
||||
example.com
|
||||
handle {
|
||||
respond "two"
|
||||
}
|
||||
----------
|
||||
Caddyfile:6: unrecognized directive: example.com
|
||||
Did you mean to define a second site? If so, you must use curly braces around each site to separate their configurations.
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
:8080 {
|
||||
respond "one"
|
||||
}
|
||||
|
||||
:8080 {
|
||||
respond "two"
|
||||
}
|
||||
----------
|
||||
ambiguous site definition: :8080
|
||||
@@ -1,142 +0,0 @@
|
||||
{
|
||||
auto_https disable_redirects
|
||||
admin off
|
||||
}
|
||||
|
||||
http://localhost {
|
||||
bind fd/{env.CADDY_HTTP_FD} {
|
||||
protocols h1
|
||||
}
|
||||
log
|
||||
respond "Hello, HTTP!"
|
||||
}
|
||||
|
||||
https://localhost {
|
||||
bind fd/{env.CADDY_HTTPS_FD} {
|
||||
protocols h1 h2
|
||||
}
|
||||
bind fdgram/{env.CADDY_HTTP3_FD} {
|
||||
protocols h3
|
||||
}
|
||||
log
|
||||
respond "Hello, HTTPS!"
|
||||
}
|
||||
----------
|
||||
{
|
||||
"admin": {
|
||||
"disabled": true
|
||||
},
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
"fd/{env.CADDY_HTTPS_FD}",
|
||||
"fdgram/{env.CADDY_HTTP3_FD}"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "Hello, HTTPS!",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
],
|
||||
"automatic_https": {
|
||||
"disable_redirects": true
|
||||
},
|
||||
"logs": {
|
||||
"logger_names": {
|
||||
"localhost": [
|
||||
""
|
||||
]
|
||||
}
|
||||
},
|
||||
"listen_protocols": [
|
||||
[
|
||||
"h1",
|
||||
"h2"
|
||||
],
|
||||
[
|
||||
"h3"
|
||||
]
|
||||
]
|
||||
},
|
||||
"srv1": {
|
||||
"automatic_https": {
|
||||
"disable_redirects": true
|
||||
}
|
||||
},
|
||||
"srv2": {
|
||||
"listen": [
|
||||
"fd/{env.CADDY_HTTP_FD}"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "Hello, HTTP!",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
],
|
||||
"automatic_https": {
|
||||
"disable_redirects": true,
|
||||
"skip": [
|
||||
"localhost"
|
||||
]
|
||||
},
|
||||
"logs": {
|
||||
"logger_names": {
|
||||
"localhost": [
|
||||
""
|
||||
]
|
||||
}
|
||||
},
|
||||
"listen_protocols": [
|
||||
[
|
||||
"h1"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
handle
|
||||
|
||||
respond "should not work"
|
||||
----------
|
||||
Caddyfile:1: parsed 'handle' as a site address, but it is a known directive; directives must appear in a site block
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
servers {
|
||||
srv0 {
|
||||
listen :8080
|
||||
}
|
||||
srv1 {
|
||||
listen :8080
|
||||
}
|
||||
}
|
||||
}
|
||||
----------
|
||||
parsing caddyfile tokens for 'servers': unrecognized servers option 'srv0', at Caddyfile:3
|
||||
@@ -1,37 +0,0 @@
|
||||
:8443 {
|
||||
tls internal {
|
||||
on_demand
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":8443"
|
||||
],
|
||||
"tls_connection_policies": [
|
||||
{}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"tls": {
|
||||
"automation": {
|
||||
"policies": [
|
||||
{
|
||||
"issuers": [
|
||||
{
|
||||
"module": "internal"
|
||||
}
|
||||
],
|
||||
"on_demand": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-15
@@ -11,7 +11,6 @@ encode gzip zstd {
|
||||
header Content-Type application/xhtml+xml*
|
||||
header Content-Type application/atom+xml*
|
||||
header Content-Type application/rss+xml*
|
||||
header Content-Type application/wasm*
|
||||
header Content-Type image/svg+xml*
|
||||
}
|
||||
}
|
||||
@@ -21,8 +20,6 @@ encode {
|
||||
zstd
|
||||
gzip 5
|
||||
}
|
||||
|
||||
encode
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
@@ -50,7 +47,6 @@ encode
|
||||
"application/xhtml+xml*",
|
||||
"application/atom+xml*",
|
||||
"application/rss+xml*",
|
||||
"application/wasm*",
|
||||
"image/svg+xml*"
|
||||
]
|
||||
},
|
||||
@@ -78,17 +74,6 @@ encode
|
||||
"zstd",
|
||||
"gzip"
|
||||
]
|
||||
},
|
||||
{
|
||||
"encodings": {
|
||||
"gzip": {},
|
||||
"zstd": {}
|
||||
},
|
||||
"handler": "encode",
|
||||
"prefer": [
|
||||
"zstd",
|
||||
"gzip"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+12
-21
@@ -106,29 +106,20 @@ example.com {
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"group": "group0",
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"group": "group0",
|
||||
"handle": [
|
||||
{
|
||||
"handler": "rewrite",
|
||||
"uri": "/{http.error.status_code}.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "file_server",
|
||||
"hide": [
|
||||
"./Caddyfile"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"handler": "rewrite",
|
||||
"uri": "/{http.error.status_code}.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "file_server",
|
||||
"hide": [
|
||||
"./Caddyfile"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,281 +0,0 @@
|
||||
foo.localhost {
|
||||
root * /srv
|
||||
error /private* "Unauthorized" 410
|
||||
error /fivehundred* "Internal Server Error" 500
|
||||
|
||||
handle_errors 5xx {
|
||||
respond "Error In range [500 .. 599]"
|
||||
}
|
||||
handle_errors 410 {
|
||||
respond "404 or 410 error"
|
||||
}
|
||||
}
|
||||
|
||||
bar.localhost {
|
||||
root * /srv
|
||||
error /private* "Unauthorized" 410
|
||||
error /fivehundred* "Internal Server Error" 500
|
||||
|
||||
handle_errors 5xx {
|
||||
respond "Error In range [500 .. 599] from second site"
|
||||
}
|
||||
handle_errors 410 {
|
||||
respond "404 or 410 error from second site"
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":443"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"foo.localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "vars",
|
||||
"root": "/srv"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"error": "Internal Server Error",
|
||||
"handler": "error",
|
||||
"status_code": 500
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/fivehundred*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"handler": "error",
|
||||
"status_code": 410
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/private*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
},
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"bar.localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "vars",
|
||||
"root": "/srv"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"error": "Internal Server Error",
|
||||
"handler": "error",
|
||||
"status_code": 500
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/fivehundred*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"handler": "error",
|
||||
"status_code": 410
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/private*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
],
|
||||
"errors": {
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"foo.localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "404 or 410 error",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"expression": "{http.error.status_code} in [410]"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "Error In range [500 .. 599]",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"expression": "{http.error.status_code} \u003e= 500 \u0026\u0026 {http.error.status_code} \u003c= 599"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
},
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"bar.localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "404 or 410 error from second site",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"expression": "{http.error.status_code} in [410]"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "Error In range [500 .. 599] from second site",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"expression": "{http.error.status_code} \u003e= 500 \u0026\u0026 {http.error.status_code} \u003c= 599"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
{
|
||||
http_port 3010
|
||||
}
|
||||
localhost:3010 {
|
||||
root * /srv
|
||||
error /private* "Unauthorized" 410
|
||||
error /hidden* "Not found" 404
|
||||
|
||||
handle_errors 4xx {
|
||||
respond "Error in the [400 .. 499] range"
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"http_port": 3010,
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":3010"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "vars",
|
||||
"root": "/srv"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"handler": "error",
|
||||
"status_code": 410
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/private*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"error": "Not found",
|
||||
"handler": "error",
|
||||
"status_code": 404
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/hidden*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
],
|
||||
"errors": {
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "Error in the [400 .. 499] range",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"expression": "{http.error.status_code} \u003e= 400 \u0026\u0026 {http.error.status_code} \u003c= 499"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
{
|
||||
http_port 2099
|
||||
}
|
||||
localhost:2099 {
|
||||
root * /srv
|
||||
error /private* "Unauthorized" 410
|
||||
error /threehundred* "Moved Permanently" 301
|
||||
error /internalerr* "Internal Server Error" 500
|
||||
|
||||
handle_errors 500 3xx {
|
||||
respond "Error code is equal to 500 or in the [300..399] range"
|
||||
}
|
||||
handle_errors 4xx {
|
||||
respond "Error in the [400 .. 499] range"
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"http_port": 2099,
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":2099"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "vars",
|
||||
"root": "/srv"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"error": "Moved Permanently",
|
||||
"handler": "error",
|
||||
"status_code": 301
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/threehundred*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"error": "Internal Server Error",
|
||||
"handler": "error",
|
||||
"status_code": 500
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/internalerr*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"handler": "error",
|
||||
"status_code": 410
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/private*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
],
|
||||
"errors": {
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "Error in the [400 .. 499] range",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"expression": "{http.error.status_code} \u003e= 400 \u0026\u0026 {http.error.status_code} \u003c= 499"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "Error code is equal to 500 or in the [300..399] range",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"expression": "{http.error.status_code} \u003e= 300 \u0026\u0026 {http.error.status_code} \u003c= 399 || {http.error.status_code} in [500]"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
{
|
||||
http_port 3010
|
||||
}
|
||||
localhost:3010 {
|
||||
root * /srv
|
||||
error /private* "Unauthorized" 410
|
||||
error /hidden* "Not found" 404
|
||||
|
||||
handle_errors 404 410 {
|
||||
respond "404 or 410 error"
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"http_port": 3010,
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":3010"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "vars",
|
||||
"root": "/srv"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"handler": "error",
|
||||
"status_code": 410
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/private*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"error": "Not found",
|
||||
"handler": "error",
|
||||
"status_code": 404
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/hidden*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
],
|
||||
"errors": {
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "404 or 410 error",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"expression": "{http.error.status_code} in [404, 410]"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
{
|
||||
http_port 2099
|
||||
}
|
||||
localhost:2099 {
|
||||
root * /srv
|
||||
error /private* "Unauthorized" 410
|
||||
error /hidden* "Not found" 404
|
||||
error /internalerr* "Internal Server Error" 500
|
||||
|
||||
handle_errors {
|
||||
respond "Fallback route: code outside the [400..499] range"
|
||||
}
|
||||
handle_errors 4xx {
|
||||
respond "Error in the [400 .. 499] range"
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"http_port": 2099,
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":2099"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "vars",
|
||||
"root": "/srv"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"error": "Internal Server Error",
|
||||
"handler": "error",
|
||||
"status_code": 500
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/internalerr*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"error": "Unauthorized",
|
||||
"handler": "error",
|
||||
"status_code": 410
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/private*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"error": "Not found",
|
||||
"handler": "error",
|
||||
"status_code": 404
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/hidden*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
],
|
||||
"errors": {
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "Error in the [400 .. 499] range",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"expression": "{http.error.status_code} \u003e= 400 \u0026\u0026 {http.error.status_code} \u003c= 499"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "Fallback route: code outside the [400..499] range",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
{
|
||||
http_port 2099
|
||||
}
|
||||
localhost:2099 {
|
||||
root * /var/www/
|
||||
file_server
|
||||
|
||||
handle_errors 404 {
|
||||
handle /en/* {
|
||||
respond "not found" 404
|
||||
}
|
||||
handle /es/* {
|
||||
respond "no encontrado"
|
||||
}
|
||||
handle {
|
||||
respond "default not found"
|
||||
}
|
||||
}
|
||||
handle_errors {
|
||||
handle /en/* {
|
||||
respond "English error"
|
||||
}
|
||||
handle /es/* {
|
||||
respond "Spanish error"
|
||||
}
|
||||
handle {
|
||||
respond "Default error"
|
||||
}
|
||||
}
|
||||
}
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
"http": {
|
||||
"http_port": 2099,
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"listen": [
|
||||
":2099"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "vars",
|
||||
"root": "/var/www/"
|
||||
},
|
||||
{
|
||||
"handler": "file_server",
|
||||
"hide": [
|
||||
"./Caddyfile"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
],
|
||||
"errors": {
|
||||
"routes": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"host": [
|
||||
"localhost"
|
||||
]
|
||||
}
|
||||
],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"group": "group3",
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "not found",
|
||||
"handler": "static_response",
|
||||
"status_code": 404
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/en/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "group3",
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "no encontrado",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/es/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "group3",
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "default not found",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"expression": "{http.error.status_code} in [404]"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"group": "group8",
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "English error",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/en/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "group8",
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "Spanish error",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"path": [
|
||||
"/es/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "group8",
|
||||
"handle": [
|
||||
{
|
||||
"handler": "subroute",
|
||||
"routes": [
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"body": "Default error",
|
||||
"handler": "static_response"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"terminal": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-50
@@ -1,7 +1,3 @@
|
||||
(snippet) {
|
||||
@g `{http.error.status_code} == 404`
|
||||
}
|
||||
|
||||
example.com
|
||||
|
||||
@a expression {http.error.status_code} == 400
|
||||
@@ -18,12 +14,6 @@ abort @d
|
||||
|
||||
@e expression `{http.error.status_code} == 404`
|
||||
abort @e
|
||||
|
||||
@f `{http.error.status_code} == 404`
|
||||
abort @f
|
||||
|
||||
import snippet
|
||||
abort @g
|
||||
----------
|
||||
{
|
||||
"apps": {
|
||||
@@ -94,10 +84,7 @@ abort @g
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"expression": {
|
||||
"expr": "{http.error.status_code} == 403",
|
||||
"name": "d"
|
||||
}
|
||||
"expression": "{http.error.status_code} == 403"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -110,42 +97,7 @@ abort @g
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"expression": {
|
||||
"expr": "{http.error.status_code} == 404",
|
||||
"name": "e"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"abort": true,
|
||||
"handler": "static_response"
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"expression": {
|
||||
"expr": "{http.error.status_code} == 404",
|
||||
"name": "f"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"handle": [
|
||||
{
|
||||
"abort": true,
|
||||
"handler": "static_response"
|
||||
}
|
||||
],
|
||||
"match": [
|
||||
{
|
||||
"expression": {
|
||||
"expr": "{http.error.status_code} == 404",
|
||||
"name": "g"
|
||||
}
|
||||
"expression": "{http.error.status_code} == 404"
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user