Compare commits

..

2 Commits

Author SHA1 Message Date
Erica Portnoy
15b3f7bc6a Add changelog entry about the removal 2019-11-22 18:23:42 -08:00
Erica Portnoy
d607276b6c Remove unused nginx docs 2019-11-21 15:11:51 -08:00
1322 changed files with 52586 additions and 54499 deletions

View File

@@ -1,8 +1,8 @@
# Configuring Azure Pipelines with Certbot
Let's begin. All pipelines are defined in `.azure-pipelines`. Currently there are two:
* `.azure-pipelines/main.yml` is the main one, executed on PRs for main, and pushes to main,
* `.azure-pipelines/advanced.yml` add installer testing on top of the main pipeline, and is executed for `test-*` branches, release branches, and nightly run for main.
* `.azure-pipelines/main.yml` is the main one, executed on PRs for master, and pushes to master,
* `.azure-pipelines/advanced.yml` add installer testing on top of the main pipeline, and is executed for `test-*` branches, release branches, and nightly run for master.
Several templates are defined in `.azure-pipelines/templates`. These YAML files aggregate common jobs configuration that can be reused in several pipelines.
@@ -64,17 +64,17 @@ Azure Pipeline needs RW on code, RO on metadata, RW on checks, commit statuses,
RW access here is required to allow update of the pipelines YAML files from Azure DevOps interface, and to
update the status of builds and PRs on GitHub side when Azure Pipelines are triggered.
Note however that no admin access is defined here: this means that Azure Pipelines cannot do anything with
protected branches, like main, and cannot modify the security context around this on GitHub.
protected branches, like master, and cannot modify the security context around this on GitHub.
Access can be defined for all or only selected repositories, which is nice.
```
- Redirected to Azure DevOps, select the account created in _Having an Azure DevOps account_ section.
- Select the organization, and click "Create a new project" (let's name it the same than the targeted github repo)
- Select the organization, and click "Create a new project" (let's name it the same than the targetted github repo)
- The Visibility is public, to profit from 10 parallel jobs
```
!!! ACCESS !!!
Azure Pipelines needs access to the GitHub account (in term of being able to check it is valid), and the Resources shared between the GitHub account and Azure Pipelines.
Azure Pipelines needs access to the GitHub account (in term of beeing able to check it is valid), and the Resources shared between the GitHub account and Azure Pipelines.
```
_Done. We can move to pipelines configuration._
@@ -91,11 +91,11 @@ grant permissions from Azure Pipelines to GitHub in order to setup a GitHub OAut
then are way too large (admin level on almost everything), while the classic approach does not add any more
permissions, and works perfectly well.__
- Select GitHub in "Select your repository section", choose certbot/certbot in Repository, main in default branch.
- Select GitHub in "Select your repository section", choose certbot/certbot in Repository, master in default branch.
- Click on YAML option for "Select a template"
- Choose a name for the pipeline (eg. test-pipeline), and browse to the actual pipeline YAML definition in the
"YAML file path" input (eg. `.azure-pipelines/test-pipeline.yml`)
- Click "Save & queue", choose the main branch to build the first pipeline, and click "Save and run" button.
- Click "Save & queue", choose the master branch to build the first pipeline, and click "Save and run" button.
_Done. Pipeline is operational. Repeat to add more pipelines from existing YAML files in `.azure-pipelines`._

View File

@@ -1,15 +0,0 @@
# Advanced pipeline for running our full test suite on demand.
trigger:
# When changing these triggers, please ensure the documentation under
# "Running tests in CI" is still correct.
- test-*
pr: none
variables:
# We don't publish our Docker images in this pipeline, but when building them
# for testing, let's use the nightly tag.
dockerTag: nightly
snapBuildTimeout: 5400
stages:
- template: templates/stages/test-and-package-stage.yml

View File

@@ -0,0 +1,20 @@
# Advanced pipeline for isolated checks and release purpose
trigger:
- test-*
- '*.x'
pr:
- test-*
# This pipeline is also nightly run on master
schedules:
- cron: "0 4 * * *"
displayName: Nightly build
branches:
include:
- master
always: true
jobs:
# Any addition here should be reflected in the release pipeline.
# It is advised to declare all jobs here as templates to improve maintainability.
- template: templates/tests-suite.yml
- template: templates/installer-tests.yml

View File

@@ -1,18 +1,12 @@
# We run the test suite on commits to main so codecov gets coverage data
# about the main branch and can use it to track coverage changes.
trigger:
- main
# apache-parser-v2 is a temporary branch for doing work related to
# rewriting the parser in the Apache plugin.
- apache-parser-v2
- master
pr:
- main
- apache-parser-v2
- master
- '*.x'
variables:
# We set this here to avoid coverage data being uploaded from things like our
# nightly pipeline. This is done because codecov (helpfully) keeps track of
# the number of coverage uploads for a commit and displays a warning when
# comparing two commits with an unequal number of uploads. Only uploading
# coverage here should keep the number of uploads it sees consistent.
uploadCoverage: true
jobs:
- template: templates/jobs/standard-tests-jobs.yml
- template: templates/tests-suite.yml

View File

@@ -1,19 +0,0 @@
# Nightly pipeline running each day for main.
trigger: none
pr: none
schedules:
- cron: "30 4 * * *"
displayName: Nightly build
branches:
include:
- main
always: true
variables:
dockerTag: nightly
snapBuildTimeout: 19800
stages:
- template: templates/stages/test-and-package-stage.yml
- template: templates/stages/changelog-stage.yml
- template: templates/stages/nightly-deploy-stage.yml

View File

@@ -1,16 +1,13 @@
# Release pipeline to run our full test suite, build artifacts, and deploy them
# for GitHub release tags.
# Release pipeline to build and deploy Certbot for Windows for GitHub release tags
trigger:
tags:
include:
- v*
pr: none
variables:
dockerTag: ${{variables['Build.SourceBranchName']}}
snapBuildTimeout: 19800
stages:
- template: templates/stages/test-and-package-stage.yml
- template: templates/stages/changelog-stage.yml
- template: templates/stages/release-deploy-stage.yml
jobs:
# Any addition here should be reflected in the advanced pipeline.
# It is advised to declare all jobs here as templates to improve maintainability.
- template: templates/tests-suite.yml
- template: templates/installer-tests.yml
- template: templates/changelog.yml

View File

@@ -0,0 +1,14 @@
jobs:
- job: changelog
pool:
vmImage: vs2017-win2016
steps:
- bash: |
CERTBOT_VERSION="$(python -c "import certbot; print(certbot.__version__)")"
"${BUILD_REPOSITORY_LOCALPATH}\tools\extract_changelog.py" "${CERTBOT_VERSION}" >> "${BUILD_ARTIFACTSTAGINGDIRECTORY}/release_notes.md"
displayName: Prepare changelog
- task: PublishPipelineArtifact@1
inputs:
path: $(Build.ArtifactStagingDirectory)
artifact: changelog
displayName: Publish changelog

View File

@@ -0,0 +1,32 @@
jobs:
- job: installer
pool:
vmImage: vs2017-win2016
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: 3.7
architecture: x86
addToPath: true
- script: python windows-installer/construct.py
displayName: Build Certbot installer
- task: CopyFiles@2
inputs:
sourceFolder: $(System.DefaultWorkingDirectory)/windows-installer/build/nsis
contents: '*.exe'
targetFolder: $(Build.ArtifactStagingDirectory)
- task: PublishPipelineArtifact@1
inputs:
path: $(Build.ArtifactStagingDirectory)
artifact: windows-installer
displayName: Publish Windows installer
- script: $(Build.ArtifactStagingDirectory)\certbot-beta-installer-win32.exe /S
displayName: Install Certbot
- script: |
python -m venv venv
venv\Scripts\python tools\pip_install.py -e certbot-ci
displayName: Prepare Certbot-CI
- script: |
set PATH=%ProgramFiles(x86)%\Certbot\bin;%PATH%
venv\Scripts\python -m pytest certbot-ci\certbot_integration_tests\certbot_tests -n 4
displayName: Run integration tests

View File

@@ -1,128 +0,0 @@
# As (somewhat) described at
# https://docs.microsoft.com/en-us/azure/devops/pipelines/process/templates?view=azure-devops#context,
# each template only has access to the parameters passed into it. To help make
# use of this design, we define snapReleaseChannel without a default value
# which requires the user of this template to define it as described at
# https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/parameters-name?view=azure-pipelines#remarks.
# This makes the user of this template be explicit while allowing them to
# define their own parameters with defaults that make sense for that context.
parameters:
- name: snapReleaseChannel
type: string
values:
- edge
- beta
jobs:
# This job relies on credentials used to publish the Certbot snaps. This
# credential file was created by running:
#
# snapcraft logout
# snapcraft export-login --channels=beta,edge snapcraft.cfg
# (provide the shared snapcraft credentials when prompted)
#
# Then the file was added as a secure file in Azure pipelines
# with the name snapcraft.cfg by following the instructions at
# https://docs.microsoft.com/en-us/azure/devops/pipelines/library/secure-files?view=azure-devops
# including authorizing the file for use in the "nightly" and "release"
# pipelines as described at
# https://docs.microsoft.com/en-us/azure/devops/pipelines/library/secure-files?view=azure-devops#q-how-do-i-authorize-a-secure-file-for-use-in-a-specific-pipeline.
#
# This file has a maximum lifetime of one year and the current file will
# expire on 2024-02-10. The file will need to be updated before then to
# prevent automated deploys from breaking.
#
# Revoking these credentials can be done by changing the password of the
# account used to generate the credentials. See
# https://forum.snapcraft.io/t/revoking-exported-credentials/19031 for
# more info.
- job: publish_snap
pool:
vmImage: ubuntu-22.04
variables:
- group: certbot-common
strategy:
matrix:
amd64:
SNAP_ARCH: amd64
arm32v6:
SNAP_ARCH: armhf
arm64v8:
SNAP_ARCH: arm64
steps:
- bash: |
set -e
sudo apt-get update
sudo apt-get install -y --no-install-recommends snapd
sudo snap install --classic snapcraft
displayName: Install dependencies
- task: DownloadPipelineArtifact@2
inputs:
artifact: snaps_$(SNAP_ARCH)
path: $(Build.SourcesDirectory)/snap
displayName: Retrieve Certbot snaps
- task: DownloadSecureFile@1
name: snapcraftCfg
inputs:
secureFile: snapcraft.cfg
- bash: |
set -e
export SNAPCRAFT_STORE_CREDENTIALS=$(cat "$(snapcraftCfg.secureFilePath)")
for SNAP_FILE in snap/*.snap; do
tools/retry.sh eval snapcraft upload --release=${{ parameters.snapReleaseChannel }} "${SNAP_FILE}"
done
displayName: Publish to Snap store
# The credentials used in the following jobs are for the shared
# certbotbot account on Docker Hub. The credentials are stored
# in a service account which was created by following the
# instructions at
# https://docs.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints?view=azure-devops&tabs=yaml#sep-docreg.
# The name given to this service account must match the value
# given to containerRegistry below. The authentication used when
# creating this service account was a personal access token
# rather than a password to bypass 2FA. When Brad set this up,
# Azure Pipelines failed to verify the credentials with an error
# like "access is forbidden with a JWT issued from a personal
# access token", but after saving them without verification, the
# access token worked when the pipeline actually ran. "Grant
# access to all pipelines" should also be checked on the service
# account. The access token can be deleted on Docker Hub if
# these credentials need to be revoked.
- job: publish_docker_by_arch
pool:
vmImage: ubuntu-22.04
strategy:
matrix:
arm32v6:
DOCKER_ARCH: arm32v6
arm64v8:
DOCKER_ARCH: arm64v8
amd64:
DOCKER_ARCH: amd64
steps:
- task: DownloadPipelineArtifact@2
inputs:
artifact: docker_$(DOCKER_ARCH)
path: $(Build.SourcesDirectory)
displayName: Retrieve Docker images
- bash: set -e && docker load --input $(Build.SourcesDirectory)/images.tar
displayName: Load Docker images
- task: Docker@2
inputs:
command: login
containerRegistry: docker-hub
displayName: Login to Docker Hub
- bash: set -e && tools/docker/deploy_images.sh $(dockerTag) $DOCKER_ARCH
displayName: Deploy the Docker images by architecture
- job: publish_docker_multiarch
dependsOn: publish_docker_by_arch
pool:
vmImage: ubuntu-22.04
steps:
- task: Docker@2
inputs:
command: login
containerRegistry: docker-hub
displayName: Login to Docker Hub
- bash: set -e && tools/docker/deploy_manifests.sh $(dockerTag) all
displayName: Deploy the Docker multiarch manifests

View File

@@ -1,57 +0,0 @@
jobs:
- job: extended_test
variables:
- name: IMAGE_NAME
value: ubuntu-22.04
- name: PYTHON_VERSION
value: 3.14
- group: certbot-common
strategy:
matrix:
linux-py311:
PYTHON_VERSION: 3.11
TOXENV: py311
linux-py312:
PYTHON_VERSION: 3.12
TOXENV: py312
linux-py313:
PYTHON_VERSION: 3.13
TOXENV: py313
linux-isolated:
TOXENV: 'isolated-acme,isolated-certbot,isolated-apache,isolated-cloudflare,isolated-digitalocean,isolated-dnsimple,isolated-dnsmadeeasy,isolated-gehirn,isolated-google,isolated-linode,isolated-luadns,isolated-nsone,isolated-ovh,isolated-rfc2136,isolated-route53,isolated-sakuracloud,isolated-nginx'
linux-integration-certbot-oldest:
PYTHON_VERSION: 3.10
TOXENV: integration-certbot-oldest
linux-integration-nginx-oldest:
PYTHON_VERSION: 3.10
TOXENV: integration-nginx-oldest
linux-py310-integration:
PYTHON_VERSION: 3.10
TOXENV: integration
linux-py311-integration:
PYTHON_VERSION: 3.11
TOXENV: integration
linux-py312-integration:
PYTHON_VERSION: 3.12
TOXENV: integration
linux-py313-integration:
PYTHON_VERSION: 3.13
TOXENV: integration
# python 3.14 integration tests are not run here because they're run as
# part of the standard test suite
nginx-compat:
TOXENV: nginx_compat
linux-integration-rfc2136:
IMAGE_NAME: ubuntu-22.04
PYTHON_VERSION: 3.12
TOXENV: integration-dns-rfc2136
le-modification:
IMAGE_NAME: ubuntu-22.04
TOXENV: modification
farmtest-apache2:
PYTHON_VERSION: 3.12
TOXENV: test-farm-apache2
pool:
vmImage: $(IMAGE_NAME)
steps:
- template: ../steps/tox-steps.yml

View File

@@ -1,159 +0,0 @@
jobs:
- job: docker_build
pool:
vmImage: ubuntu-24.04
strategy:
matrix:
arm32v6:
DOCKER_ARCH: arm32v6
arm64v8:
DOCKER_ARCH: arm64v8
amd64:
DOCKER_ARCH: amd64
# The default timeout of 60 minutes is a little low for compiling
# cryptography on ARM architectures.
timeoutInMinutes: 180
steps:
- bash: set -e && tools/docker/build.sh $(dockerTag) $DOCKER_ARCH
displayName: Build the Docker images
# We don't filter for the Docker Hub organization to continue to allow
# easy testing of these scripts on forks.
- bash: |
set -e
DOCKER_IMAGES=$(docker images --filter reference='*/certbot' --filter reference='*/dns-*' --format '{{.Repository}}')
docker save --output images.tar $DOCKER_IMAGES
displayName: Save the Docker images
# If the name of the tar file or artifact changes, the deploy stage will
# also need to be updated.
- bash: set -e && mv images.tar $(Build.ArtifactStagingDirectory)
displayName: Prepare Docker artifact
- task: PublishPipelineArtifact@1
inputs:
path: $(Build.ArtifactStagingDirectory)
artifact: docker_$(DOCKER_ARCH)
displayName: Store Docker artifact
- job: docker_test
dependsOn: docker_build
pool:
vmImage: ubuntu-22.04
strategy:
matrix:
arm32v6:
DOCKER_ARCH: arm32v6
arm64v8:
DOCKER_ARCH: arm64v8
amd64:
DOCKER_ARCH: amd64
steps:
- task: DownloadPipelineArtifact@2
inputs:
artifact: docker_$(DOCKER_ARCH)
path: $(Build.SourcesDirectory)
displayName: Retrieve Docker images
- bash: set -e && docker load --input $(Build.SourcesDirectory)/images.tar
displayName: Load Docker images
- bash: |
set -e && tools/docker/test.sh $(dockerTag) $DOCKER_ARCH
displayName: Run integration tests for Docker images
- job: snaps_build
pool:
vmImage: ubuntu-22.04
strategy:
matrix:
amd64:
SNAP_ARCH: amd64
armhf:
SNAP_ARCH: armhf
arm64:
SNAP_ARCH: arm64
timeoutInMinutes: 0
steps:
- script: |
set -e
sudo apt-get update
sudo apt-get install -y --no-install-recommends snapd
sudo snap install --classic snapcraft
displayName: Install dependencies
- task: UsePythonVersion@0
inputs:
versionSpec: 3.12
addToPath: true
- task: DownloadSecureFile@1
name: credentials
inputs:
secureFile: launchpad-credentials
- script: |
set -e
git config --global user.email "$(Build.RequestedForEmail)"
git config --global user.name "$(Build.RequestedFor)"
mkdir -p ~/.local/share/snapcraft/provider/launchpad
cp $(credentials.secureFilePath) ~/.local/share/snapcraft/provider/launchpad/credentials
python3 tools/snap/build_remote.py ALL --archs ${SNAP_ARCH} --timeout $(snapBuildTimeout)
displayName: Build snaps
- script: |
set -e
mv *.snap $(Build.ArtifactStagingDirectory)
mv certbot-dns-*/*.snap $(Build.ArtifactStagingDirectory)
displayName: Prepare artifacts
- task: PublishPipelineArtifact@1
inputs:
path: $(Build.ArtifactStagingDirectory)
artifact: snaps_$(SNAP_ARCH)
displayName: Store snaps artifacts
- job: snap_run
dependsOn: snaps_build
pool:
vmImage: ubuntu-22.04
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: 3.12
addToPath: true
- script: |
set -e
sudo apt-get update
sudo apt-get install -y --no-install-recommends nginx-light snapd
python3 -m venv venv
venv/bin/python tools/pip_install.py -U tox
displayName: Install dependencies
- task: DownloadPipelineArtifact@2
inputs:
artifact: snaps_amd64
path: $(Build.SourcesDirectory)/snap
displayName: Retrieve Certbot snaps
- script: |
set -e
sudo snap install --dangerous --classic snap/certbot_*.snap
displayName: Install Certbot snap
- script: |
set -e
venv/bin/python -m tox run -e integration-external,apacheconftest-external-with-pebble
displayName: Run tox
- job: snap_dns_run
dependsOn: snaps_build
pool:
vmImage: ubuntu-22.04
steps:
- script: |
set -e
sudo apt-get update
sudo apt-get install -y --no-install-recommends snapd
displayName: Install dependencies
- task: UsePythonVersion@0
inputs:
versionSpec: 3.12
addToPath: true
- task: DownloadPipelineArtifact@2
inputs:
artifact: snaps_amd64
path: $(Build.SourcesDirectory)/snap
displayName: Retrieve Certbot snaps
- script: |
set -e
python3 -m venv venv
venv/bin/python tools/pip_install.py -e certbot-ci
displayName: Prepare Certbot-CI
- script: |
set -e
sudo -E venv/bin/pytest certbot-ci/src/snap_integration_tests/dns_tests --allow-persistent-changes --snap-folder $(Build.SourcesDirectory)/snap --snap-arch amd64
displayName: Test DNS plugins snaps

View File

@@ -1,55 +0,0 @@
jobs:
- job: test
variables:
PYTHON_VERSION: 3.14
strategy:
matrix:
macos-cover:
IMAGE_NAME: macOS-15
TOXENV: cover
# As of pip 23.1.0, builds started failing on macOS unless this flag was set.
# See https://github.com/certbot/certbot/pull/9717#issuecomment-1610861794.
PIP_USE_PEP517: "true"
linux-oldest:
IMAGE_NAME: ubuntu-22.04
PYTHON_VERSION: 3.10
TOXENV: oldest
linux-py310:
# linux unit tests with the oldest python we support
IMAGE_NAME: ubuntu-22.04
PYTHON_VERSION: 3.10
TOXENV: py310
linux-cover:
# linux unit+cover tests with the newest python we support
IMAGE_NAME: ubuntu-22.04
TOXENV: cover
linux-lint:
IMAGE_NAME: ubuntu-22.04
TOXENV: lint-posix
linux-mypy:
IMAGE_NAME: ubuntu-22.04
TOXENV: mypy
linux-integration:
IMAGE_NAME: ubuntu-22.04
TOXENV: integration
apache-compat:
IMAGE_NAME: ubuntu-22.04
TOXENV: apache_compat
apacheconftest:
IMAGE_NAME: ubuntu-22.04
TOXENV: apacheconftest-with-pebble
nginxroundtrip:
IMAGE_NAME: ubuntu-22.04
TOXENV: nginxroundtrip
validate-changelog:
IMAGE_NAME: ubuntu-22.04
TOXENV: validate-changelog
pool:
vmImage: $(IMAGE_NAME)
steps:
- template: ../steps/tox-steps.yml
- job: test_sphinx_builds
pool:
vmImage: ubuntu-22.04
steps:
- template: ../steps/sphinx-steps.yml

View File

@@ -1,19 +0,0 @@
stages:
- stage: Changelog
jobs:
- job: prepare
pool:
vmImage: ubuntu-latest
steps:
# If we change the output filename from `release_notes.md`, it should also be changed in tools/create_github_release.py
- bash: |
set -e
CERTBOT_VERSION="$(cd certbot/src && python -c "import certbot; print(certbot.__version__)" && cd ~-)"
"${BUILD_REPOSITORY_LOCALPATH}/tools/extract_changelog.py" "${CERTBOT_VERSION}" >> "${BUILD_ARTIFACTSTAGINGDIRECTORY}/release_notes.md"
displayName: Prepare changelog
- task: PublishPipelineArtifact@1
inputs:
path: $(Build.ArtifactStagingDirectory)
# If we change the artifact's name, it should also be changed in tools/create_github_release.py
artifact: changelog
displayName: Publish changelog

View File

@@ -1,6 +0,0 @@
stages:
- stage: Deploy
jobs:
- template: ../jobs/common-deploy-jobs.yml
parameters:
snapReleaseChannel: edge

View File

@@ -1,27 +0,0 @@
stages:
- stage: Deploy
jobs:
- template: ../jobs/common-deploy-jobs.yml
parameters:
snapReleaseChannel: beta
- job: create_github_release
pool:
vmImage: ubuntu-22.04
steps:
- task: DownloadPipelineArtifact@2
inputs:
artifact: changelog
path: '$(Pipeline.Workspace)'
- task: GitHubRelease@1
inputs:
# this "github-releases" credential is what azure pipelines calls a
# "service connection". it needs to be recreated annually. instructions
# to do so and further information about the token are available at
# https://github.com/EFForg/certbot-misc/wiki/Azure-Pipelines-setup#regenerating-github-release-credentials-for-use-on-azure
#
# as of writing this, the current token will expire on Wed, Feb 25 2026.
gitHubConnection: github-releases
title: ${{ format('Certbot {0}', replace(variables['Build.SourceBranchName'], 'v', '')) }}
releaseNotesFilePath: '$(Pipeline.Workspace)/release_notes.md'
assets: '$(Build.SourcesDirectory)/packages/{*.tar.gz,SHA256SUMS*}'
addChangeLog: false

View File

@@ -1,6 +0,0 @@
stages:
- stage: TestAndPackage
jobs:
- template: ../jobs/standard-tests-jobs.yml
- template: ../jobs/extended-tests-jobs.yml
- template: ../jobs/packaging-jobs.yml

View File

@@ -1,24 +0,0 @@
steps:
- bash: |
set -e
sudo apt-get update
sudo apt-get install -y --no-install-recommends libaugeas-dev
FINAL_STATUS=0
declare -a FAILED_BUILDS
tools/venv.py
source venv/bin/activate
for doc_path in */docs
do
echo ""
echo "##[group]Building $doc_path"
if ! sphinx-build -W --keep-going -b html $doc_path $doc_path/_build/html; then
FINAL_STATUS=1
FAILED_BUILDS[${#FAILED_BUILDS[@]}]="${doc_path%/docs}"
fi
echo "##[endgroup]"
done
if [[ $FINAL_STATUS -ne 0 ]]; then
echo "##[error]The following builds failed: ${FAILED_BUILDS[*]}"
exit 1
fi
displayName: Build Sphinx Documentation

View File

@@ -1,77 +0,0 @@
# This does not include the dependencies needed to build cryptography. See
# https://cryptography.io/en/latest/installation/
steps:
# We run brew update because we've seen attempts to install an older version
# of a package fail. See
# https://github.com/actions/virtual-environments/issues/3165.
#
# We untap homebrew/core and homebrew/cask and unset HOMEBREW_NO_INSTALL_FROM_API (which
# is set by the CI macOS env) because GitHub has been having issues, making these jobs
# fail on git clones: https://github.com/orgs/Homebrew/discussions/4612.
- bash: |
set -e
unset HOMEBREW_NO_INSTALL_FROM_API
brew untap homebrew/core homebrew/cask
brew update
brew install augeas
condition: startswith(variables['IMAGE_NAME'], 'macOS')
displayName: Install MacOS dependencies
- bash: |
set -e
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libaugeas-dev \
nginx-light
sudo systemctl stop nginx
sudo sysctl net.ipv4.ip_unprivileged_port_start=0
condition: startswith(variables['IMAGE_NAME'], 'ubuntu')
displayName: Install Linux dependencies
- task: UsePythonVersion@0
inputs:
versionSpec: $(PYTHON_VERSION)
addToPath: true
- bash: |
set -e
python3 tools/pip_install.py tox
displayName: Install runtime dependencies
- task: DownloadSecureFile@1
name: testFarmPem
inputs:
secureFile: azure-test-farm.pem
condition: contains(variables['TOXENV'], 'test-farm')
- bash: |
set -e
export TARGET_BRANCH="`echo "${BUILD_SOURCEBRANCH}" | sed -E 's!refs/(heads|tags)/!!g'`"
[ -z "${SYSTEM_PULLREQUEST_TARGETBRANCH}" ] || export TARGET_BRANCH="${SYSTEM_PULLREQUEST_TARGETBRANCH}"
env
python3 -m tox run
env:
AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID)
AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY)
AWS_EC2_PEM_FILE: $(testFarmPem.secureFilePath)
displayName: Run tox
# For now, let's omit `set -e` and avoid the script exiting with a nonzero
# status code to prevent problems here from causing build failures. If
# this turns out to work well, we can change this.
- bash: |
python3 tools/pip_install.py -I coverage
case "$AGENT_OS" in
Darwin)
CODECOV_URL="https://uploader.codecov.io/latest/macos/codecov"
;;
Linux)
CODECOV_URL="https://uploader.codecov.io/latest/linux/codecov"
;;
Windows_NT)
CODECOV_URL="https://uploader.codecov.io/latest/windows/codecov.exe"
;;
*)
echo "Unexpected OS"
exit 0
esac
curl --retry 3 -o codecov "$CODECOV_URL"
chmod +x codecov
coverage xml
./codecov || echo "Uploading coverage data failed"
condition: and(eq(variables['uploadCoverage'], true), or(startsWith(variables['TOXENV'], 'cover'), startsWith(variables['TOXENV'], 'integration')))
displayName: Upload coverage data

View File

@@ -0,0 +1,38 @@
jobs:
- job: test
pool:
vmImage: vs2017-win2016
strategy:
matrix:
py35:
PYTHON_VERSION: 3.5
TOXENV: py35
py37-cover:
PYTHON_VERSION: 3.7
TOXENV: py37-cover
integration-certbot:
PYTHON_VERSION: 3.7
TOXENV: integration-certbot
PYTEST_ADDOPTS: --numprocesses 4
variables:
- group: certbot-common
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: $(PYTHON_VERSION)
addToPath: true
- script: python tools/pip_install.py -U tox coverage
displayName: Install dependencies
- script: python -m tox
displayName: Run tox
# We do not require codecov report upload to succeed. So to avoid to break the pipeline if
# something goes wrong, each command is suffixed with a command that hides any non zero exit
# codes and echoes an informative message instead.
- bash: |
curl -s https://codecov.io/bash -o codecov-bash || echo "Failed to download codecov-bash"
chmod +x codecov-bash || echo "Failed to apply execute permissions on codecov-bash"
./codecov-bash -F windows || echo "Codecov did not collect coverage reports"
condition: in(variables['TOXENV'], 'py37-cover', 'integration-certbot')
env:
CODECOV_TOKEN: $(codecov_token)
displayName: Publish coverage

18
.codecov.yml Normal file
View File

@@ -0,0 +1,18 @@
coverage:
status:
project:
default: off
linux:
flags: linux
# Fixed target instead of auto set by #7173, can
# be removed when flags in Codecov are added back.
target: 97.4
threshold: 0.1
base: auto
windows:
flags: windows
# Fixed target instead of auto set by #7173, can
# be removed when flags in Codecov are added back.
target: 97.4
threshold: 0.1
base: auto

View File

@@ -1,24 +1,5 @@
[run]
omit = */setup.py
source =
acme
certbot
certbot-apache
certbot-dns-cloudflare
certbot-dns-digitalocean
certbot-dns-dnsimple
certbot-dns-dnsmadeeasy
certbot-dns-gehirn
certbot-dns-google
certbot-dns-linode
certbot-dns-luadns
certbot-dns-nsone
certbot-dns-ovh
certbot-dns-rfc2136
certbot-dns-route53
certbot-dns-sakuracloud
certbot-nginx
[report]
omit = */setup.py
show_missing = True

View File

@@ -8,4 +8,5 @@
.git
.tox
venv
venv3
docs

View File

@@ -1,18 +0,0 @@
# https://editorconfig.org/
root = true
[*]
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
[*.py]
indent_style = space
indent_size = 4
charset = utf-8
max_line_length = 100
[*.yaml]
indent_style = space
indent_size = 2

1
.github/FUNDING.yml vendored
View File

@@ -1 +0,0 @@
custom: https://supporters.eff.org/donate/support-work-on-certbot

View File

@@ -1,69 +0,0 @@
name: Bug Report
description: File a bug report.
title: "[Bug]: "
type: Bug
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report! If you're
having trouble using Certbot and aren't sure you've found a bug,
please first try asking for help at https://community.letsencrypt.org/.
There is a much larger community there of people familiar with the
project who will be able to more quickly answer your questions.
- type: input
attributes:
label: OS
description: |
Describe your Operating System. Examples: Ubuntu 18.04, CentOS 8 Stream
placeholder: Ubuntu 24.04
validations:
required: true
- type: input
attributes:
label: Installation method
description: |
How did you install Certbot? Examples: snap, pip, apt, yum
placeholder: snap
validations:
required: true
- type: input
attributes:
label: Certbot Version
description: |
If you're not sure, you can find this by running `certbot --version`.
placeholder: 1.0.0
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: |
I ran this command and it produced this output. Example:
```
$ sudo certbot certonly -d adfsfasdf.asdfasdf --staging
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Plugins selected: Authenticator nginx, Installer nginx
Requesting a certificate for example.com
An unexpected error occurred:
Invalid identifiers requested :: Cannot issue for "adfsfasdf.asdfasdf": Domain name does not end with a valid public suffix (TLD)
Ask for help or search for solutions at https://community.letsencrypt.org. See the logfile /var/log/letsencrypt/letsencrypt.log or re-run Certbot with -v for more details.
```
placeholder: Tell us what you see!
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: Certbot's behavior differed from what I expected because.
placeholder: "What was expected?"
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Here is a Certbot log showing the issue (if available). Logs are stored in `/var/log/letsencrypt` by default. Feel free to redact domains, e-mail and IP addresses as you see fit.
render: shell

View File

@@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Let's Encrypt Community Support
url: https://community.letsencrypt.org/
about: If you're having trouble using Certbot and aren't sure you've found a bug or request for a new feature, please first try asking for help here. There is a much larger community there of people familiar with the project who will be able to more quickly answer your questions.
- name: Certbot Security Policy
url: https://github.com/certbot/certbot/security/advisories/new
about: Please report security vulnerabilities here.

View File

@@ -1,27 +0,0 @@
name: Feature Request
description: Suggest a new feature or improvement to Certbot
title: "[Feature Request]: "
type: Feature
body:
- type: textarea
id: problem
attributes:
label: What problem does this feature solve or what does it enhance?
description: Explain what this feature addresses, or the benefit it provides.
placeholder: For example, "Currently, users have to manually do X, which is time-consuming."
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe the solution you'd like to see implemented.
placeholder: For example, "Implement a new button that automatically does X."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you considered any alternative solutions?
placeholder: For example, "We considered Y, but Z is a better approach because..."

View File

@@ -1,15 +0,0 @@
name: Task
description: A codebase upkeep task such as managing deprecations and refactoring
title: "[Task]: "
type: Task
body:
- type: textarea
id: problem
attributes:
label: Task description
description: Describe the work that needs to happen, and why.
placeholder: >
For example, "In issue [link here], we noted that we cannot update [dependency] until
[something happens]. That thing has happened, so now we should update [dependency]."
validations:
required: true

7
.github/codecov.yml vendored
View File

@@ -1,7 +0,0 @@
# This disables all reporting from codecov. Let's just set it up to collect
# data for now and then we can play with the settings here.
comment: false
coverage:
status:
project: off
patch: off

View File

@@ -1,7 +0,0 @@
## Pull Request Checklist
- [ ] The Certbot team has recently expressed interest in reviewing a PR for this. If not, this PR may be closed due our limited resources and need to prioritize how we spend them.
- [ ] If the change being made is to a [distributed component](https://certbot.eff.org/docs/contributing.html#code-components-and-layout), add a description of your change to the `newsfragments` directory. This should be a file called `<title>.<type>`, where `<title>` is either a GitHub issue number or some other unique name starting with `+`, and `<type>` is either `changed`, `fixed`, or `added`.
* For example, if you fixed a bug for issue number 42, create a file called `42.fixed` and put a description of your change in that file.
- [ ] Add or update any documentation as needed to support the changes in this PR.
- [ ] Include your name in `AUTHORS.md` if you like.

35
.github/stale.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
# Configuration for https://github.com/marketplace/stale
# Number of days of inactivity before an Issue or Pull Request becomes stale
daysUntilStale: 365
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
# When changing this value, be sure to also update markComment below.
daysUntilClose: 30
# Ignore issues with an assignee (defaults to false)
exemptAssignees: true
# Label to use when marking as stale
staleLabel: needs-update
# Comment to post when marking as stale. Set to `false` to disable
markComment: >
We've made a lot of changes to Certbot since this issue was opened. If you
still have this issue with an up-to-date version of Certbot, can you please
add a comment letting us know? This helps us to better see what issues are
still affecting our users. If there is no activity in the next 30 days, this
issue will be automatically closed.
# Comment to post when closing a stale Issue or Pull Request.
closeComment: >
This issue has been closed due to lack of activity, but if you think it
should be reopened, please open a new issue with a link to this one and we'll
take a look.
# Limit the number of actions per hour, from 1-30. Default is 30
limitPerRun: 1
# Don't mark pull requests as stale.
only: issues

View File

@@ -1,25 +0,0 @@
name: Issue Assigned
on:
issues:
types: [assigned]
permissions: {} # let's not use any permissions we don't need here
jobs:
send-mattermost-message:
runs-on: ubuntu-latest
steps:
# issue triggers in github actions can be dangerous like
# pull_request_target because they run with additional privileges in an
# environment containing values that can be controlled by an attacker.
# because of this, please take extra caution when modifying the steps taken
# by this workflow. for additional information, see
# https://github.com/certbot/certbot/pull/10490
#
# we pin this action to a version tested and audited by certbot's
# maintainers for extra security. the full hash is used as doing so is
# recommended by zizmor
- uses: mattermost/action-mattermost-notify@b7d118e440bf2749cd18a4a8c88e7092e696257a
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_ASSIGN_WEBHOOK }}
TEXT: >
${{ github.event.assignee.login }} assigned to "${{ github.event.issue.title }}": ${{ github.event.issue.html_url }}

View File

@@ -1,30 +0,0 @@
name: Merge Event
on:
pull_request_target:
types:
- closed
permissions: {} # let's not use any permissions we don't need here
jobs:
if_merged:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
# github actions workflows triggered by pull_request_target can be
# dangerous because they run with additional privileges in an environment
# containing values that can be controlled by an attacker. because of
# this, please take extra caution when modifying the steps taken by this
# workflow. for additional information, see
# https://github.com/certbot/certbot/pull/10490
#
# we pin this action to a version tested and audited by certbot's
# maintainers for extra security. the full hash is used as doing so is
# recommended by zizmor
- uses: mattermost/action-mattermost-notify@b7d118e440bf2749cd18a4a8c88e7092e696257a
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_MERGE_WEBHOOK }}
TEXT: >
[${{ github.repository }}] |
[${{ github.event.pull_request.title }}
#${{ github.event.number }}](https://github.com/${{ github.repository }}/pull/${{ github.event.number }})
was merged into ${{ github.event.pull_request.base.ref }} by ${{ github.actor }}

View File

@@ -1,29 +0,0 @@
name: Weekly Github Update
on:
schedule:
# Every week on Thursday @ 10:00
- cron: "0 10 * * 4"
workflow_dispatch:
permissions: {} # let's not use any permissions we don't need here
jobs:
send-mattermost-message:
runs-on: ubuntu-latest
steps:
- name: Create Mattermost Message
run: |
DATE=$(date --date="7 days ago" +"%Y-%m-%d")
echo "ASSIGNED_PRS=https://github.com/pulls?q=is%3Apr+is%3Aopen+updated%3A%3E%3D${DATE}+assignee%3A*+user%3Acertbot" >> $GITHUB_ENV
echo "UPDATED_URL=https://github.com/issues?q=is%3Aissue+is%3Aopen+sort%3Acomments-desc+updated%3A%3E%3D${DATE}+user%3Acertbot" >> $GITHUB_ENV
# we pin this action to a version tested and audited by certbot's
# maintainers for extra security. the full hash is used as doing so is
# recommended by zizmor
- uses: mattermost/action-mattermost-notify@b7d118e440bf2749cd18a4a8c88e7092e696257a
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK_URL }}
MATTERMOST_CHANNEL: private-certbot
TEXT: |
## Updates In the Past Week
- Most commented in the last week: [link](${{ env.UPDATED_URL }})
- Updated (assigned) PRs in the last week: [link](${{ env.ASSIGNED_PRS }})

View File

@@ -1,27 +0,0 @@
name: Review Requested
on:
pull_request_target:
types: [review_requested]
permissions: {}
jobs:
send-mattermost-message:
# Don't notify for the interim step of certbot/eff-devs being assigned
if: ${{ github.event.requested_reviewer.login != ''}}
runs-on: ubuntu-latest
steps:
# github actions workflows triggered by pull_request_target can be
# dangerous because they run with additional privileges in an environment
# containing values that can be controlled by an attacker. because of
# this, please take extra caution when modifying the steps taken by this
# workflow. for additional information, see
# https://github.com/certbot/certbot/pull/10490
#
# we pin this action to a version tested and audited by certbot's
# maintainers for extra security. the full hash is used as doing so is
# recommended by zizmor
- uses: mattermost/action-mattermost-notify@b7d118e440bf2749cd18a4a8c88e7092e696257a
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_ASSIGN_WEBHOOK }}
TEXT: >
Review requested from ${{ github.event.requested_reviewer.login }} for "${{ github.event.pull_request.title }}": ${{ github.event.pull_request.html_url }}

View File

@@ -1,51 +0,0 @@
name: Update Stale Issues
on:
schedule:
# Run 1:24AM every night
- cron: '24 1 * * *'
workflow_dispatch:
permissions:
issues: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v6
with:
# Idle number of days before marking issues stale
days-before-issue-stale: 365
# Never mark PRs as stale
days-before-pr-stale: -1
# Idle number of days before closing stale issues
days-before-issue-close: 30
# Never close PRs
days-before-pr-close: -1
# Ignore issues with an assignee
exempt-all-issue-assignees: true
# Label to use when marking as stale
stale-issue-label: stale-needs-update
# Label to use when issue is automatically closed
close-issue-label: auto-closed
stale-issue-message: >
We've made a lot of changes to Certbot since this issue was opened. If you
still have this issue with an up-to-date version of Certbot, can you please
add a comment letting us know? This helps us to better see what issues are
still affecting our users. If there is no activity in the next 30 days, this
issue will be automatically closed.
close-issue-message: >
This issue has been closed due to lack of activity, but if you think it
should be reopened, please open a new issue with a link to this one and we'll
take a look.
# Limit the number of actions per run. As of writing this, GitHub's
# rate limit is 1000 requests per hour so we're still a ways off. See
# https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#rate-limits-for-requests-from-github-actions.
operations-per-run: 180

30
.gitignore vendored
View File

@@ -4,16 +4,16 @@
build/
dist*/
/venv*/
/kgs/
/.tox/
/releases*/
/log*
letsencrypt.log
certbot.log
poetry.lock
letsencrypt-auto-source/letsencrypt-auto.sig.lzma.base64
# coverage
.coverage
.coverage.*
/htmlcov/
/.vagrant
@@ -26,13 +26,15 @@ tags
\#*#
.idea
.ropeproject
.vscode
*.sublime-project
*.sublime-workspace
# auth --cert-path --chain-path
/*.pem
# letstest
tests/letstest/letest-*/
tests/letstest/*.pem
tests/letstest/venv/
.venv
# pytest cache
@@ -47,21 +49,3 @@ tags
.certbot_test_workspace
**/assets/pebble*
**/assets/challtestsrv*
# snap files
.snapcraft
parts
prime
stage
*.snap
snap-constraints.txt
qemu-*
certbot-dns*/certbot-dns*_amd64*.txt
certbot-dns*/certbot-dns*_arm*.txt
/certbot_amd64*.txt
/certbot_arm*.txt
certbot-dns*/snap
snapcraft.cfg
# pyenv files
.python-version

View File

@@ -1,7 +0,0 @@
[settings]
skip_glob=venv*
force_sort_within_sections=True
force_single_line=True
order_by_type=False
line_length=400
src_paths=acme/src,acme/tests,certbot*/tests,certbot/src,certbot*/src/certbot*

789
.pylintrc
View File

@@ -1,577 +1,304 @@
[MAIN]
[MASTER]
# Analyse import fallback blocks. This can be used to support both Python 2 and
# 3 compatible code, which means that the block might have code that exists
# only in one or another interpreter, leading to false positives when analysed.
analyse-fallback-blocks=no
# use as many jobs as there are cores
jobs=0
# Load and enable all available extensions. Use --list-extensions to see a list
# all available extensions.
#enable-all-extensions=
# In error mode, messages with a category besides ERROR or FATAL are
# suppressed, and no reports are done by default. Error mode is compatible with
# disabling specific errors.
#errors-only=
# Always return a 0 (non-error) status code, even if lint errors are found.
# This is primarily useful in continuous integration scripts.
#exit-zero=
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code.
extension-pkg-allow-list=
# A comma-separated list of package or module names from where C extensions may
# be loaded. Extensions are loading into the active Python interpreter and may
# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
# for backward compatibility.)
extension-pkg-whitelist=pywintypes,win32api,win32file,win32security
# Return non-zero exit code if any of these messages/categories are detected,
# even if score is above --fail-under value. Syntax same as enable. Messages
# specified are enabled, while categories only check already-enabled messages.
fail-on=
# Specify a score threshold under which the program will exit with error.
fail-under=10
# Interpret the stdin as a python script, whose filename needs to be passed as
# the module_or_package argument.
#from-stdin=
# Files or directories to be skipped. They should be base names, not paths.
ignore=CVS
# Add files or directories matching the regular expressions patterns to the
# ignore-list. The regex matches against paths and can be in Posix or Windows
# format. Because '\' represents the directory delimiter on Windows systems, it
# can't be used as an escape character.
# CERTBOT COMMENT
# Changing this line back to the default of `ignore-paths=` is being tracked by
# https://github.com/certbot/certbot/issues/7908.
ignore-paths=.*/_internal/tests/
# Files or directories matching the regular expression patterns are skipped.
# The regex matches against base names, not paths. The default value ignores
# Emacs file locks
ignore-patterns=^\.#
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis). It
# supports qualified module names, as well as Unix pattern matching.
ignored-modules=
# Specify a configuration file.
#rcfile=
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
# CERTBOT COMMENT
# This is needed for pylint to import linter_plugin.py since
# https://github.com/PyCQA/pylint/pull/3396.
init-hook="import pylint.config, os, sys; sys.path.append(os.path.dirname(next(pylint.config.find_default_config_files())))"
#init-hook=
# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
# number of processors available to use, and will cap the count on Windows to
# avoid hangs.
jobs=0
# Profiled execution.
profile=no
# Control the amount of potential inferred values when inferring a single
# object. This can help the performance when dealing with large functions or
# complex, nested conditions.
limit-inference-results=100
# List of plugins (as comma separated values of python module names) to load,
# usually to register additional checkers.
load-plugins=linter_plugin
# Add files or directories to the blacklist. They should be base names, not
# paths.
ignore=CVS
# Pickle collected data for later comparisons.
persistent=yes
# Minimum Python version to use for version dependent checks. Will default to
# the version used to run pylint.
py-version=3.10
# List of plugins (as comma separated values of python modules names) to load,
# usually to register additional checkers.
load-plugins=linter_plugin
# Discover python modules and packages in the file system subtree.
recursive=no
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages.
suggestion-mode=yes
[MESSAGES CONTROL]
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time. See also the "--disable" option for examples.
#enable=
# In verbose mode, extra non-checker-related info will be displayed.
#verbose=
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once).You can also use "--disable=all" to
# disable everything first and then reenable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use"--disable=all --enable=classes
# --disable=W"
disable=fixme,locally-disabled,locally-enabled,abstract-class-not-used,abstract-class-little-used,bad-continuation,no-self-use,invalid-name,cyclic-import,duplicate-code,design
# abstract-class-not-used cannot be disabled locally (at least in
# pylint 1.4.1), same for abstract-class-little-used
[REPORTS]
# Set the output format. Available formats are text, parseable, colorized, msvs
# (visual studio) and html. You can also give a reporter class, eg
# mypackage.mymodule.MyReporterClass.
output-format=text
# Put messages in a separate file for each module / package specified on the
# command line instead of printing them on stdout. Reports (if any) will be
# written in a file name "pylint_global.[txt|html]".
files-output=no
# Tells whether to display a full report or only the messages
reports=yes
# Python expression which should return a note less than 10 (10 is the highest
# note). You have access to the variables errors warning, statement which
# respectively contain the number of errors / warnings messages and the total
# number of statements analyzed. This is used by the global evaluation report
# (RP0004).
evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
# Add a comment according to your evaluation note. This is used by the global
# evaluation report (RP0004).
comment=no
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details
#msg-template=
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Required attributes for module, separated by a comma
required-attributes=
# Regular expression matching correct argument names. Overrides argument-
# naming-style. If left empty, argument names will be checked with the set
# naming style.
#argument-rgx=
# List of builtins function names that should not be used, separated by a comma
bad-functions=map,filter,apply,input,file
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Good variable names which should always be accepted, separated by a comma
good-names=f,i,j,k,ex,Run,_,fd,logger
# Regular expression matching correct attribute names. Overrides attr-naming-
# style. If left empty, attribute names will be checked with the set naming
# style.
#attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
# Bad variable names regexes, separated by a comma. If names match any regex,
# they will always be refused
bad-names-rgxs=
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style. If left empty, class attribute names will be checked
# with the set naming style.
#class-attribute-rgx=
# Naming style matching correct class constant names.
class-const-naming-style=UPPER_CASE
# Regular expression matching correct class constant names. Overrides class-
# const-naming-style. If left empty, class constant names will be checked with
# the set naming style.
#class-const-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style. If left empty, class names will be checked with the set naming style.
#class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style. If left empty, constant names will be checked with the set naming
# style.
#const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style. If left empty, function names will be checked with the set
# naming style.
function-rgx=[a-z_][a-z0-9_]{2,40}$
# Good variable names which should always be accepted, separated by a comma.
good-names=i,
j,
k,
ex,
Run,
_,
fd,
logger
# Good variable names regexes, separated by a comma. If names match any regex,
# they will always be accepted
good-names-rgxs=
# Include a hint for the correct naming format with invalid-name.
include-naming-hint=no
# Naming style matching correct inline iteration names.
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style. If left empty, inline iteration names will be checked
# with the set naming style.
#inlinevar-rgx=
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style. If left empty, method names will be checked with the set naming style.
method-rgx=[a-z_][a-z0-9_]{2,50}$
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style. If left empty, module names will be checked with the set naming style.
#module-rgx=
# Bad variable names which should always be refused, separated by a comma
bad-names=foo,bar,baz,toto,tutu,tata
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Include a hint for the correct naming format with invalid-name
include-naming-hint=no
# Regular expression matching correct function names
function-rgx=[a-z_][a-z0-9_]{2,40}$
# Naming hint for function names
function-name-hint=[a-z_][a-z0-9_]{2,40}$
# Regular expression matching correct variable names
variable-rgx=[a-z_][a-z0-9_]{1,30}$
# Naming hint for variable names
variable-name-hint=[a-z_][a-z0-9_]{2,30}$
# Regular expression matching correct constant names
const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
# Naming hint for constant names
const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$
# Regular expression matching correct attribute names
attr-rgx=[a-z_][a-z0-9_]{2,30}$
# Naming hint for attribute names
attr-name-hint=[a-z_][a-z0-9_]{2,30}$
# Regular expression matching correct argument names
argument-rgx=[a-z_][a-z0-9_]{2,30}$
# Naming hint for argument names
argument-name-hint=[a-z_][a-z0-9_]{2,30}$
# Regular expression matching correct class attribute names
class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
# Naming hint for class attribute names
class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$
# Regular expression matching correct inline iteration names
inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
# Naming hint for inline iteration names
inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$
# Regular expression matching correct class names
class-rgx=[A-Z_][a-zA-Z0-9]+$
# Naming hint for class names
class-name-hint=[A-Z_][a-zA-Z0-9]+$
# Regular expression matching correct module names
module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
# Naming hint for module names
module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
# Regular expression matching correct method names
method-rgx=[a-z_][a-z0-9_]{2,50}$
# Naming hint for method names
method-name-hint=[a-z_][a-z0-9_]{2,50}$
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=(__.*__)|(test_[A-Za-z0-9_]*)|(_.*)|(.*Test$)
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Regular expression matching correct type variable names. If left empty, type
# variable names will be checked with the set naming style.
#typevar-rgx=
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style. If left empty, variable names will be checked with the set
# naming style.
variable-rgx=[a-z_][a-z0-9_]{1,30}$
[CLASSES]
# Warn about protected attribute access inside special methods
check-protected-access-in-special-methods=no
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,
__new__,
setUp,
__post_init__
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=cls
[EXCEPTIONS]
# Exceptions that will emit a warning when caught.
overgeneral-exceptions=builtins.BaseException,
builtins.Exception
[FORMAT]
# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
expected-line-ending-format=
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Number of spaces of indent required inside a hanging or continued line.
# git history told me that "This does something silly/broken..."
#indent-after-paren=4
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Maximum number of characters on a single line.
max-line-length=100
# Maximum number of lines in a module.
max-module-lines=1250
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
[IMPORTS]
# List of modules that can be imported at any level, not just the top level
# one.
allow-any-import-level=
# Allow wildcard imports from modules that define __all__.
allow-wildcard-with-all=no
# Deprecated modules which should not be used, separated by a comma.
deprecated-modules=
# Output a graph (.gv or any supported image format) of external dependencies
# to the given file (report RP0402 must not be disabled).
ext-import-graph=
# Output a graph (.gv or any supported image format) of all (i.e. internal and
# external) dependencies to the given file (report RP0402 must not be
# disabled).
import-graph=
# Output a graph (.gv or any supported image format) of internal dependencies
# to the given file (report RP0402 must not be disabled).
int-import-graph=
# Force import order to recognize a module as part of the standard
# compatibility libraries.
known-standard-library=
# Force import order to recognize a module as part of a third party library.
known-third-party=enchant
# Couples of modules and preferred modules, separated by a comma.
preferred-modules=
[LOGGING]
# The type of string formatting that logging methods do. `old` means using %
# formatting, `new` is for `{}` formatting.
logging-format-style=old
# Logging modules to check that the string format arguments are in logging
# function parameter format.
logging-modules=logging,logger
[MESSAGES CONTROL]
# Only show warnings with the listed confidence levels. Leave empty to show
# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
# UNDEFINED.
confidence=HIGH,
CONTROL_FLOW,
INFERENCE,
INFERENCE_FAILURE,
UNDEFINED
# Disable the message, report, category or checker with the given id(s). You
# can either give multiple identifiers separated by comma (,) or put this
# option multiple times (only on the command line, not in the configuration
# file where it should appear only once). You can also use "--disable=all" to
# disable everything first and then re-enable specific checks. For example, if
# you want to run only the similarities checker, you can use "--disable=all
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use "--disable=all --enable=classes
# --disable=W".
# CERTBOT COMMENT
# 1) Once certbot codebase is claimed to be compatible exclusively with Python 3,
# the useless-object-inheritance check can be enabled again, and code fixed accordingly.
# 2) Check unsubscriptable-object tends to create a lot of false positives. Let's disable it.
# See https://github.com/PyCQA/pylint/issues/1498.
# 3) Same as point 2 for no-value-for-parameter.
# See https://github.com/PyCQA/pylint/issues/2820.
# 4) raise-missing-from makes it an error to raise an exception from except
# block without using explicit exception chaining. While explicit exception
# chaining results in a slightly more informative traceback, I don't think
# it's beneficial enough for us to change all of our current instances and
# give Certbot developers errors about this when they're working on new code
# in the future. You can read more about exception chaining and this pylint
# check at
# https://blog.ram.rachum.com/post/621791438475296768/improving-python-exception-chaining-with.
# 5) wrong-import-order generates false positives and a pylint developer
# suggests that people using isort should disable this check at
# https://github.com/PyCQA/pylint/issues/3817#issuecomment-687892090.
# 6) unspecified-encoding generates errors when encoding is not specified in
# in a call to the built-in open function. This relates more to a design decision
# (unspecified encoding makes the open function use the default encoding of the system)
# than a clear flaw on which a check should be enforced. Anyway the project does
# not need to enforce encoding on files so we disable this check.
# 7) consider-using-f-string is "suggesting" to move to f-string when possible with an error. This
# clearly relates to code design and not to potential defects in the code, let's just ignore that.
disable=fixme,locally-disabled,invalid-name,cyclic-import,duplicate-code,design,import-outside-toplevel,useless-object-inheritance,unsubscriptable-object,no-value-for-parameter,no-else-return,no-else-raise,no-else-break,no-else-continue,raise-missing-from,wrong-import-order,unspecified-encoding,consider-using-f-string,raw-checker-failed,bad-inline-option,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,use-symbolic-message-instead
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
# multiple time (only on the command line, not in the configuration file where
# it should appear only once). See also the "--disable" option for examples.
enable=c-extension-no-member
[METHOD_ARGS]
# List of qualified names (i.e., library.method) which require a timeout
# parameter e.g. 'requests.api.get,requests.api.post'
timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
[MISCELLANEOUS]
# List of note tags to take in consideration, separated by a comma.
notes=FIXME,
XXX,
TODO
# Regular expression of note tags to take in consideration.
notes-rgx=
notes=FIXME,XXX,TODO
[REFACTORING]
[LOGGING]
# Maximum number of nested blocks for function / method body
max-nested-blocks=5
# Complete name of functions that never returns. When checking for
# inconsistent-return-statements if a never returning function is called then
# it will be considered as an explicit return statement and no message will be
# printed.
never-returning-functions=sys.exit,argparse.parse_error
[REPORTS]
# Python expression which should return a score less than or equal to 10. You
# have access to the variables 'fatal', 'error', 'warning', 'refactor',
# 'convention', and 'info' which contain the number of messages in each
# category, as well as 'statement' which is the total number of statements
# analyzed. This score is used by the global evaluation report (RP0004).
evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
# Template used to display messages. This is a python new-style format string
# used to format the message information. See doc for all details.
msg-template=
# Set the output format. Available formats are text, parseable, colorized, json
# and msvs (visual studio). You can also give a reporter class, e.g.
# mypackage.mymodule.MyReporterClass.
#output-format=
# Tells whether to display a full report or only the messages.
reports=no
# Activate the evaluation score.
score=yes
[SIMILARITIES]
# Comments are removed from the similarity computation
ignore-comments=yes
# Docstrings are removed from the similarity computation
ignore-docstrings=yes
# Imports are removed from the similarity computation
ignore-imports=yes
# Signatures are removed from the similarity computation
ignore-signatures=yes
# Minimum lines number of a similarity.
min-similarity-lines=6
[STRING]
# This flag controls whether inconsistent-quotes generates a warning when the
# character used as a quote delimiter is used inconsistently within a module.
check-quote-consistency=no
# This flag controls whether the implicit-str-concat should generate a warning
# on implicit string concatenation in sequences defined over several lines.
check-str-concat-over-line-jumps=no
[TYPECHECK]
# List of decorators that produce context managers, such as
# contextlib.contextmanager. Add to this list to register other decorators that
# produce valid context managers.
contextmanager-decorators=contextlib.contextmanager
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
# expressions are accepted.
generated-members=
# Tells whether to warn about missing members when the owner of the attribute
# is inferred to be None.
ignore-none=yes
# This flag controls whether pylint should warn about no-member and similar
# checks whenever an opaque object is returned when inferring. The inference
# can return multiple potential results while evaluating a Python object, but
# some branches might not be evaluated, which results in partial inference. In
# that case, it might be useful to still emit no-member and other checks for
# the rest of the inferred objects.
ignore-on-opaque-inference=yes
# List of symbolic message names to ignore for Mixin members.
ignored-checks-for-mixins=no-member,
not-async-context-manager,
not-context-manager,
attribute-defined-outside-init
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
# qualified names.
ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace,Field,Header,JWS,closing
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis
ignored-modules=confargparse,argparse
# Show a hint with possible names when a member name was not found. The aspect
# of finding the hint is based on edit distance.
missing-member-hint=yes
# The minimum edit distance a name should have in order to be considered a
# similar match for a missing member name.
missing-member-hint-distance=1
# The total number of similar names that should be taken in consideration when
# showing a hint for a missing member.
missing-member-max-choices=1
# Regex pattern to define which classes are considered mixins.
mixin-class-rgx=.*[Mm]ixin
# List of decorators that change the signature of a decorated function.
signature-mutators=
# Logging modules to check that the string format arguments are in logging
# function parameter format
logging-modules=logging,logger
[VARIABLES]
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid defining new builtins when possible.
additional-builtins=
# Tells whether unused global variables should be treated as a violation.
allow-global-unused-variables=yes
# List of names allowed to shadow builtins
allowed-redefined-builtins=
# List of strings which can identify a callback function by name. A callback
# name must start or end with one of those strings.
callbacks=cb_,
_cb
# A regular expression matching the name of dummy variables (i.e. expected to
# not be used).
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
# Argument names that match this expression will be ignored.
ignored-argument-names=_.*|^ignored_|^unused_
# Tells whether we should check for unused import in __init__ files.
init-import=no
# List of qualified module names which can have objects that can redefine
# builtins.
redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
# A regular expression matching the name of dummy variables (i.e. expectedly
# not used).
dummy-variables-rgx=(unused)?_.*|dummy
# List of additional names supposed to be defined in builtins. Remember that
# you should avoid to define new builtins when possible.
additional-builtins=
[SIMILARITIES]
# Minimum lines number of a similarity.
min-similarity-lines=6
# Ignore comments when computing similarities.
ignore-comments=yes
# Ignore docstrings when computing similarities.
ignore-docstrings=yes
# Ignore imports when computing similarities.
ignore-imports=yes
[FORMAT]
# Maximum number of characters on a single line.
max-line-length=100
# Regexp for a line that is allowed to be longer than the limit.
ignore-long-lines=^\s*(# )?<?https?://\S+>?$
# Allow the body of an if to be on the same line as the test if there is no
# else.
single-line-if-stmt=no
# List of optional constructs for which whitespace checking is disabled
no-space-check=trailing-comma
# Maximum number of lines in a module
max-module-lines=1250
# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
# tab).
indent-string=' '
# Number of spaces of indent required inside a hanging or continued line.
# This does something silly/broken...
#indent-after-paren=4
[TYPECHECK]
# Tells whether missing members accessed in mixin class should be ignored. A
# mixin class is detected if its name ends with "mixin" (case insensitive).
ignore-mixin-members=yes
# List of module names for which member attributes should not be checked
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis
ignored-modules=pkg_resources,confargparse,argparse,six.moves,six.moves.urllib
# import errors ignored only in 1.4.4
# https://bitbucket.org/logilab/pylint/commits/cd000904c9e2
# List of classes names for which member attributes should not be checked
# (useful for classes with attributes dynamically set).
ignored-classes=Field,Header,JWS,closing
# When zope mode is activated, add a predefined set of Zope acquired attributes
# to generated-members.
zope=yes
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E0201 when accessed. Python regular
# expressions are accepted.
generated-members=REQUEST,acl_users,aq_parent
[IMPORTS]
# Deprecated modules which should not be used, separated by a comma
deprecated-modules=regsub,TERMIOS,Bastion,rexec
# Create a graph of every (i.e. internal and external) dependencies in the
# given file (report RP0402 must not be disabled)
import-graph=
# Create a graph of external dependencies in the given file (report RP0402 must
# not be disabled)
ext-import-graph=
# Create a graph of internal dependencies in the given file (report RP0402 must
# not be disabled)
int-import-graph=
[CLASSES]
# List of interface methods to ignore, separated by a comma. This is used for
# instance to not check methods defined in Zope's Interface base class.
ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by,implementedBy,providedBy
# List of method names used to declare (i.e. assign) instance attributes.
defining-attr-methods=__init__,__new__,setUp
# List of valid names for the first argument in a class method.
valid-classmethod-first-arg=cls
# List of valid names for the first argument in a metaclass class method.
valid-metaclass-classmethod-first-arg=mcs
[EXCEPTIONS]
# Exceptions that will emit a warning when being caught. Defaults to
# "Exception"
overgeneral-exceptions=Exception

312
.travis.yml Normal file
View File

@@ -0,0 +1,312 @@
language: python
dist: xenial
cache:
directories:
- $HOME/.cache/pip
before_script:
- 'if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then ulimit -n 1024 ; fi'
# On Travis, the fastest parallelization for integration tests has proved to be 4.
- 'if [[ "$TOXENV" == *"integration"* ]]; then export PYTEST_ADDOPTS="--numprocesses 4"; fi'
# Use Travis retry feature for farm tests since they are flaky
- 'if [[ "$TOXENV" == "travis-test-farm"* ]]; then export TRAVIS_RETRY=travis_retry; fi'
- export TOX_TESTENV_PASSENV=TRAVIS
# Only build pushes to the master branch, PRs, and branches beginning with
# `test-` or of the form `digit(s).digit(s).x`. This reduces the number of
# simultaneous Travis runs, which speeds turnaround time on review since there
# is a cap of on the number of simultaneous runs.
branches:
only:
# apache-parser-v2 is a temporary branch for doing work related to
# rewriting the parser in the Apache plugin.
- apache-parser-v2
- master
- /^\d+\.\d+\.x$/
- /^test-.*$/
# Jobs for the main test suite are always executed (including on PRs) except for pushes on master.
not-on-master: &not-on-master
if: NOT (type = push AND branch = master)
# Jobs for the extended test suite are executed for cron jobs and pushes to
# non-development branches. See the explanation for apache-parser-v2 above.
extended-test-suite: &extended-test-suite
if: type = cron OR (type = push AND branch NOT IN (apache-parser-v2, master))
matrix:
include:
# Main test suite
- python: "2.7"
env: ACME_SERVER=pebble TOXENV=integration
<<: *not-on-master
# This job is always executed, including on master
- python: "2.7"
env: TOXENV=py27-cover FYI="py27 tests + code coverage"
- python: "2.7"
env: TOXENV=lint
<<: *not-on-master
- python: "3.4"
env: TOXENV=mypy
<<: *not-on-master
- python: "3.5"
env: TOXENV=mypy
<<: *not-on-master
- python: "2.7"
# Ubuntu Trusty or older must be used because the oldest version of
# cryptography we support cannot be compiled against the version of
# OpenSSL in Xenial or newer.
dist: trusty
env: TOXENV='py27-{acme,apache,certbot,dns,nginx}-oldest'
<<: *not-on-master
- python: "3.4"
env: TOXENV=py34
<<: *not-on-master
- python: "3.7"
env: TOXENV=py37
<<: *not-on-master
- python: "3.8"
env: TOXENV=py38
<<: *not-on-master
- sudo: required
env: TOXENV=apache_compat
services: docker
before_install:
addons:
<<: *not-on-master
- sudo: required
env: TOXENV=le_auto_xenial
services: docker
<<: *not-on-master
- python: "2.7"
env: TOXENV=apacheconftest-with-pebble
<<: *not-on-master
- python: "2.7"
env: TOXENV=nginxroundtrip
<<: *not-on-master
# Extended test suite on cron jobs and pushes to tested branches other than master
- sudo: required
env: TOXENV=nginx_compat
services: docker
before_install:
addons:
<<: *extended-test-suite
- python: "2.7"
env:
- TOXENV=travis-test-farm-apache2
- secure: "f+j/Lj9s1lcuKo5sEFrlRd1kIAMnIJI4z0MTI7QF8jl9Fkmbx7KECGzw31TNgzrOSzxSapHbcueFYvNCLKST+kE/8ogMZBbwqXfEDuKpyF6BY3uYoJn+wPVE5pIb8Hhe08xPte8TTDSMIyHI3EyTfcAKrIreauoArePvh/cRvSw="
<<: *extended-test-suite
- python: "2.7"
env:
- TOXENV=travis-test-farm-leauto-upgrades
- secure: "f+j/Lj9s1lcuKo5sEFrlRd1kIAMnIJI4z0MTI7QF8jl9Fkmbx7KECGzw31TNgzrOSzxSapHbcueFYvNCLKST+kE/8ogMZBbwqXfEDuKpyF6BY3uYoJn+wPVE5pIb8Hhe08xPte8TTDSMIyHI3EyTfcAKrIreauoArePvh/cRvSw="
git:
depth: false # This is needed to have the history to checkout old versions of certbot-auto.
<<: *extended-test-suite
- python: "2.7"
env:
- TOXENV=travis-test-farm-certonly-standalone
- secure: "f+j/Lj9s1lcuKo5sEFrlRd1kIAMnIJI4z0MTI7QF8jl9Fkmbx7KECGzw31TNgzrOSzxSapHbcueFYvNCLKST+kE/8ogMZBbwqXfEDuKpyF6BY3uYoJn+wPVE5pIb8Hhe08xPte8TTDSMIyHI3EyTfcAKrIreauoArePvh/cRvSw="
<<: *extended-test-suite
- python: "2.7"
env:
- TOXENV=travis-test-farm-sdists
- secure: "f+j/Lj9s1lcuKo5sEFrlRd1kIAMnIJI4z0MTI7QF8jl9Fkmbx7KECGzw31TNgzrOSzxSapHbcueFYvNCLKST+kE/8ogMZBbwqXfEDuKpyF6BY3uYoJn+wPVE5pIb8Hhe08xPte8TTDSMIyHI3EyTfcAKrIreauoArePvh/cRvSw="
<<: *extended-test-suite
- python: "3.7"
env: TOXENV=py37 CERTBOT_NO_PIN=1
<<: *extended-test-suite
- python: "2.7"
env: ACME_SERVER=boulder-v1 TOXENV=integration
sudo: required
services: docker
<<: *extended-test-suite
- python: "2.7"
env: ACME_SERVER=boulder-v2 TOXENV=integration
sudo: required
services: docker
<<: *extended-test-suite
- python: "2.7"
env: ACME_SERVER=boulder-v1 TOXENV=integration-certbot-oldest
# Ubuntu Trusty or older must be used because the oldest version of
# cryptography we support cannot be compiled against the version of
# OpenSSL in Xenial or newer.
dist: trusty
sudo: required
services: docker
<<: *extended-test-suite
- python: "2.7"
env: ACME_SERVER=boulder-v2 TOXENV=integration-certbot-oldest
# Ubuntu Trusty or older must be used because the oldest version of
# cryptography we support cannot be compiled against the version of
# OpenSSL in Xenial or newer.
dist: trusty
sudo: required
services: docker
<<: *extended-test-suite
- python: "2.7"
env: ACME_SERVER=boulder-v1 TOXENV=integration-nginx-oldest
# Ubuntu Trusty or older must be used because the oldest version of
# cryptography we support cannot be compiled against the version of
# OpenSSL in Xenial or newer.
dist: trusty
sudo: required
services: docker
<<: *extended-test-suite
- python: "2.7"
env: ACME_SERVER=boulder-v2 TOXENV=integration-nginx-oldest
# Ubuntu Trusty or older must be used because the oldest version of
# cryptography we support cannot be compiled against the version of
# OpenSSL in Xenial or newer.
dist: trusty
sudo: required
services: docker
<<: *extended-test-suite
- python: "3.4"
env: TOXENV=py34
<<: *extended-test-suite
- python: "3.5"
env: TOXENV=py35
<<: *extended-test-suite
- python: "3.6"
env: TOXENV=py36
<<: *extended-test-suite
- python: "3.7"
env: TOXENV=py37
<<: *extended-test-suite
- python: "3.8-dev"
env: TOXENV=py38
<<: *extended-test-suite
- python: "3.4"
env: ACME_SERVER=boulder-v1 TOXENV=integration
sudo: required
services: docker
<<: *extended-test-suite
- python: "3.4"
env: ACME_SERVER=boulder-v2 TOXENV=integration
sudo: required
services: docker
<<: *extended-test-suite
- python: "3.5"
env: ACME_SERVER=boulder-v1 TOXENV=integration
sudo: required
services: docker
<<: *extended-test-suite
- python: "3.5"
env: ACME_SERVER=boulder-v2 TOXENV=integration
sudo: required
services: docker
<<: *extended-test-suite
- python: "3.6"
env: ACME_SERVER=boulder-v1 TOXENV=integration
sudo: required
services: docker
<<: *extended-test-suite
- python: "3.6"
env: ACME_SERVER=boulder-v2 TOXENV=integration
sudo: required
services: docker
<<: *extended-test-suite
- python: "3.7"
env: ACME_SERVER=boulder-v1 TOXENV=integration
sudo: required
services: docker
<<: *extended-test-suite
- python: "3.7"
env: ACME_SERVER=boulder-v2 TOXENV=integration
sudo: required
services: docker
<<: *extended-test-suite
- python: "3.8-dev"
env: ACME_SERVER=boulder-v1 TOXENV=integration
<<: *extended-test-suite
- python: "3.8-dev"
env: ACME_SERVER=boulder-v2 TOXENV=integration
<<: *extended-test-suite
- sudo: required
env: TOXENV=le_auto_jessie
services: docker
<<: *extended-test-suite
- sudo: required
env: TOXENV=le_auto_centos6
services: docker
<<: *extended-test-suite
- sudo: required
env: TOXENV=docker_dev
services: docker
addons:
apt:
packages: # don't install nginx and apache
- libaugeas0
<<: *extended-test-suite
- language: generic
env: TOXENV=py27
os: osx
# Using this osx_image is a workaround for
# https://travis-ci.community/t/xcode-8-3-homebrew-outdated-error/3798.
osx_image: xcode10.2
addons:
homebrew:
packages:
- augeas
- python2
<<: *extended-test-suite
- language: generic
env: TOXENV=py3
os: osx
# Using this osx_image is a workaround for
# https://travis-ci.community/t/xcode-8-3-homebrew-outdated-error/3798.
osx_image: xcode10.2
addons:
homebrew:
packages:
- augeas
- python3
<<: *extended-test-suite
# container-based infrastructure
sudo: false
addons:
apt:
packages: # Keep in sync with letsencrypt-auto-source/pieces/bootstrappers/deb_common.sh and Boulder.
- python-dev
- gcc
- libaugeas0
- libssl-dev
- libffi-dev
- ca-certificates
# For certbot-nginx integration testing
- nginx-light
- openssl
# tools/pip_install.py is used to pin packages to a known working version
# except in tests where the environment variable CERTBOT_NO_PIN is set.
# virtualenv is listed here explicitly to make sure it is upgraded when
# CERTBOT_NO_PIN is set to work around failures we've seen when using an older
# version of virtualenv.
install: 'tools/pip_install.py -U codecov tox virtualenv'
# Most of the time TRAVIS_RETRY is an empty string, and has no effect on the
# script command. It is set only to `travis_retry` during farm tests, in
# order to trigger the Travis retry feature, and compensate the inherent
# flakiness of these specific tests.
script: '$TRAVIS_RETRY tox'
after_success: '[ "$TOXENV" == "py27-cover" ] && codecov -F linux'
notifications:
email: false
irc:
channels:
# This is set to a secure variable to prevent forks from sending
# notifications. This value was created by installing
# https://github.com/travis-ci/travis.rb and running
# `travis encrypt "chat.freenode.net#certbot-devel"`.
- secure: "EWW66E2+KVPZyIPR8ViENZwfcup4Gx3/dlimmAZE0WuLwxDCshBBOd3O8Rf6pBokEoZlXM5eDT6XdyJj8n0DLslgjO62pExdunXpbcMwdY7l1ELxX2/UbnDTE6UnPYa09qVBHNG7156Z6yE0x2lH4M9Ykvp0G0cubjPQHylAwo0="
on_cancel: never
on_success: never
on_failure: always

View File

@@ -1,7 +1,6 @@
Authors
=======
* [Aaron Gable](https://github.com/aarongable)
* [Aaron Zirbes](https://github.com/aaronzirbes)
* Aaron Zuehlke
* Ada Lovelace
@@ -17,16 +16,11 @@ Authors
* [Alex Halderman](https://github.com/jhalderm)
* [Alex Jordan](https://github.com/strugee)
* [Alex Zorin](https://github.com/alexzorin)
* [Alexis Hancock](https://github.com/zoracon)
* [Amir Omidi](https://github.com/aaomidi)
* [Amjad Mashaal](https://github.com/TheNavigat)
* [amplifi](https://github.com/amplifi)
* [Andrew Murray](https://github.com/radarhere)
* [Andrzej Górski](https://github.com/andrzej3393)
* [Anna Glasgall](https://github.com/aglasgall)
* [Anselm Levskaya](https://github.com/levskaya)
* [Antoine Jacoutot](https://github.com/ajacoutot)
* [April King](https://github.com/april)
* [asaph](https://github.com/asaph)
* [Axel Beckert](https://github.com/xtaran)
* [Bas](https://github.com/Mechazawa)
@@ -41,9 +35,7 @@ Authors
* [Blake Griffith](https://github.com/cowlicks)
* [Brad Warren](https://github.com/bmw)
* [Brandon Kraft](https://github.com/kraftbj)
* [Brandon Kreisel](https://github.com/BKreisel)
* [Brian Heim](https://github.com/brianlheim)
* [Cameron Steel](https://github.com/Tugzrida)
* [Brandon Kreisel](https://github.com/kraftbj)
* [Ceesjan Luiten](https://github.com/quinox)
* [Chad Whitacre](https://github.com/whit537)
* [Chhatoi Pritam Baral](https://github.com/pritambaral)
@@ -65,11 +57,8 @@ Authors
* [DanCld](https://github.com/DanCld)
* [Daniel Albers](https://github.com/AID)
* [Daniel Aleksandersen](https://github.com/da2x)
* [Daniel Almasi](https://github.com/almasen)
* [Daniel Convissor](https://github.com/convissor)
* [Daniel "Drex" Drexler](https://github.com/aeturnum)
* [Daniel Huang](https://github.com/dhuang)
* [Daniel McMahon] (https://github.com/igloodan)
* [Dave Guarino](https://github.com/daguar)
* [David cz](https://github.com/dave-cz)
* [David Dworken](https://github.com/ddworken)
@@ -93,8 +82,6 @@ Authors
* [Felix Schwarz](https://github.com/FelixSchwarz)
* [Felix Yan](https://github.com/felixonmars)
* [Filip Ochnik](https://github.com/filipochnik)
* [Florian Klink](https://github.com/flokli)
* [Francesco Colista](https://github.com/fcolista)
* [Francois Marier](https://github.com/fmarier)
* [Frank](https://github.com/Frankkkkk)
* [Frederic BLANC](https://github.com/fblanc)
@@ -113,19 +100,14 @@ Authors
* [Harlan Lieberman-Berg](https://github.com/hlieberman)
* [Henri Salo](https://github.com/fgeek)
* [Henry Chen](https://github.com/henrychen95)
* [Hugo van Kemenade](https://github.com/hugovk)
* [Ingolf Becker](https://github.com/watercrossing)
* [Ivan Nejgebauer](https://github.com/inejge)
* [Jaap Eldering](https://github.com/eldering)
* [Jacob Hoffman-Andrews](https://github.com/jsha)
* [Jacob Sachs](https://github.com/jsachs)
* [Jairo Llopis](https://github.com/Yajo)
* [Jakub Warmuz](https://github.com/kuba)
* [James Balazs](https://github.com/jamesbalazs)
* [James Kasten](https://github.com/jdkasten)
* [Jason Grinblat](https://github.com/ptychomancer)
* [Jason Owen](https://github.com/jasonaowen)
* [Jawshua](https://github.com/jawshua)
* [Jay Faulkner](https://github.com/jayofdoom)
* [J.C. Jones](https://github.com/jcjones)
* [Jeff Hodges](https://github.com/jmhodges)
@@ -140,15 +122,12 @@ Authors
* [John Reed](https://github.com/leerspace)
* [Jonas Berlin](https://github.com/xkr47)
* [Jonathan Herlin](https://github.com/Jonher937)
* [Jonathan Vanasco](https://github.com/jvanasco)
* [Jon Walsh](https://github.com/code-tree)
* [Joona Hoikkala](https://github.com/joohoi)
* [Josh McCullough](https://github.com/JoshMcCullough)
* [Josh Soref](https://github.com/jsoref)
* [Joubin Jabbari](https://github.com/joubin)
* [Juho Juopperi](https://github.com/jkjuopperi)
* [Kane York](https://github.com/riking)
* [Katsuyoshi Ozaki](https://github.com/moratori)
* [Kenichi Maehashi](https://github.com/kmaehashi)
* [Kenneth Skovhede](https://github.com/kenkendk)
* [Kevin Burke](https://github.com/kevinburke)
@@ -157,20 +136,16 @@ Authors
* [LeCoyote](https://github.com/LeCoyote)
* [Lee Watson](https://github.com/TheReverend403)
* [Leo Famulari](https://github.com/lfam)
* [Leon G](https://github.com/LeonGr)
* [lf](https://github.com/lf-)
* [Liam Marshall](https://github.com/liamim)
* [Lior Sabag](https://github.com/liorsbg)
* [Lipis](https://github.com/lipis)
* [lord63](https://github.com/lord63)
* [Lorenzo Fundaró](https://github.com/lfundaro)
* [Luca Beltrame](https://github.com/lbeltrame)
* [Luca Ebach](https://github.com/lucebac)
* [Luca Olivetti](https://github.com/olivluca)
* [Luke Rogers](https://github.com/lukeroge)
* [Lukhnos Liu](https://github.com/lukhnos)
* [Maarten](https://github.com/mrtndwrd)
* [Mads Jensen](https://github.com/atombrella)
* [Maikel Martens](https://github.com/krukas)
* [Malte Janduda](https://github.com/MalteJ)
* [Mantas Mikulėnas](https://github.com/grawity)
@@ -186,7 +161,6 @@ Authors
* [Mathieu Leduc-Hamel](https://github.com/mlhamel)
* [Matt Bostock](https://github.com/mattbostock)
* [Matthew Ames](https://github.com/SuperMatt)
* [Matthew W. Thomas](https://github.com/mwt)
* [Michael Schumacher](https://github.com/schumaml)
* [Michael Strache](https://github.com/Jarodiv)
* [Michael Sverdlin](https://github.com/sveder)
@@ -211,33 +185,25 @@ Authors
* [osirisinferi](https://github.com/osirisinferi)
* Patrick Figel
* [Patrick Heppler](https://github.com/PatrickHeppler)
* [Paul Buonopane](https://github.com/Zenexer)
* [Paul Feitzinger](https://github.com/pfeyz)
* [Paulo Dias](https://github.com/paulojmdias)
* [Pavan Gupta](https://github.com/pavgup)
* [Pavel Pavlov](https://github.com/ghost355)
* [Peter Conrad](https://github.com/pconrad-fb)
* [Peter Eckersley](https://github.com/pde)
* [Peter Mosmans](https://github.com/PeterMosmans)
* [Phil Martin](https://github.com/frillip)
* [Philippe Langlois](https://github.com/langloisjp)
* [Philipp Spitzer](https://github.com/spitza)
* [Piero Steinger](https://github.com/Jadaw1n)
* [Pierre Jaury](https://github.com/kaiyou)
* [Piotr Kasprzyk](https://github.com/kwadrat)
* [Prayag Verma](https://github.com/pra85)
* [Preston Locke](https://github.com/Preston12321)
* [Q Misell][https://magicalcodewit.ch]
* [Rasesh Patel](https://github.com/raspat1)
* [Reinaldo de Souza Jr](https://github.com/juniorz)
* [Remi Rampin](https://github.com/remram44)
* [Rémy HUBSCHER](https://github.com/Natim)
* [Rémy Léone](https://github.com/sieben)
* [Richard Barnes](https://github.com/r-barnes)
* [Richard Harman](https://github.com/warewolf)
* [Richard Panek](https://github.com/kernelpanek)
* [Robert Buchholz](https://github.com/rbu)
* [Robert Dailey](https://github.com/pahrohfit)
* [Robert Habermann](https://github.com/frennkie)
* [Robert Xiao](https://github.com/nneonneo)
* [Roland Shoemaker](https://github.com/rolandshoemaker)
@@ -247,7 +213,6 @@ Authors
* [Sagi Kedmi](https://github.com/sagi)
* [Sam Lanning](https://github.com/samlanning)
* [sapics](https://github.com/sapics)
* [SATOH Fumiyasu](https://github.com/fumiyas)
* [Scott Barr](https://github.com/scottjbarr)
* [Scott Merrill](https://github.com/skpy)
* [Sebastian Bögl](https://github.com/TheBoegl)
@@ -264,11 +229,9 @@ Authors
* [Spencer Bliven](https://github.com/sbliven)
* [Stacey Sheldon](https://github.com/solidgoldbomb)
* [Stavros Korokithakis](https://github.com/skorokithakis)
* [Ștefan Talpalaru](https://github.com/stefantalpalaru)
* [Stefan Weil](https://github.com/stweil)
* [Steve Desmond](https://github.com/stevedesmond-ca)
* [sydneyli](https://github.com/sydneyli)
* [taixx046](https://github.com/taixx046)
* [Tan Jay Jun](https://github.com/jayjun)
* [Tapple Gao](https://github.com/tapple)
* [Telepenin Nikolay](https://github.com/telepenin)
@@ -293,7 +256,6 @@ Authors
* [Wilfried Teiken](https://github.com/wteiken)
* [Willem Fibbe](https://github.com/fibbers)
* [William Budington](https://github.com/Hainish)
* [Will Greenberg](https://github.com/wgreenberg)
* [Will Newby](https://github.com/willnewby)
* [Will Oller](https://github.com/willoller)
* [Yan](https://github.com/diracdeltas)
@@ -301,7 +263,5 @@ Authors
* [Yomna](https://github.com/ynasser)
* [Yoni Jah](https://github.com/yonjah)
* [YourDaddyIsHere](https://github.com/YourDaddyIsHere)
* [Yuseong Cho](https://github.com/g6123)
* [Zach Shepherd](https://github.com/zjs)
* [陈三](https://github.com/chenxsan)
* [Shahar Naveh](https://github.com/ShaharNaveh)

View File

@@ -1 +0,0 @@
certbot/CHANGELOG.md

1852
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,5 @@
<!---
zoracon: (This is an old comment below, not sure how accurate this is anymore.
Since Github seems to lean more towards Markdown these days, it's still probably accurate)
This file serves as an entry point for GitHub's Contributing
Guidelines [1] only.
@@ -14,7 +11,7 @@ to the Sphinx generated docs is provided below.
[1] https://github.com/blog/1184-contributing-guidelines
[2] https://docutils.sourceforge.io/docs/user/rst/quickref.html#hyperlink-targets
[2] http://docutils.sourceforge.net/docs/user/rst/quickref.html#hyperlink-targets
-->
@@ -22,15 +19,19 @@ to the Sphinx generated docs is provided below.
Hi! Welcome to the Certbot project. We look forward to collaborating with you.
If you're reporting a bug in Certbot. Please open an issue: https://github.com/certbot/certbot/issues/new/choose.
If you're having trouble using Certbot and aren't sure you've found a bug, please first try asking for help at https://community.letsencrypt.org/. There is a much larger community there of people familiar with the project who will be able to more quickly answer your questions.
If you're reporting a bug in Certbot, please make sure to include:
- The version of Certbot you're running.
- The operating system you're running it on.
- The commands you ran.
- What you expected to happen, and
- What actually happened.
If you're a developer, we have some helpful information in our
[Developer's Guide](https://certbot.eff.org/docs/contributing.html) to get you
started. In particular, we recommend you read these sections:
started. In particular, we recommend you read these sections
- [EFF's Public Projects Code of Conduct](https://www.eff.org/pages/eppcode)
- [Finding issues to work on](https://certbot.eff.org/docs/contributing.html#find-issues-to-work-on)
- [Coding style](https://certbot.eff.org/docs/contributing.html#coding-style)
- [Submitting a pull request](https://certbot.eff.org/docs/contributing.html#submitting-a-pull-request)
- [EFF's Public Projects Code of Conduct](https://www.eff.org/pages/eppcode)

21
Dockerfile-dev Normal file
View File

@@ -0,0 +1,21 @@
# This Dockerfile builds an image for development.
FROM debian:buster
# Note: this only exposes the port to other docker containers.
EXPOSE 80 443
WORKDIR /opt/certbot/src
# TODO: Install Apache/Nginx for plugin development.
COPY . .
RUN apt-get update && \
apt-get install apache2 git nginx-light -y && \
letsencrypt-auto-source/letsencrypt-auto --os-packages-only && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* \
/tmp/* \
/var/tmp/*
RUN VENV_NAME="../venv" python tools/venv.py
ENV PATH /opt/certbot/venv/bin:$PATH

22
ISSUE_TEMPLATE.md Normal file
View File

@@ -0,0 +1,22 @@
If you're having trouble using Certbot and aren't sure you've found a bug or
request for a new feature, please first try asking for help at
https://community.letsencrypt.org/. There is a much larger community there of
people familiar with the project who will be able to more quickly answer your
questions.
## My operating system is (include version):
## I installed Certbot with (certbot-auto, OS package manager, pip, etc):
## I ran this command and it produced this output:
## Certbot's behavior differed from what I expected because:
## Here is a Certbot log showing the issue (if available):
###### Logs are stored in `/var/log/letsencrypt` by default. Feel free to redact domains, e-mail and IP addresses as you see fit.
## Here is the relevant nginx server block or Apache virtualhost for the domain I am configuring:

9
MANIFEST.in Normal file
View File

@@ -0,0 +1,9 @@
include README.rst
include CHANGELOG.md
include CONTRIBUTING.md
include LICENSE.txt
include linter_plugin.py
recursive-include docs *
recursive-include examples *
recursive-include certbot/tests/testdata *
include certbot/ssl-dhparams.pem

View File

@@ -1 +0,0 @@
certbot/README.rst

131
README.rst Normal file
View File

@@ -0,0 +1,131 @@
.. This file contains a series of comments that are used to include sections of this README in other files. Do not modify these comments unless you know what you are doing. tag:intro-begin
Certbot is part of EFFs effort to encrypt the entire Internet. Secure communication over the Web relies on HTTPS, which requires the use of a digital certificate that lets browsers verify the identity of web servers (e.g., is that really google.com?). Web servers obtain their certificates from trusted third parties called certificate authorities (CAs). Certbot is an easy-to-use client that fetches a certificate from Lets Encrypt—an open certificate authority launched by the EFF, Mozilla, and others—and deploys it to a web server.
Anyone who has gone through the trouble of setting up a secure website knows what a hassle getting and maintaining a certificate is. Certbot and Lets Encrypt can automate away the pain and let you turn on and manage HTTPS with simple commands. Using Certbot and Let's Encrypt is free, so theres no need to arrange payment.
How you use Certbot depends on the configuration of your web server. The best way to get started is to use our `interactive guide <https://certbot.eff.org>`_. It generates instructions based on your configuration settings. In most cases, youll need `root or administrator access <https://certbot.eff.org/faq/#does-certbot-require-root-administrator-privileges>`_ to your web server to run Certbot.
Certbot is meant to be run directly on your web server, not on your personal computer. If youre using a hosted service and dont have direct access to your web server, you might not be able to use Certbot. Check with your hosting provider for documentation about uploading certificates or using certificates issued by Lets Encrypt.
Certbot is a fully-featured, extensible client for the Let's
Encrypt CA (or any other CA that speaks the `ACME
<https://github.com/ietf-wg-acme/acme/blob/master/draft-ietf-acme-acme.md>`_
protocol) that can automate the tasks of obtaining certificates and
configuring webservers to use them. This client runs on Unix-based operating
systems.
To see the changes made to Certbot between versions please refer to our
`changelog <https://github.com/certbot/certbot/blob/master/CHANGELOG.md>`_.
Until May 2016, Certbot was named simply ``letsencrypt`` or ``letsencrypt-auto``,
depending on install method. Instructions on the Internet, and some pieces of the
software, may still refer to this older name.
Contributing
------------
If you'd like to contribute to this project please read `Developer Guide
<https://certbot.eff.org/docs/contributing.html>`_.
This project is governed by `EFF's Public Projects Code of Conduct <https://www.eff.org/pages/eppcode>`_.
.. _installation:
How to run the client
---------------------
The easiest way to install and run Certbot is by visiting `certbot.eff.org`_,
where you can find the correct instructions for many web server and OS
combinations. For more information, see `Get Certbot
<https://certbot.eff.org/docs/install.html>`_.
.. _certbot.eff.org: https://certbot.eff.org/
Understanding the client in more depth
--------------------------------------
To understand what the client is doing in detail, it's important to
understand the way it uses plugins. Please see the `explanation of
plugins <https://certbot.eff.org/docs/using.html#plugins>`_ in
the User Guide.
Links
=====
.. Do not modify this comment unless you know what you're doing. tag:links-begin
Documentation: https://certbot.eff.org/docs
Software project: https://github.com/certbot/certbot
Notes for developers: https://certbot.eff.org/docs/contributing.html
Main Website: https://certbot.eff.org
Let's Encrypt Website: https://letsencrypt.org
Community: https://community.letsencrypt.org
ACME spec: http://ietf-wg-acme.github.io/acme/
ACME working area in github: https://github.com/ietf-wg-acme/acme
|build-status| |coverage| |docs| |container|
.. |build-status| image:: https://travis-ci.com/certbot/certbot.svg?branch=master
:target: https://travis-ci.com/certbot/certbot
:alt: Travis CI status
.. |coverage| image:: https://codecov.io/gh/certbot/certbot/branch/master/graph/badge.svg
:target: https://codecov.io/gh/certbot/certbot
:alt: Coverage status
.. |docs| image:: https://readthedocs.org/projects/letsencrypt/badge/
:target: https://readthedocs.org/projects/letsencrypt/
:alt: Documentation status
.. |container| image:: https://quay.io/repository/letsencrypt/letsencrypt/status
:target: https://quay.io/repository/letsencrypt/letsencrypt
:alt: Docker Repository on Quay.io
.. Do not modify this comment unless you know what you're doing. tag:links-end
System Requirements
===================
See https://certbot.eff.org/docs/install.html#system-requirements.
.. Do not modify this comment unless you know what you're doing. tag:intro-end
.. Do not modify this comment unless you know what you're doing. tag:features-begin
Current Features
=====================
* Supports multiple web servers:
- apache/2.x
- nginx/0.8.48+
- webroot (adds files to webroot directories in order to prove control of
domains and obtain certs)
- standalone (runs its own simple webserver to prove you control a domain)
- other server software via `third party plugins <https://certbot.eff.org/docs/using.html#third-party-plugins>`_
* The private key is generated locally on your system.
* Can talk to the Let's Encrypt CA or optionally to other ACME
compliant services.
* Can get domain-validated (DV) certificates.
* Can revoke certificates.
* Adjustable RSA key bit-length (2048 (default), 4096, ...).
* Can optionally install a http -> https redirect, so your site effectively
runs https only (Apache only)
* Fully automated.
* Configuration changes are logged and can be reverted.
* Supports an interactive text UI, or can be driven entirely from the
command line.
* Free and Open Source Software, made with Python.
.. Do not modify this comment unless you know what you're doing. tag:features-end
For extensive documentation on using and contributing to Certbot, go to https://certbot.eff.org/docs. If you would like to contribute to the project or run the latest code from git, you should read our `developer guide <https://certbot.eff.org/docs/contributing.html>`_.

View File

@@ -1,16 +0,0 @@
# Security Policy
## Supported Versions
Explanation on supported versions [here](https://github.com/certbot/certbot/wiki/Architectural-Decision-Records#-update-to-certbots-version-policy-and-end-of-life-support-on-previous-major-versions)
| Major Version | Support Level |
| ------- | ------------------ |
| >= 4.0 | Full Support |
| 3.x | Discretionary Backports |
| <=2.x | None |
## Reporting a Vulnerability
Security vulnerabilities can be reported using GitHub's [private vulnerability reporting tool](https://github.com/certbot/certbot/security/advisories/new).

View File

@@ -1,33 +0,0 @@
# Read the Docs configuration file for Sphinx projects
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the OS, Python version and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.11"
# You can also specify other tool versions:
# Build documentation in the "docs/" directory with Sphinx
sphinx:
configuration: acme/docs/conf.py
# You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs
# builder: "dirhtml"
# Fail on all warnings to avoid broken references
fail_on_warning: true
# Optionally build your docs in additional formats such as PDF and ePub
formats:
- pdf
- epub
# Optional but recommended, declare the Python requirements required
# to build your documentation
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
python:
install:
- requirements: acme/readthedocs.org.requirements.txt

View File

@@ -1,8 +1,6 @@
include LICENSE.txt
include README.rst
include pytest.ini
recursive-include docs *
recursive-include examples *
recursive-include src/acme/_internal/tests/testdata *
include src/acme/py.typed
global-exclude __pycache__
global-exclude *.py[cod]
recursive-include acme/testdata *

View File

@@ -2,17 +2,19 @@
This module is an implementation of the `ACME protocol`_.
.. _`ACME protocol`: https://datatracker.ietf.org/doc/html/rfc8555
.. _`ACME protocol`: https://ietf-wg-acme.github.io/acme
"""
import sys
import warnings
# This code exists to keep backwards compatibility with people using acme.jose
# before it became the standalone josepy package.
#
# It is based on
# https://github.com/requests/requests/blob/1278ecdf71a312dc2268f3bfc0aabfab3c006dcf/requests/packages.py
import josepy as jose # noqa: F401
import josepy as jose
for mod in list(sys.modules):
# This traversal is apparently necessary such that the identities are

View File

@@ -3,32 +3,26 @@ import abc
import functools
import hashlib
import logging
from typing import Any
from typing import cast
from typing import Mapping
from typing import Optional
from typing import TypeVar
from typing import Union
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import hashes # type: ignore
import josepy as jose
import requests
import six
from acme import fields
logger = logging.getLogger(__name__)
GenericChallenge = TypeVar('GenericChallenge', bound='Challenge')
class Challenge(jose.TypedJSONObjectWithFields):
# _fields_to_partial_json
"""ACME challenge."""
TYPES: dict[str, type['Challenge']] = {}
TYPES = {} # type: dict
@classmethod
def from_json(cls: type[GenericChallenge],
jobj: Mapping[str, Any]) -> Union[GenericChallenge, 'UnrecognizedChallenge']:
def from_json(cls, jobj):
try:
return cast(GenericChallenge, super().from_json(jobj))
return super(Challenge, cls).from_json(jobj)
except jose.UnrecognizedTypeError as error:
logger.debug(error)
return UnrecognizedChallenge.from_json(jobj)
@@ -37,14 +31,9 @@ class Challenge(jose.TypedJSONObjectWithFields):
class ChallengeResponse(jose.TypedJSONObjectWithFields):
# _fields_to_partial_json
"""ACME challenge response."""
TYPES: dict[str, type['ChallengeResponse']] = {}
def to_partial_json(self) -> dict[str, Any]:
# Removes the `type` field which is inserted by TypedJSONObjectWithFields.to_partial_json.
# This field breaks RFC8555 compliance.
jobj = super().to_partial_json()
jobj.pop(self.type_field_name, None)
return jobj
TYPES = {} # type: dict
resource_type = 'challenge'
resource = fields.Resource(resource_type)
class UnrecognizedChallenge(Challenge):
@@ -59,17 +48,17 @@ class UnrecognizedChallenge(Challenge):
:ivar jobj: Original JSON decoded object.
"""
jobj: dict[str, Any]
def __init__(self, jobj: Mapping[str, Any]) -> None:
super().__init__()
def __init__(self, jobj):
super(UnrecognizedChallenge, self).__init__()
object.__setattr__(self, "jobj", jobj)
def to_partial_json(self) -> dict[str, Any]:
return self.jobj # pylint: disable=no-member
def to_partial_json(self):
# pylint: disable=no-member
return self.jobj
@classmethod
def from_json(cls, jobj: Mapping[str, Any]) -> 'UnrecognizedChallenge':
def from_json(cls, jobj):
return cls(jobj)
@@ -79,17 +68,17 @@ class _TokenChallenge(Challenge):
:ivar bytes token:
"""
TOKEN_SIZE = 128 // 8 # Based on the entropy value from the spec
TOKEN_SIZE = 128 / 8 # Based on the entropy value from the spec
"""Minimum size of the :attr:`token` in bytes."""
# TODO: acme-spec doesn't specify token as base64-encoded value
token: bytes = jose.field(
token = jose.Field(
"token", encoder=jose.encode_b64jose, decoder=functools.partial(
jose.decode_b64jose, size=TOKEN_SIZE, minimum=True))
# XXX: rename to ~token_good_for_url
@property
def good_token(self) -> bool: # XXX: @token.decoder
def good_token(self): # XXX: @token.decoder
"""Is `token` good?
.. todo:: acme-spec wants "It MUST NOT contain any non-ASCII
@@ -106,13 +95,13 @@ class _TokenChallenge(Challenge):
class KeyAuthorizationChallengeResponse(ChallengeResponse):
"""Response to Challenges based on Key Authorization.
:param str key_authorization:
:param unicode key_authorization:
"""
key_authorization: str = jose.field("keyAuthorization")
key_authorization = jose.Field("keyAuthorization")
thumbprint_hash_function = hashes.SHA256
def verify(self, chall: 'KeyAuthorizationChallenge', account_public_key: jose.JWK) -> bool:
def verify(self, chall, account_public_key):
"""Verify the key authorization.
:param KeyAuthorization chall: Challenge that corresponds to
@@ -144,39 +133,37 @@ class KeyAuthorizationChallengeResponse(ChallengeResponse):
return True
def to_partial_json(self) -> dict[str, Any]:
jobj = super().to_partial_json()
def to_partial_json(self):
jobj = super(KeyAuthorizationChallengeResponse, self).to_partial_json()
jobj.pop('keyAuthorization', None)
return jobj
# TODO: Make this method a generic of K (bound=KeyAuthorizationChallenge), response_cls of type
# Type[K] and use it in response/response_and_validation return types once Python 3.6 support is
# dropped (do not support generic ABC classes, see https://github.com/python/typing/issues/449).
class KeyAuthorizationChallenge(_TokenChallenge, metaclass=abc.ABCMeta):
@six.add_metaclass(abc.ABCMeta)
class KeyAuthorizationChallenge(_TokenChallenge):
"""Challenge based on Key Authorization.
:param response_cls: Subclass of `KeyAuthorizationChallengeResponse`
that will be used to generate ``response``.
that will be used to generate `response`.
:param str typ: type of the challenge
"""
typ: str = NotImplemented
response_cls: type[KeyAuthorizationChallengeResponse] = NotImplemented
typ = NotImplemented
response_cls = NotImplemented
thumbprint_hash_function = (
KeyAuthorizationChallengeResponse.thumbprint_hash_function)
def key_authorization(self, account_key: jose.JWK) -> str:
def key_authorization(self, account_key):
"""Generate Key Authorization.
:param JWK account_key:
:rtype str:
:rtype unicode:
"""
return self.encode("token") + "." + jose.b64encode(
account_key.thumbprint(
hash_function=self.thumbprint_hash_function)).decode()
def response(self, account_key: jose.JWK) -> KeyAuthorizationChallengeResponse:
def response(self, account_key):
"""Generate response to the challenge.
:param JWK account_key:
@@ -189,7 +176,7 @@ class KeyAuthorizationChallenge(_TokenChallenge, metaclass=abc.ABCMeta):
key_authorization=self.key_authorization(account_key))
@abc.abstractmethod
def validation(self, account_key: jose.JWK, **kwargs: Any) -> Any:
def validation(self, account_key, **kwargs):
"""Generate validation for the challenge.
Subclasses must implement this method, but they are likely to
@@ -203,8 +190,7 @@ class KeyAuthorizationChallenge(_TokenChallenge, metaclass=abc.ABCMeta):
"""
raise NotImplementedError() # pragma: no cover
def response_and_validation(self, account_key: jose.JWK, *args: Any, **kwargs: Any
) -> tuple[KeyAuthorizationChallengeResponse, Any]:
def response_and_validation(self, account_key, *args, **kwargs):
"""Generate response and validation.
Convenience function that return results of `response` and
@@ -223,14 +209,14 @@ class DNS01Response(KeyAuthorizationChallengeResponse):
"""ACME dns-01 challenge response."""
typ = "dns-01"
def simple_verify(self, chall: 'DNS01', domain: str, account_public_key: jose.JWK) -> bool: # pylint: disable=unused-argument
def simple_verify(self, chall, domain, account_public_key): # pylint: disable=unused-argument
"""Simple verify.
This method no longer checks DNS records and is a simple wrapper
around `KeyAuthorizationChallengeResponse.verify`.
:param challenges.DNS01 chall: Corresponding challenge.
:param str domain: Domain name being verified.
:param unicode domain: Domain name being verified.
:param JWK account_public_key: Public key for the key pair
being authorized.
@@ -254,24 +240,23 @@ class DNS01(KeyAuthorizationChallenge):
LABEL = "_acme-challenge"
"""Label clients prepend to the domain name being validated."""
def validation(self, account_key: jose.JWK, **unused_kwargs: Any) -> str:
def validation(self, account_key, **unused_kwargs):
"""Generate validation.
:param JWK account_key:
:rtype: str
:rtype: unicode
"""
return jose.b64encode(hashlib.sha256(self.key_authorization(
account_key).encode("utf-8")).digest()).decode()
def validation_domain_name(self, name: str) -> str:
def validation_domain_name(self, name):
"""Domain name for TXT validation record.
:param str name: Domain name being validated.
:rtype: str
:param unicode name: Domain name being validated.
"""
return f"{self.LABEL}.{name}"
return "{0}.{1}".format(self.LABEL, name)
@ChallengeResponse.register
@@ -290,16 +275,14 @@ class HTTP01Response(KeyAuthorizationChallengeResponse):
WHITESPACE_CUTSET = "\n\r\t "
"""Whitespace characters which should be ignored at the end of the body."""
def simple_verify(self, chall: 'HTTP01', domain: str, account_public_key: jose.JWK,
port: Optional[int] = None, timeout: int = 30) -> bool:
def simple_verify(self, chall, domain, account_public_key, port=None):
"""Simple verify.
:param challenges.SimpleHTTP chall: Corresponding challenge.
:param str domain: Domain name being verified.
:param unicode domain: Domain name being verified.
:param JWK account_public_key: Public key for the key pair
being authorized.
:param int port: Port used in the validation.
:param int timeout: Timeout in seconds.
:returns: ``True`` iff validation with the files currently served by the
HTTP server is successful.
@@ -321,19 +304,10 @@ class HTTP01Response(KeyAuthorizationChallengeResponse):
uri = chall.uri(domain)
logger.debug("Verifying %s at %s...", chall.typ, uri)
try:
http_response = requests.get(uri, verify=False, timeout=timeout)
http_response = requests.get(uri)
except requests.exceptions.RequestException as error:
logger.error("Unable to reach %s: %s", uri, error)
return False
# By default, http_response.text will try to guess the encoding to use
# when decoding the response to Python unicode strings. This guesswork
# is error prone. RFC 8555 specifies that HTTP-01 responses should be
# key authorizations with possible trailing whitespace. Since key
# authorizations must be composed entirely of the base64url alphabet
# plus ".", we tell requests that the response should be ASCII. See
# https://datatracker.ietf.org/doc/html/rfc8555#section-8.3 for more
# info.
http_response.encoding = "ascii"
logger.debug("Received %s: %s. Headers: %s", http_response,
http_response.text, http_response.headers)
@@ -357,36 +331,63 @@ class HTTP01(KeyAuthorizationChallenge):
"""URI root path for the server provisioned resource."""
@property
def path(self) -> str:
def path(self):
"""Path (starting with '/') for provisioned resource.
:rtype: str
:rtype: string
"""
return '/' + self.URI_ROOT_PATH + '/' + self.encode('token')
def uri(self, domain: str) -> str:
def uri(self, domain):
"""Create an URI to the provisioned resource.
Forms an URI to the HTTPS server provisioned resource
(containing :attr:`~SimpleHTTP.token`).
:param str domain: Domain name being verified.
:rtype: str
:param unicode domain: Domain name being verified.
:rtype: string
"""
return "http://" + domain + self.path
def validation(self, account_key: jose.JWK, **unused_kwargs: Any) -> str:
def validation(self, account_key, **unused_kwargs):
"""Generate validation.
:param JWK account_key:
:rtype: str
:rtype: unicode
"""
return self.key_authorization(account_key)
@ChallengeResponse.register
class TLSALPN01Response(KeyAuthorizationChallengeResponse):
"""ACME TLS-ALPN-01 challenge response.
This class only allows initiating a TLS-ALPN-01 challenge returned from the
CA. Full support for responding to TLS-ALPN-01 challenges by generating and
serving the expected response certificate is not currently provided.
"""
typ = "tls-alpn-01"
@Challenge.register
class TLSALPN01(KeyAuthorizationChallenge):
"""ACME tls-alpn-01 challenge.
This class simply allows parsing the TLS-ALPN-01 challenge returned from
the CA. Full TLS-ALPN-01 support is not currently provided.
"""
typ = "tls-alpn-01"
response_cls = TLSALPN01Response
def validation(self, account_key, **kwargs):
"""Generate validation for the challenge."""
raise NotImplementedError()
@Challenge.register
class DNS(_TokenChallenge):
"""ACME "dns" challenge."""
@@ -395,8 +396,7 @@ class DNS(_TokenChallenge):
LABEL = "_acme-challenge"
"""Label clients prepend to the domain name being validated."""
def gen_validation(self, account_key: jose.JWK, alg: jose.JWASignature = jose.RS256,
**kwargs: Any) -> jose.JWS:
def gen_validation(self, account_key, alg=jose.RS256, **kwargs):
"""Generate validation.
:param .JWK account_key: Private account key.
@@ -410,7 +410,7 @@ class DNS(_TokenChallenge):
payload=self.json_dumps(sort_keys=True).encode('utf-8'),
key=account_key, alg=alg, **kwargs)
def check_validation(self, validation: jose.JWS, account_public_key: jose.JWK) -> bool:
def check_validation(self, validation, account_public_key):
"""Check validation.
:param JWS validation:
@@ -427,7 +427,7 @@ class DNS(_TokenChallenge):
logger.debug("Checking validation for DNS failed: %s", error)
return False
def gen_response(self, account_key: jose.JWK, **kwargs: Any) -> 'DNSResponse':
def gen_response(self, account_key, **kwargs):
"""Generate response.
:param .JWK account_key: Private account key.
@@ -436,12 +436,13 @@ class DNS(_TokenChallenge):
:rtype: DNSResponse
"""
return DNSResponse(validation=self.gen_validation(account_key, **kwargs))
return DNSResponse(validation=self.gen_validation(
account_key, **kwargs))
def validation_domain_name(self, name: str) -> str:
def validation_domain_name(self, name):
"""Domain name for TXT validation record.
:param str name: Domain name being validated.
:param unicode name: Domain name being validated.
"""
return "{0}.{1}".format(self.LABEL, name)
@@ -456,9 +457,9 @@ class DNSResponse(ChallengeResponse):
"""
typ = "dns"
validation: jose.JWS = jose.field("validation", decoder=jose.JWS.from_json)
validation = jose.Field("validation", decoder=jose.JWS.from_json)
def check_validation(self, chall: 'DNS', account_public_key: jose.JWK) -> bool:
def check_validation(self, chall, account_public_key):
"""Check validation.
:param challenges.DNS chall:

View File

@@ -1,17 +1,15 @@
"""Tests for acme.challenges."""
import sys
import unittest
from unittest import mock
import urllib.parse as urllib_parse
import josepy as jose
from josepy.jwk import JWKEC
import pytest
import mock
import requests
from acme._internal.tests import test_util
from six.moves.urllib import parse as urllib_parse # pylint: disable=relative-import
CERT = test_util.load_cert('cert.pem')
from acme import test_util
CERT = test_util.load_comparable_cert('cert.pem')
KEY = jose.JWKRSA(key=test_util.load_rsa_private_key('rsa512_key.pem'))
@@ -21,7 +19,8 @@ class ChallengeTest(unittest.TestCase):
from acme.challenges import Challenge
from acme.challenges import UnrecognizedChallenge
chall = UnrecognizedChallenge({"type": "foo"})
assert chall == Challenge.from_json(chall.jobj)
# pylint: disable=no-member
self.assertEqual(chall, Challenge.from_json(chall.jobj))
class UnrecognizedChallengeTest(unittest.TestCase):
@@ -32,11 +31,12 @@ class UnrecognizedChallengeTest(unittest.TestCase):
self.chall = UnrecognizedChallenge(self.jobj)
def test_to_partial_json(self):
assert self.jobj == self.chall.to_partial_json()
self.assertEqual(self.jobj, self.chall.to_partial_json())
def test_from_json(self):
from acme.challenges import UnrecognizedChallenge
assert self.chall == UnrecognizedChallenge.from_json(self.jobj)
self.assertEqual(
self.chall, UnrecognizedChallenge.from_json(self.jobj))
class KeyAuthorizationChallengeResponseTest(unittest.TestCase):
@@ -52,26 +52,26 @@ class KeyAuthorizationChallengeResponseTest(unittest.TestCase):
from acme.challenges import KeyAuthorizationChallengeResponse
response = KeyAuthorizationChallengeResponse(
key_authorization='foo.oKGqedy-b-acd5eoybm2f-NVFxvyOoET5CNy3xnv8WY')
assert response.verify(self.chall, KEY.public_key())
self.assertTrue(response.verify(self.chall, KEY.public_key()))
def test_verify_wrong_token(self):
from acme.challenges import KeyAuthorizationChallengeResponse
response = KeyAuthorizationChallengeResponse(
key_authorization='bar.oKGqedy-b-acd5eoybm2f-NVFxvyOoET5CNy3xnv8WY')
assert not response.verify(self.chall, KEY.public_key())
self.assertFalse(response.verify(self.chall, KEY.public_key()))
def test_verify_wrong_thumbprint(self):
from acme.challenges import KeyAuthorizationChallengeResponse
response = KeyAuthorizationChallengeResponse(
key_authorization='foo.oKGqedy-b-acd5eoybm2f-NVFxv')
assert not response.verify(self.chall, KEY.public_key())
self.assertFalse(response.verify(self.chall, KEY.public_key()))
def test_verify_wrong_form(self):
from acme.challenges import KeyAuthorizationChallengeResponse
response = KeyAuthorizationChallengeResponse(
key_authorization='.foo.oKGqedy-b-acd5eoybm2f-'
'NVFxvyOoET5CNy3xnv8WY')
assert not response.verify(self.chall, KEY.public_key())
self.assertFalse(response.verify(self.chall, KEY.public_key()))
class DNS01ResponseTest(unittest.TestCase):
@@ -90,11 +90,12 @@ class DNS01ResponseTest(unittest.TestCase):
self.response = self.chall.response(KEY)
def test_to_partial_json(self):
assert {} == self.msg.to_partial_json()
self.assertEqual({k: v for k, v in self.jmsg.items() if k != 'keyAuthorization'},
self.msg.to_partial_json())
def test_from_json(self):
from acme.challenges import DNS01Response
assert self.msg == DNS01Response.from_json(self.jmsg)
self.assertEqual(self.msg, DNS01Response.from_json(self.jmsg))
def test_from_json_hashable(self):
from acme.challenges import DNS01Response
@@ -104,12 +105,12 @@ class DNS01ResponseTest(unittest.TestCase):
key2 = jose.JWKRSA.load(test_util.load_vector('rsa256_key.pem'))
public_key = key2.public_key()
verified = self.response.simple_verify(self.chall, "local", public_key)
assert not verified
self.assertFalse(verified)
def test_simple_verify_success(self):
public_key = KEY.public_key()
verified = self.response.simple_verify(self.chall, "local", public_key)
assert verified
self.assertTrue(verified)
class DNS01Test(unittest.TestCase):
@@ -124,19 +125,20 @@ class DNS01Test(unittest.TestCase):
}
def test_validation_domain_name(self):
assert '_acme-challenge.www.example.com' == \
self.msg.validation_domain_name('www.example.com')
self.assertEqual('_acme-challenge.www.example.com',
self.msg.validation_domain_name('www.example.com'))
def test_validation(self):
assert "rAa7iIg4K2y63fvUhCfy8dP1Xl7wEhmQq0oChTcE3Zk" == \
self.msg.validation(KEY)
self.assertEqual(
"rAa7iIg4K2y63fvUhCfy8dP1Xl7wEhmQq0oChTcE3Zk",
self.msg.validation(KEY))
def test_to_partial_json(self):
assert self.jmsg == self.msg.to_partial_json()
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from acme.challenges import DNS01
assert self.msg == DNS01.from_json(self.jmsg)
self.assertEqual(self.msg, DNS01.from_json(self.jmsg))
def test_from_json_hashable(self):
from acme.challenges import DNS01
@@ -159,11 +161,13 @@ class HTTP01ResponseTest(unittest.TestCase):
self.response = self.chall.response(KEY)
def test_to_partial_json(self):
assert {} == self.msg.to_partial_json()
self.assertEqual({k: v for k, v in self.jmsg.items() if k != 'keyAuthorization'},
self.msg.to_partial_json())
def test_from_json(self):
from acme.challenges import HTTP01Response
assert self.msg == HTTP01Response.from_json(self.jmsg)
self.assertEqual(
self.msg, HTTP01Response.from_json(self.jmsg))
def test_from_json_hashable(self):
from acme.challenges import HTTP01Response
@@ -177,16 +181,15 @@ class HTTP01ResponseTest(unittest.TestCase):
def test_simple_verify_good_validation(self, mock_get):
validation = self.chall.validation(KEY)
mock_get.return_value = mock.MagicMock(text=validation)
assert self.response.simple_verify(
self.chall, "local", KEY.public_key())
mock_get.assert_called_once_with(self.chall.uri("local"), verify=False,
timeout=mock.ANY)
self.assertTrue(self.response.simple_verify(
self.chall, "local", KEY.public_key()))
mock_get.assert_called_once_with(self.chall.uri("local"))
@mock.patch("acme.challenges.requests.get")
def test_simple_verify_bad_validation(self, mock_get):
mock_get.return_value = mock.MagicMock(text="!")
assert not self.response.simple_verify(
self.chall, "local", KEY.public_key())
self.assertFalse(self.response.simple_verify(
self.chall, "local", KEY.public_key()))
@mock.patch("acme.challenges.requests.get")
def test_simple_verify_whitespace_validation(self, mock_get):
@@ -194,34 +197,23 @@ class HTTP01ResponseTest(unittest.TestCase):
mock_get.return_value = mock.MagicMock(
text=(self.chall.validation(KEY) +
HTTP01Response.WHITESPACE_CUTSET))
assert self.response.simple_verify(
self.chall, "local", KEY.public_key())
mock_get.assert_called_once_with(self.chall.uri("local"), verify=False,
timeout=mock.ANY)
self.assertTrue(self.response.simple_verify(
self.chall, "local", KEY.public_key()))
mock_get.assert_called_once_with(self.chall.uri("local"))
@mock.patch("acme.challenges.requests.get")
def test_simple_verify_connection_error(self, mock_get):
mock_get.side_effect = requests.exceptions.RequestException
assert not self.response.simple_verify(
self.chall, "local", KEY.public_key())
self.assertFalse(self.response.simple_verify(
self.chall, "local", KEY.public_key()))
@mock.patch("acme.challenges.requests.get")
def test_simple_verify_port(self, mock_get):
self.response.simple_verify(
self.chall, domain="local",
account_public_key=KEY.public_key(), port=8080)
assert "local:8080" == urllib_parse.urlparse(
mock_get.mock_calls[0][1][0]).netloc
@mock.patch("acme.challenges.requests.get")
def test_simple_verify_timeout(self, mock_get):
self.response.simple_verify(self.chall, "local", KEY.public_key())
mock_get.assert_called_once_with(self.chall.uri("local"), verify=False,
timeout=30)
mock_get.reset_mock()
self.response.simple_verify(self.chall, "local", KEY.public_key(), timeout=1234)
mock_get.assert_called_once_with(self.chall.uri("local"), verify=False,
timeout=1234)
self.assertEqual("local:8080", urllib_parse.urlparse(
mock_get.mock_calls[0][1][0]).netloc)
class HTTP01Test(unittest.TestCase):
@@ -237,28 +229,90 @@ class HTTP01Test(unittest.TestCase):
}
def test_path(self):
assert self.msg.path == '/.well-known/acme-challenge/' \
'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA'
self.assertEqual(self.msg.path, '/.well-known/acme-challenge/'
'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA')
def test_uri(self):
assert 'http://example.com/.well-known/acme-challenge/' \
'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA' == \
self.msg.uri('example.com')
self.assertEqual(
'http://example.com/.well-known/acme-challenge/'
'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA',
self.msg.uri('example.com'))
def test_to_partial_json(self):
assert self.jmsg == self.msg.to_partial_json()
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from acme.challenges import HTTP01
assert self.msg == HTTP01.from_json(self.jmsg)
self.assertEqual(self.msg, HTTP01.from_json(self.jmsg))
def test_from_json_hashable(self):
from acme.challenges import HTTP01
hash(HTTP01.from_json(self.jmsg))
def test_good_token(self):
assert self.msg.good_token
assert not self.msg.update(token=b'..').good_token
self.assertTrue(self.msg.good_token)
self.assertFalse(
self.msg.update(token=b'..').good_token)
class TLSALPN01ResponseTest(unittest.TestCase):
def setUp(self):
from acme.challenges import TLSALPN01Response
self.msg = TLSALPN01Response(key_authorization=u'foo')
self.jmsg = {
'resource': 'challenge',
'type': 'tls-alpn-01',
'keyAuthorization': u'foo',
}
from acme.challenges import TLSALPN01
self.chall = TLSALPN01(token=(b'x' * 16))
self.response = self.chall.response(KEY)
def test_to_partial_json(self):
self.assertEqual({k: v for k, v in self.jmsg.items() if k != 'keyAuthorization'},
self.msg.to_partial_json())
def test_from_json(self):
from acme.challenges import TLSALPN01Response
self.assertEqual(self.msg, TLSALPN01Response.from_json(self.jmsg))
def test_from_json_hashable(self):
from acme.challenges import TLSALPN01Response
hash(TLSALPN01Response.from_json(self.jmsg))
class TLSALPN01Test(unittest.TestCase):
def setUp(self):
from acme.challenges import TLSALPN01
self.msg = TLSALPN01(
token=jose.b64decode('a82d5ff8ef740d12881f6d3c2277ab2e'))
self.jmsg = {
'type': 'tls-alpn-01',
'token': 'a82d5ff8ef740d12881f6d3c2277ab2e',
}
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from acme.challenges import TLSALPN01
self.assertEqual(self.msg, TLSALPN01.from_json(self.jmsg))
def test_from_json_hashable(self):
from acme.challenges import TLSALPN01
hash(TLSALPN01.from_json(self.jmsg))
def test_from_json_invalid_token_length(self):
from acme.challenges import TLSALPN01
self.jmsg['token'] = jose.encode_b64jose(b'abcd')
self.assertRaises(
jose.DeserializationError, TLSALPN01.from_json, self.jmsg)
def test_validation(self):
self.assertRaises(NotImplementedError, self.msg.validation, KEY)
class DNSTest(unittest.TestCase):
@@ -273,27 +327,24 @@ class DNSTest(unittest.TestCase):
}
def test_to_partial_json(self):
assert self.jmsg == self.msg.to_partial_json()
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from acme.challenges import DNS
assert self.msg == DNS.from_json(self.jmsg)
self.assertEqual(self.msg, DNS.from_json(self.jmsg))
def test_from_json_hashable(self):
from acme.challenges import DNS
hash(DNS.from_json(self.jmsg))
def test_gen_check_validation(self):
ec_key_secp384r1 = JWKEC(key=test_util.load_ecdsa_private_key('ec_secp384r1_key.pem'))
for key, alg in [(KEY, jose.RS256), (ec_key_secp384r1, jose.ES384)]:
with self.subTest(key=key, alg=alg):
assert self.msg.check_validation(
self.msg.gen_validation(key, alg=alg), key.public_key())
self.assertTrue(self.msg.check_validation(
self.msg.gen_validation(KEY), KEY.public_key()))
def test_gen_check_validation_wrong_key(self):
key2 = jose.JWKRSA.load(test_util.load_vector('rsa1024_key.pem'))
assert not self.msg.check_validation(
self.msg.gen_validation(KEY), key2.public_key())
self.assertFalse(self.msg.check_validation(
self.msg.gen_validation(KEY), key2.public_key()))
def test_check_validation_wrong_payload(self):
validations = tuple(
@@ -301,32 +352,28 @@ class DNSTest(unittest.TestCase):
for payload in (b'', b'{}')
)
for validation in validations:
assert not self.msg.check_validation(
validation, KEY.public_key())
self.assertFalse(self.msg.check_validation(
validation, KEY.public_key()))
def test_check_validation_wrong_fields(self):
bad_validation = jose.JWS.sign(
payload=self.msg.update(
token=b'x' * 20).json_dumps().encode('utf-8'),
alg=jose.RS256, key=KEY)
assert not self.msg.check_validation(bad_validation, KEY.public_key())
self.assertFalse(self.msg.check_validation(
bad_validation, KEY.public_key()))
def test_gen_response(self):
with mock.patch('acme.challenges.DNS.gen_validation') as mock_gen:
mock_gen.return_value = mock.sentinel.validation
response = self.msg.gen_response(KEY)
from acme.challenges import DNSResponse
assert isinstance(response, DNSResponse)
assert response.validation == mock.sentinel.validation
self.assertTrue(isinstance(response, DNSResponse))
self.assertEqual(response.validation, mock.sentinel.validation)
def test_validation_domain_name(self):
assert '_acme-challenge.le.wtf' == self.msg.validation_domain_name('le.wtf')
def test_validation_domain_name_ecdsa(self):
ec_key_secp384r1 = JWKEC(key=test_util.load_ecdsa_private_key('ec_secp384r1_key.pem'))
assert self.msg.check_validation(
self.msg.gen_validation(ec_key_secp384r1, alg=jose.ES384),
ec_key_secp384r1.public_key()) is True
self.assertEqual(
'_acme-challenge.le.wtf', self.msg.validation_domain_name('le.wtf'))
class DNSResponseTest(unittest.TestCase):
@@ -342,6 +389,8 @@ class DNSResponseTest(unittest.TestCase):
from acme.challenges import DNSResponse
self.msg = DNSResponse(validation=self.validation)
self.jmsg_to = {
'resource': 'challenge',
'type': 'dns',
'validation': self.validation,
}
self.jmsg_from = {
@@ -351,31 +400,20 @@ class DNSResponseTest(unittest.TestCase):
}
def test_to_partial_json(self):
assert self.jmsg_to == self.msg.to_partial_json()
self.assertEqual(self.jmsg_to, self.msg.to_partial_json())
def test_from_json(self):
from acme.challenges import DNSResponse
assert self.msg == DNSResponse.from_json(self.jmsg_from)
self.assertEqual(self.msg, DNSResponse.from_json(self.jmsg_from))
def test_from_json_hashable(self):
from acme.challenges import DNSResponse
hash(DNSResponse.from_json(self.jmsg_from))
def test_check_validation(self):
assert self.msg.check_validation(self.chall, KEY.public_key())
class JWSPayloadRFC8555Compliant(unittest.TestCase):
"""Test for RFC8555 compliance of JWS generated from resources/challenges"""
def test_challenge_payload(self):
from acme.challenges import HTTP01Response
challenge_body = HTTP01Response()
jobj = challenge_body.json_dumps(indent=2).encode()
# RFC8555 states that challenge responses must have an empty payload.
assert jobj == b'{}'
self.assertTrue(
self.msg.check_validation(self.chall, KEY.public_key()))
if __name__ == '__main__':
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover
unittest.main() # pragma: no cover

1207
acme/acme/client.py Normal file

File diff suppressed because it is too large Load Diff

1308
acme/acme/client_test.py Normal file

File diff suppressed because it is too large Load Diff

307
acme/acme/crypto_util.py Normal file
View File

@@ -0,0 +1,307 @@
"""Crypto utilities."""
import binascii
import contextlib
import logging
import os
import re
import socket
from OpenSSL import crypto
from OpenSSL import SSL # type: ignore # https://github.com/python/typeshed/issues/2052
import josepy as jose
from acme import errors
# pylint: disable=unused-import, no-name-in-module
from acme.magic_typing import Callable, Union, Tuple, Optional
# pylint: enable=unused-import, no-name-in-module
logger = logging.getLogger(__name__)
# Default SSL method selected here is the most compatible, while secure
# SSL method: TLSv1_METHOD is only compatible with
# TLSv1_METHOD, while SSLv23_METHOD is compatible with all other
# methods, including TLSv2_METHOD (read more at
# https://www.openssl.org/docs/ssl/SSLv23_method.html). _serve_sni
# should be changed to use "set_options" to disable SSLv2 and SSLv3,
# in case it's used for things other than probing/serving!
_DEFAULT_SSL_METHOD = SSL.SSLv23_METHOD # type: ignore
class SSLSocket(object):
"""SSL wrapper for sockets.
:ivar socket sock: Original wrapped socket.
:ivar dict certs: Mapping from domain names (`bytes`) to
`OpenSSL.crypto.X509`.
:ivar method: See `OpenSSL.SSL.Context` for allowed values.
"""
def __init__(self, sock, certs, method=_DEFAULT_SSL_METHOD):
self.sock = sock
self.certs = certs
self.method = method
def __getattr__(self, name):
return getattr(self.sock, name)
def _pick_certificate_cb(self, connection):
"""SNI certificate callback.
This method will set a new OpenSSL context object for this
connection when an incoming connection provides an SNI name
(in order to serve the appropriate certificate, if any).
:param connection: The TLS connection object on which the SNI
extension was received.
:type connection: :class:`OpenSSL.Connection`
"""
server_name = connection.get_servername()
try:
key, cert = self.certs[server_name]
except KeyError:
logger.debug("Server name (%s) not recognized, dropping SSL",
server_name)
return
new_context = SSL.Context(self.method)
new_context.set_options(SSL.OP_NO_SSLv2)
new_context.set_options(SSL.OP_NO_SSLv3)
new_context.use_privatekey(key)
new_context.use_certificate(cert)
connection.set_context(new_context)
class FakeConnection(object):
"""Fake OpenSSL.SSL.Connection."""
# pylint: disable=missing-docstring
def __init__(self, connection):
self._wrapped = connection
def __getattr__(self, name):
return getattr(self._wrapped, name)
def shutdown(self, *unused_args):
# OpenSSL.SSL.Connection.shutdown doesn't accept any args
return self._wrapped.shutdown()
def accept(self): # pylint: disable=missing-docstring
sock, addr = self.sock.accept()
context = SSL.Context(self.method)
context.set_options(SSL.OP_NO_SSLv2)
context.set_options(SSL.OP_NO_SSLv3)
context.set_tlsext_servername_callback(self._pick_certificate_cb)
ssl_sock = self.FakeConnection(SSL.Connection(context, sock))
ssl_sock.set_accept_state()
logger.debug("Performing handshake with %s", addr)
try:
ssl_sock.do_handshake()
except SSL.Error as error:
# _pick_certificate_cb might have returned without
# creating SSL context (wrong server name)
raise socket.error(error)
return ssl_sock, addr
def probe_sni(name, host, port=443, timeout=300,
method=_DEFAULT_SSL_METHOD, source_address=('', 0)):
"""Probe SNI server for SSL certificate.
:param bytes name: Byte string to send as the server name in the
client hello message.
:param bytes host: Host to connect to.
:param int port: Port to connect to.
:param int timeout: Timeout in seconds.
:param method: See `OpenSSL.SSL.Context` for allowed values.
:param tuple source_address: Enables multi-path probing (selection
of source interface). See `socket.creation_connection` for more
info. Available only in Python 2.7+.
:raises acme.errors.Error: In case of any problems.
:returns: SSL certificate presented by the server.
:rtype: OpenSSL.crypto.X509
"""
context = SSL.Context(method)
context.set_timeout(timeout)
socket_kwargs = {'source_address': source_address}
try:
logger.debug(
"Attempting to connect to %s:%d%s.", host, port,
" from {0}:{1}".format(
source_address[0],
source_address[1]
) if socket_kwargs else ""
)
socket_tuple = (host, port) # type: Tuple[str, int]
sock = socket.create_connection(socket_tuple, **socket_kwargs) # type: ignore
except socket.error as error:
raise errors.Error(error)
with contextlib.closing(sock) as client:
client_ssl = SSL.Connection(context, client)
client_ssl.set_connect_state()
client_ssl.set_tlsext_host_name(name) # pyOpenSSL>=0.13
try:
client_ssl.do_handshake()
client_ssl.shutdown()
except SSL.Error as error:
raise errors.Error(error)
return client_ssl.get_peer_certificate()
def make_csr(private_key_pem, domains, must_staple=False):
"""Generate a CSR containing a list of domains as subjectAltNames.
:param buffer private_key_pem: Private key, in PEM PKCS#8 format.
:param list domains: List of DNS names to include in subjectAltNames of CSR.
:param bool must_staple: Whether to include the TLS Feature extension (aka
OCSP Must Staple: https://tools.ietf.org/html/rfc7633).
:returns: buffer PEM-encoded Certificate Signing Request.
"""
private_key = crypto.load_privatekey(
crypto.FILETYPE_PEM, private_key_pem)
csr = crypto.X509Req()
extensions = [
crypto.X509Extension(
b'subjectAltName',
critical=False,
value=', '.join('DNS:' + d for d in domains).encode('ascii')
),
]
if must_staple:
extensions.append(crypto.X509Extension(
b"1.3.6.1.5.5.7.1.24",
critical=False,
value=b"DER:30:03:02:01:05"))
csr.add_extensions(extensions)
csr.set_pubkey(private_key)
csr.set_version(2)
csr.sign(private_key, 'sha256')
return crypto.dump_certificate_request(
crypto.FILETYPE_PEM, csr)
def _pyopenssl_cert_or_req_all_names(loaded_cert_or_req):
common_name = loaded_cert_or_req.get_subject().CN
sans = _pyopenssl_cert_or_req_san(loaded_cert_or_req)
if common_name is None:
return sans
return [common_name] + [d for d in sans if d != common_name]
def _pyopenssl_cert_or_req_san(cert_or_req):
"""Get Subject Alternative Names from certificate or CSR using pyOpenSSL.
.. todo:: Implement directly in PyOpenSSL!
.. note:: Although this is `acme` internal API, it is used by
`letsencrypt`.
:param cert_or_req: Certificate or CSR.
:type cert_or_req: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`.
:returns: A list of Subject Alternative Names.
:rtype: `list` of `unicode`
"""
# This function finds SANs by dumping the certificate/CSR to text and
# searching for "X509v3 Subject Alternative Name" in the text. This method
# is used to support PyOpenSSL version 0.13 where the
# `_subjectAltNameString` and `get_extensions` methods are not available
# for CSRs.
# constants based on PyOpenSSL certificate/CSR text dump
part_separator = ":"
parts_separator = ", "
prefix = "DNS" + part_separator
if isinstance(cert_or_req, crypto.X509):
# pylint: disable=line-too-long
func = crypto.dump_certificate # type: Union[Callable[[int, crypto.X509Req], bytes], Callable[[int, crypto.X509], bytes]]
else:
func = crypto.dump_certificate_request
text = func(crypto.FILETYPE_TEXT, cert_or_req).decode("utf-8")
# WARNING: this function does not support multiple SANs extensions.
# Multiple X509v3 extensions of the same type is disallowed by RFC 5280.
match = re.search(r"X509v3 Subject Alternative Name:(?: critical)?\s*(.*)", text)
# WARNING: this function assumes that no SAN can include
# parts_separator, hence the split!
sans_parts = [] if match is None else match.group(1).split(parts_separator)
return [part.split(part_separator)[1]
for part in sans_parts if part.startswith(prefix)]
def gen_ss_cert(key, domains, not_before=None,
validity=(7 * 24 * 60 * 60), force_san=True):
"""Generate new self-signed certificate.
:type domains: `list` of `unicode`
:param OpenSSL.crypto.PKey key:
:param bool force_san:
If more than one domain is provided, all of the domains are put into
``subjectAltName`` X.509 extension and first domain is set as the
subject CN. If only one domain is provided no ``subjectAltName``
extension is used, unless `force_san` is ``True``.
"""
assert domains, "Must provide one or more hostnames for the cert."
cert = crypto.X509()
cert.set_serial_number(int(binascii.hexlify(os.urandom(16)), 16))
cert.set_version(2)
extensions = [
crypto.X509Extension(
b"basicConstraints", True, b"CA:TRUE, pathlen:0"),
]
cert.get_subject().CN = domains[0]
# TODO: what to put into cert.get_subject()?
cert.set_issuer(cert.get_subject())
if force_san or len(domains) > 1:
extensions.append(crypto.X509Extension(
b"subjectAltName",
critical=False,
value=b", ".join(b"DNS:" + d.encode() for d in domains)
))
cert.add_extensions(extensions)
cert.gmtime_adj_notBefore(0 if not_before is None else not_before)
cert.gmtime_adj_notAfter(validity)
cert.set_pubkey(key)
cert.sign(key, "sha256")
return cert
def dump_pyopenssl_chain(chain, filetype=crypto.FILETYPE_PEM):
"""Dump certificate chain into a bundle.
:param list chain: List of `OpenSSL.crypto.X509` (or wrapped in
:class:`josepy.util.ComparableX509`).
:returns: certificate chain bundle
:rtype: bytes
"""
# XXX: returns empty string when no chain is available, which
# shuts up RenewableCert, but might not be the best solution...
def _dump_cert(cert):
if isinstance(cert, jose.ComparableX509):
# pylint: disable=protected-access
cert = cert.wrapped
return crypto.dump_certificate(filetype, cert)
# assumes that OpenSSL.crypto.dump_certificate includes ending
# newline character
return b"".join(_dump_cert(cert) for cert in chain)

View File

@@ -0,0 +1,267 @@
"""Tests for acme.crypto_util."""
import itertools
import socket
import threading
import time
import unittest
import six
from six.moves import socketserver #type: ignore # pylint: disable=import-error
import josepy as jose
import OpenSSL
from acme import errors
from acme import test_util
from acme.magic_typing import List # pylint: disable=unused-import, no-name-in-module
class SSLSocketAndProbeSNITest(unittest.TestCase):
"""Tests for acme.crypto_util.SSLSocket/probe_sni."""
def setUp(self):
self.cert = test_util.load_comparable_cert('rsa2048_cert.pem')
key = test_util.load_pyopenssl_private_key('rsa2048_key.pem')
# pylint: disable=protected-access
certs = {b'foo': (key, self.cert.wrapped)}
from acme.crypto_util import SSLSocket
class _TestServer(socketserver.TCPServer):
# six.moves.* | pylint: disable=attribute-defined-outside-init,no-init
def server_bind(self): # pylint: disable=missing-docstring
self.socket = SSLSocket(socket.socket(), certs=certs)
socketserver.TCPServer.server_bind(self)
self.server = _TestServer(('', 0), socketserver.BaseRequestHandler)
self.port = self.server.socket.getsockname()[1]
self.server_thread = threading.Thread(
# pylint: disable=no-member
target=self.server.handle_request)
def tearDown(self):
if self.server_thread.is_alive():
# The thread may have already terminated.
self.server_thread.join() # pragma: no cover
def _probe(self, name):
from acme.crypto_util import probe_sni
return jose.ComparableX509(probe_sni(
name, host='127.0.0.1', port=self.port))
def _start_server(self):
self.server_thread.start()
time.sleep(1) # TODO: avoid race conditions in other way
def test_probe_ok(self):
self._start_server()
self.assertEqual(self.cert, self._probe(b'foo'))
def test_probe_not_recognized_name(self):
self._start_server()
self.assertRaises(errors.Error, self._probe, b'bar')
def test_probe_connection_error(self):
# pylint has a hard time with six
self.server.server_close() # pylint: disable=no-member
original_timeout = socket.getdefaulttimeout()
try:
socket.setdefaulttimeout(1)
self.assertRaises(errors.Error, self._probe, b'bar')
finally:
socket.setdefaulttimeout(original_timeout)
class PyOpenSSLCertOrReqAllNamesTest(unittest.TestCase):
"""Test for acme.crypto_util._pyopenssl_cert_or_req_all_names."""
@classmethod
def _call(cls, loader, name):
# pylint: disable=protected-access
from acme.crypto_util import _pyopenssl_cert_or_req_all_names
return _pyopenssl_cert_or_req_all_names(loader(name))
def _call_cert(self, name):
return self._call(test_util.load_cert, name)
def test_cert_one_san_no_common(self):
self.assertEqual(self._call_cert('cert-nocn.der'),
['no-common-name.badssl.com'])
def test_cert_no_sans_yes_common(self):
self.assertEqual(self._call_cert('cert.pem'), ['example.com'])
def test_cert_two_sans_yes_common(self):
self.assertEqual(self._call_cert('cert-san.pem'),
['example.com', 'www.example.com'])
class PyOpenSSLCertOrReqSANTest(unittest.TestCase):
"""Test for acme.crypto_util._pyopenssl_cert_or_req_san."""
@classmethod
def _call(cls, loader, name):
# pylint: disable=protected-access
from acme.crypto_util import _pyopenssl_cert_or_req_san
return _pyopenssl_cert_or_req_san(loader(name))
@classmethod
def _get_idn_names(cls):
"""Returns expected names from '{cert,csr}-idnsans.pem'."""
chars = [six.unichr(i) for i in itertools.chain(range(0x3c3, 0x400),
range(0x641, 0x6fc),
range(0x1820, 0x1877))]
return [''.join(chars[i: i + 45]) + '.invalid'
for i in range(0, len(chars), 45)]
def _call_cert(self, name):
return self._call(test_util.load_cert, name)
def _call_csr(self, name):
return self._call(test_util.load_csr, name)
def test_cert_no_sans(self):
self.assertEqual(self._call_cert('cert.pem'), [])
def test_cert_two_sans(self):
self.assertEqual(self._call_cert('cert-san.pem'),
['example.com', 'www.example.com'])
def test_cert_hundred_sans(self):
self.assertEqual(self._call_cert('cert-100sans.pem'),
['example{0}.com'.format(i) for i in range(1, 101)])
def test_cert_idn_sans(self):
self.assertEqual(self._call_cert('cert-idnsans.pem'),
self._get_idn_names())
def test_csr_no_sans(self):
self.assertEqual(self._call_csr('csr-nosans.pem'), [])
def test_csr_one_san(self):
self.assertEqual(self._call_csr('csr.pem'), ['example.com'])
def test_csr_two_sans(self):
self.assertEqual(self._call_csr('csr-san.pem'),
['example.com', 'www.example.com'])
def test_csr_six_sans(self):
self.assertEqual(self._call_csr('csr-6sans.pem'),
['example.com', 'example.org', 'example.net',
'example.info', 'subdomain.example.com',
'other.subdomain.example.com'])
def test_csr_hundred_sans(self):
self.assertEqual(self._call_csr('csr-100sans.pem'),
['example{0}.com'.format(i) for i in range(1, 101)])
def test_csr_idn_sans(self):
self.assertEqual(self._call_csr('csr-idnsans.pem'),
self._get_idn_names())
def test_critical_san(self):
self.assertEqual(self._call_cert('critical-san.pem'),
['chicago-cubs.venafi.example', 'cubs.venafi.example'])
class RandomSnTest(unittest.TestCase):
"""Test for random certificate serial numbers."""
def setUp(self):
self.cert_count = 5
self.serial_num = [] # type: List[int]
self.key = OpenSSL.crypto.PKey()
self.key.generate_key(OpenSSL.crypto.TYPE_RSA, 2048)
def test_sn_collisions(self):
from acme.crypto_util import gen_ss_cert
for _ in range(self.cert_count):
cert = gen_ss_cert(self.key, ['dummy'], force_san=True)
self.serial_num.append(cert.get_serial_number())
self.assertTrue(len(set(self.serial_num)) > 1)
class MakeCSRTest(unittest.TestCase):
"""Test for standalone functions."""
@classmethod
def _call_with_key(cls, *args, **kwargs):
privkey = OpenSSL.crypto.PKey()
privkey.generate_key(OpenSSL.crypto.TYPE_RSA, 2048)
privkey_pem = OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, privkey)
from acme.crypto_util import make_csr
return make_csr(privkey_pem, *args, **kwargs)
def test_make_csr(self):
csr_pem = self._call_with_key(["a.example", "b.example"])
self.assertTrue(b'--BEGIN CERTIFICATE REQUEST--' in csr_pem)
self.assertTrue(b'--END CERTIFICATE REQUEST--' in csr_pem)
csr = OpenSSL.crypto.load_certificate_request(
OpenSSL.crypto.FILETYPE_PEM, csr_pem)
# In pyopenssl 0.13 (used with TOXENV=py27-oldest), csr objects don't
# have a get_extensions() method, so we skip this test if the method
# isn't available.
if hasattr(csr, 'get_extensions'):
self.assertEqual(len(csr.get_extensions()), 1)
self.assertEqual(csr.get_extensions()[0].get_data(),
OpenSSL.crypto.X509Extension(
b'subjectAltName',
critical=False,
value=b'DNS:a.example, DNS:b.example',
).get_data(),
)
def test_make_csr_must_staple(self):
csr_pem = self._call_with_key(["a.example"], must_staple=True)
csr = OpenSSL.crypto.load_certificate_request(
OpenSSL.crypto.FILETYPE_PEM, csr_pem)
# In pyopenssl 0.13 (used with TOXENV=py27-oldest), csr objects don't
# have a get_extensions() method, so we skip this test if the method
# isn't available.
if hasattr(csr, 'get_extensions'):
self.assertEqual(len(csr.get_extensions()), 2)
# NOTE: Ideally we would filter by the TLS Feature OID, but
# OpenSSL.crypto.X509Extension doesn't give us the extension's raw OID,
# and the shortname field is just "UNDEF"
must_staple_exts = [e for e in csr.get_extensions()
if e.get_data() == b"0\x03\x02\x01\x05"]
self.assertEqual(len(must_staple_exts), 1,
"Expected exactly one Must Staple extension")
class DumpPyopensslChainTest(unittest.TestCase):
"""Test for dump_pyopenssl_chain."""
@classmethod
def _call(cls, loaded):
# pylint: disable=protected-access
from acme.crypto_util import dump_pyopenssl_chain
return dump_pyopenssl_chain(loaded)
def test_dump_pyopenssl_chain(self):
names = ['cert.pem', 'cert-san.pem', 'cert-idnsans.pem']
loaded = [test_util.load_cert(name) for name in names]
length = sum(
len(OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert))
for cert in loaded)
self.assertEqual(len(self._call(loaded)), length)
def test_dump_pyopenssl_chain_wrapped(self):
names = ['cert.pem', 'cert-san.pem', 'cert-idnsans.pem']
loaded = [test_util.load_cert(name) for name in names]
wrap_func = jose.ComparableX509
wrapped = [wrap_func(cert) for cert in loaded]
dump_func = OpenSSL.crypto.dump_certificate
length = sum(len(dump_func(OpenSSL.crypto.FILETYPE_PEM, cert)) for cert in loaded)
self.assertEqual(len(self._call(wrapped)), length)
if __name__ == '__main__':
unittest.main() # pragma: no cover

View File

@@ -1,16 +1,5 @@
"""ACME errors."""
import datetime
import typing
from typing import Any
from typing import Mapping
from josepy import errors as jose_errors
import requests
# We import acme.messages only during type check to avoid circular dependencies. Type references
# to acme.message.* must be quoted to be lazily initialized and avoid compilation errors.
if typing.TYPE_CHECKING:
from acme import messages # pragma: no cover
class Error(Exception):
@@ -39,12 +28,12 @@ class NonceError(ClientError):
class BadNonce(NonceError):
"""Bad nonce error."""
def __init__(self, nonce: str, error: Exception, *args: Any) -> None:
super().__init__(*args)
def __init__(self, nonce, error, *args, **kwargs):
super(BadNonce, self).__init__(*args, **kwargs)
self.nonce = nonce
self.error = error
def __str__(self) -> str:
def __str__(self):
return 'Invalid nonce ({0!r}): {1}'.format(self.nonce, self.error)
@@ -55,14 +44,14 @@ class MissingNonce(NonceError):
Replay-Nonce header field in each successful response to a POST it
provides to a client (...)".
:ivar requests.Response ~.response: HTTP Response
:ivar requests.Response response: HTTP Response
"""
def __init__(self, response: requests.Response, *args: Any) -> None:
super().__init__(*args)
def __init__(self, response, *args, **kwargs):
super(MissingNonce, self).__init__(*args, **kwargs)
self.response = response
def __str__(self) -> str:
def __str__(self):
return ('Server {0} response did not include a replay '
'nonce, headers: {1} (This may be a service outage)'.format(
self.response.request.method, self.response.headers))
@@ -80,58 +69,41 @@ class PollError(ClientError):
to the most recently updated one
"""
def __init__(self, exhausted: set['messages.AuthorizationResource'],
updated: Mapping['messages.AuthorizationResource',
'messages.AuthorizationResource']
) -> None:
def __init__(self, exhausted, updated):
self.exhausted = exhausted
self.updated = updated
super().__init__()
super(PollError, self).__init__()
@property
def timeout(self) -> bool:
def timeout(self):
"""Was the error caused by timeout?"""
return bool(self.exhausted)
def __repr__(self) -> str:
def __repr__(self):
return '{0}(exhausted={1!r}, updated={2!r})'.format(
self.__class__.__name__, self.exhausted, self.updated)
class ValidationError(Error):
"""Error for authorization failures. Contains a list of authorization
resources, each of which is invalid and should have an error field.
"""
def __init__(self, failed_authzrs: list['messages.AuthorizationResource']) -> None:
def __init__(self, failed_authzrs):
self.failed_authzrs = failed_authzrs
super().__init__()
super(ValidationError, self).__init__()
def __str__(self) -> str:
msg = []
for authzr in self.failed_authzrs:
msg.append(f'Authorization for {authzr.body.identifier.value} ' \
'failed due to one or more failed challenges:')
for challenge in authzr.body.challenges:
msg.append(f' Challenge {challenge.chall.typ} failed ' \
f'with error {str(challenge.error)}')
return '\n'.join(msg)
class TimeoutError(Error): # pylint: disable=redefined-builtin
class TimeoutError(Error):
"""Error for when polling an authorization or an order times out."""
class IssuanceError(Error):
"""Error sent by the server after requesting issuance of a certificate."""
def __init__(self, error: 'messages.Error') -> None:
def __init__(self, error):
"""Initialize.
:param messages.Error error: The error provided by the server.
"""
self.error = error
super().__init__()
super(IssuanceError, self).__init__()
class ConflictError(ClientError):
"""Error for when the server returns a 409 (Conflict) HTTP status.
@@ -141,17 +113,10 @@ class ConflictError(ClientError):
Also used in V2 of the ACME client for the same purpose.
"""
def __init__(self, location: str) -> None:
def __init__(self, location):
self.location = location
super().__init__()
super(ConflictError, self).__init__()
class WildcardUnsupportedError(Error):
"""Error for when a wildcard is requested but is unsupported by ACME CA."""
class ARIError(ClientError):
"""An error occurred during an ARI request and we want to suggest a Retry-After time."""
def __init__(self, message: str, retry_after: datetime.datetime) -> None:
super().__init__(message, retry_after)
self.retry_after = retry_after

53
acme/acme/errors_test.py Normal file
View File

@@ -0,0 +1,53 @@
"""Tests for acme.errors."""
import unittest
import mock
class BadNonceTest(unittest.TestCase):
"""Tests for acme.errors.BadNonce."""
def setUp(self):
from acme.errors import BadNonce
self.error = BadNonce(nonce="xxx", error="error")
def test_str(self):
self.assertEqual("Invalid nonce ('xxx'): error", str(self.error))
class MissingNonceTest(unittest.TestCase):
"""Tests for acme.errors.MissingNonce."""
def setUp(self):
from acme.errors import MissingNonce
self.response = mock.MagicMock(headers={})
self.response.request.method = 'FOO'
self.error = MissingNonce(self.response)
def test_str(self):
self.assertTrue("FOO" in str(self.error))
self.assertTrue("{}" in str(self.error))
class PollErrorTest(unittest.TestCase):
"""Tests for acme.errors.PollError."""
def setUp(self):
from acme.errors import PollError
self.timeout = PollError(
exhausted=set([mock.sentinel.AR]),
updated={})
self.invalid = PollError(exhausted=set(), updated={
mock.sentinel.AR: mock.sentinel.AR2})
def test_timeout(self):
self.assertTrue(self.timeout.timeout)
self.assertFalse(self.invalid.timeout)
def test_repr(self):
self.assertEqual('PollError(exhausted=%s, updated={sentinel.AR: '
'sentinel.AR2})' % repr(set()), repr(self.invalid))
if __name__ == "__main__":
unittest.main() # pragma: no cover

View File

@@ -1,28 +1,27 @@
"""ACME JSON fields."""
import datetime
import logging
from typing import Any
import josepy as jose
import pyrfc3339
logger = logging.getLogger(__name__)
class Fixed(jose.Field):
"""Fixed field."""
def __init__(self, json_name: str, value: Any) -> None:
def __init__(self, json_name, value):
self.value = value
super().__init__(
super(Fixed, self).__init__(
json_name=json_name, default=value, omitempty=False)
def decode(self, value: Any) -> Any:
def decode(self, value):
if value != self.value:
raise jose.DeserializationError('Expected {0!r}'.format(self.value))
return self.value
def encode(self, value: Any) -> Any:
def encode(self, value):
if value != self.value:
logger.warning(
'Overriding fixed field (%s) with %r', self.json_name, value)
@@ -34,27 +33,33 @@ class RFC3339Field(jose.Field):
Handles decoding/encoding between RFC3339 strings and aware (not
naive) `datetime.datetime` objects
(e.g. ``datetime.datetime.now(datetime.timezone.utc)``).
(e.g. ``datetime.datetime.now(pytz.utc)``).
"""
@classmethod
def default_encoder(cls, value: datetime.datetime) -> str:
def default_encoder(cls, value):
return pyrfc3339.generate(value)
@classmethod
def default_decoder(cls, value: str) -> datetime.datetime:
def default_decoder(cls, value):
try:
return pyrfc3339.parse(value)
except ValueError as error:
raise jose.DeserializationError(error)
def fixed(json_name: str, value: Any) -> Any:
"""Generates a type-friendly Fixed field."""
return Fixed(json_name, value)
class Resource(jose.Field):
"""Resource MITM field."""
def __init__(self, resource_type, *args, **kwargs):
self.resource_type = resource_type
super(Resource, self).__init__(
'resource', default=resource_type, *args, **kwargs)
def rfc3339(json_name: str, omitempty: bool = False) -> Any:
"""Generates a type-friendly RFC3339 field."""
return RFC3339Field(json_name, omitempty=omitempty)
def decode(self, value):
if value != self.resource_type:
raise jose.DeserializationError(
'Wrong resource type: {0} instead of {1}'.format(
value, self.resource_type))
return value

72
acme/acme/fields_test.py Normal file
View File

@@ -0,0 +1,72 @@
"""Tests for acme.fields."""
import datetime
import unittest
import josepy as jose
import pytz
class FixedTest(unittest.TestCase):
"""Tests for acme.fields.Fixed."""
def setUp(self):
from acme.fields import Fixed
self.field = Fixed('name', 'x')
def test_decode(self):
self.assertEqual('x', self.field.decode('x'))
def test_decode_bad(self):
self.assertRaises(jose.DeserializationError, self.field.decode, 'y')
def test_encode(self):
self.assertEqual('x', self.field.encode('x'))
def test_encode_override(self):
self.assertEqual('y', self.field.encode('y'))
class RFC3339FieldTest(unittest.TestCase):
"""Tests for acme.fields.RFC3339Field."""
def setUp(self):
self.decoded = datetime.datetime(2015, 3, 27, tzinfo=pytz.utc)
self.encoded = '2015-03-27T00:00:00Z'
def test_default_encoder(self):
from acme.fields import RFC3339Field
self.assertEqual(
self.encoded, RFC3339Field.default_encoder(self.decoded))
def test_default_encoder_naive_fails(self):
from acme.fields import RFC3339Field
self.assertRaises(
ValueError, RFC3339Field.default_encoder, datetime.datetime.now())
def test_default_decoder(self):
from acme.fields import RFC3339Field
self.assertEqual(
self.decoded, RFC3339Field.default_decoder(self.encoded))
def test_default_decoder_raises_deserialization_error(self):
from acme.fields import RFC3339Field
self.assertRaises(
jose.DeserializationError, RFC3339Field.default_decoder, '')
class ResourceTest(unittest.TestCase):
"""Tests for acme.fields.Resource."""
def setUp(self):
from acme.fields import Resource
self.field = Resource('x')
def test_decode_good(self):
self.assertEqual('x', self.field.decode('x'))
def test_decode_wrong(self):
self.assertRaises(jose.DeserializationError, self.field.decode, 'y')
if __name__ == '__main__':
unittest.main() # pragma: no cover

53
acme/acme/jose_test.py Normal file
View File

@@ -0,0 +1,53 @@
"""Tests for acme.jose shim."""
import importlib
import unittest
class JoseTest(unittest.TestCase):
"""Tests for acme.jose shim."""
def _test_it(self, submodule, attribute):
if submodule:
acme_jose_path = 'acme.jose.' + submodule
josepy_path = 'josepy.' + submodule
else:
acme_jose_path = 'acme.jose'
josepy_path = 'josepy'
acme_jose_mod = importlib.import_module(acme_jose_path)
josepy_mod = importlib.import_module(josepy_path)
self.assertIs(acme_jose_mod, josepy_mod)
self.assertIs(getattr(acme_jose_mod, attribute), getattr(josepy_mod, attribute))
# We use the imports below with eval, but pylint doesn't
# understand that.
# pylint: disable=eval-used,unused-variable
import acme
import josepy
acme_jose_mod = eval(acme_jose_path)
josepy_mod = eval(josepy_path)
self.assertIs(acme_jose_mod, josepy_mod)
self.assertIs(getattr(acme_jose_mod, attribute), getattr(josepy_mod, attribute))
def test_top_level(self):
self._test_it('', 'RS512')
def test_submodules(self):
# This test ensures that the modules in josepy that were
# available at the time it was moved into its own package are
# available under acme.jose. Backwards compatibility with new
# modules or testing code is not maintained.
mods_and_attrs = [('b64', 'b64decode',),
('errors', 'Error',),
('interfaces', 'JSONDeSerializable',),
('json_util', 'Field',),
('jwa', 'HS256',),
('jwk', 'JWK',),
('jws', 'JWS',),
('util', 'ImmutableMap',),]
for mod, attr in mods_and_attrs:
self._test_it(mod, attr)
if __name__ == '__main__':
unittest.main() # pragma: no cover

View File

@@ -4,22 +4,18 @@ The JWS implementation in josepy only implements the base JOSE standard. In
order to support the new header fields defined in ACME, this module defines some
ACME-specific classes that layer on top of josepy.
"""
from typing import Optional
import josepy as jose
class Header(jose.Header):
"""ACME-specific JOSE Header. Implements nonce, kid, and url.
"""
nonce: Optional[bytes] = jose.field('nonce', omitempty=True, encoder=jose.encode_b64jose)
kid: Optional[str] = jose.field('kid', omitempty=True)
url: Optional[str] = jose.field('url', omitempty=True)
nonce = jose.Field('nonce', omitempty=True, encoder=jose.encode_b64jose)
kid = jose.Field('kid', omitempty=True)
url = jose.Field('url', omitempty=True)
# Mypy does not understand the josepy magic happening here, and falsely claims
# that nonce is redefined. Let's ignore the type check here.
@nonce.decoder # type: ignore[no-redef,union-attr]
def nonce(value: str) -> bytes: # type: ignore[misc] # pylint: disable=no-self-argument,missing-function-docstring
@nonce.decoder
def nonce(value): # pylint: disable=missing-docstring,no-self-argument
try:
return jose.decode_b64jose(value)
except jose.DeserializationError as error:
@@ -29,12 +25,12 @@ class Header(jose.Header):
class Signature(jose.Signature):
"""ACME-specific Signature. Uses ACME-specific Header for customer fields."""
__slots__ = jose.Signature._orig_slots # pylint: disable=protected-access,no-member
__slots__ = jose.Signature._orig_slots # pylint: disable=no-member
# TODO: decoder/encoder should accept cls? Otherwise, subclassing
# JSONObjectWithFields is tricky...
header_cls = Header
header: Header = jose.field(
header = jose.Field(
'header', omitempty=True, default=header_cls(),
decoder=header_cls.from_json)
@@ -44,16 +40,15 @@ class Signature(jose.Signature):
class JWS(jose.JWS):
"""ACME-specific JWS. Includes none, url, and kid in protected header."""
signature_cls = Signature
__slots__ = jose.JWS._orig_slots # pylint: disable=protected-access
__slots__ = jose.JWS._orig_slots # pylint: disable=no-member
@classmethod
# type: ignore[override] # pylint: disable=arguments-differ
def sign(cls, payload: bytes, key: jose.JWK, alg: jose.JWASignature, nonce: Optional[bytes],
url: Optional[str] = None, kid: Optional[str] = None) -> jose.JWS:
# pylint: disable=arguments-differ
def sign(cls, payload, key, alg, nonce, url=None, kid=None):
# Per ACME spec, jwk and kid are mutually exclusive, so only include a
# jwk field if kid is not provided.
include_jwk = kid is None
return super().sign(payload, key=key, alg=alg,
protect=frozenset(['nonce', 'url', 'kid', 'jwk', 'alg']),
nonce=nonce, url=url, kid=kid,
include_jwk=include_jwk)
return super(JWS, cls).sign(payload, key=key, alg=alg,
protect=frozenset(['nonce', 'url', 'kid', 'jwk', 'alg']),
nonce=nonce, url=url, kid=kid,
include_jwk=include_jwk)

View File

@@ -1,11 +1,10 @@
"""Tests for acme.jws."""
import sys
import unittest
import josepy as jose
import pytest
from acme._internal.tests import test_util
from acme import test_util
KEY = jose.JWKRSA.load(test_util.load_vector('rsa512_key.pem'))
@@ -21,15 +20,15 @@ class HeaderTest(unittest.TestCase):
except (ValueError, TypeError):
assert True
else:
pytest.fail("Exception from jose.b64decode wasn't raised") # pragma: no cover
assert False # pragma: no cover
def test_nonce_decoder(self):
from acme.jws import Header
nonce_field = Header._fields['nonce']
with pytest.raises(jose.DeserializationError):
nonce_field.decode(self.wrong_nonce)
assert b'foo' == nonce_field.decode(self.good_nonce)
self.assertRaises(
jose.DeserializationError, nonce_field.decode, self.wrong_nonce)
self.assertEqual(b'foo', nonce_field.decode(self.good_nonce))
class JWSTest(unittest.TestCase):
@@ -47,22 +46,22 @@ class JWSTest(unittest.TestCase):
jws = JWS.sign(payload=b'foo', key=self.privkey,
alg=jose.RS256, nonce=self.nonce,
url=self.url, kid=self.kid)
assert jws.signature.combined.nonce == self.nonce
assert jws.signature.combined.url == self.url
assert jws.signature.combined.kid == self.kid
assert jws.signature.combined.jwk is None
self.assertEqual(jws.signature.combined.nonce, self.nonce)
self.assertEqual(jws.signature.combined.url, self.url)
self.assertEqual(jws.signature.combined.kid, self.kid)
self.assertEqual(jws.signature.combined.jwk, None)
# TODO: check that nonce is in protected header
assert jws == JWS.from_json(jws.to_json())
self.assertEqual(jws, JWS.from_json(jws.to_json()))
def test_jwk_serialize(self):
from acme.jws import JWS
jws = JWS.sign(payload=b'foo', key=self.privkey,
alg=jose.RS256, nonce=self.nonce,
url=self.url)
assert jws.signature.combined.kid is None
assert jws.signature.combined.jwk == self.pubkey
self.assertEqual(jws.signature.combined.kid, None)
self.assertEqual(jws.signature.combined.jwk, self.pubkey)
if __name__ == '__main__':
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover
unittest.main() # pragma: no cover

16
acme/acme/magic_typing.py Normal file
View File

@@ -0,0 +1,16 @@
"""Shim class to not have to depend on typing module in prod."""
import sys
class TypingClass(object):
"""Ignore import errors by getting anything"""
def __getattr__(self, name):
return None
try:
# mypy doesn't respect modifying sys.modules
from typing import * # pylint: disable=wildcard-import, unused-wildcard-import
# pylint: disable=unused-import
from typing import Collection, IO # type: ignore
# pylint: enable=unused-import
except ImportError:
sys.modules[__name__] = TypingClass()

View File

@@ -0,0 +1,41 @@
"""Tests for acme.magic_typing."""
import sys
import unittest
import mock
class MagicTypingTest(unittest.TestCase):
"""Tests for acme.magic_typing."""
def test_import_success(self):
try:
import typing as temp_typing
except ImportError: # pragma: no cover
temp_typing = None # pragma: no cover
typing_class_mock = mock.MagicMock()
text_mock = mock.MagicMock()
typing_class_mock.Text = text_mock
sys.modules['typing'] = typing_class_mock
if 'acme.magic_typing' in sys.modules:
del sys.modules['acme.magic_typing'] # pragma: no cover
from acme.magic_typing import Text # pylint: disable=no-name-in-module
self.assertEqual(Text, text_mock)
del sys.modules['acme.magic_typing']
sys.modules['typing'] = temp_typing
def test_import_failure(self):
try:
import typing as temp_typing
except ImportError: # pragma: no cover
temp_typing = None # pragma: no cover
sys.modules['typing'] = None
if 'acme.magic_typing' in sys.modules:
del sys.modules['acme.magic_typing'] # pragma: no cover
from acme.magic_typing import Text # pylint: disable=no-name-in-module
self.assertTrue(Text is None)
del sys.modules['acme.magic_typing']
sys.modules['typing'] = temp_typing
if __name__ == '__main__':
unittest.main() # pragma: no cover

606
acme/acme/messages.py Normal file
View File

@@ -0,0 +1,606 @@
"""ACME protocol messages."""
import json
import six
try:
from collections.abc import Hashable # pylint: disable=no-name-in-module
except ImportError: # pragma: no cover
from collections import Hashable
import josepy as jose
from acme import challenges
from acme import errors
from acme import fields
from acme import util
from acme import jws
OLD_ERROR_PREFIX = "urn:acme:error:"
ERROR_PREFIX = "urn:ietf:params:acme:error:"
ERROR_CODES = {
'accountDoesNotExist': 'The request specified an account that does not exist',
'alreadyRevoked': 'The request specified a certificate to be revoked that has' \
' already been revoked',
'badCSR': 'The CSR is unacceptable (e.g., due to a short key)',
'badNonce': 'The client sent an unacceptable anti-replay nonce',
'badPublicKey': 'The JWS was signed by a public key the server does not support',
'badRevocationReason': 'The revocation reason provided is not allowed by the server',
'badSignatureAlgorithm': 'The JWS was signed with an algorithm the server does not support',
'caa': 'Certification Authority Authorization (CAA) records forbid the CA from issuing' \
' a certificate',
'compound': 'Specific error conditions are indicated in the "subproblems" array',
'connection': ('The server could not connect to the client to verify the'
' domain'),
'dns': 'There was a problem with a DNS query during identifier validation',
'dnssec': 'The server could not validate a DNSSEC signed domain',
'incorrectResponse': 'Response recieved didn\'t match the challenge\'s requirements',
# deprecate invalidEmail
'invalidEmail': 'The provided email for a registration was invalid',
'invalidContact': 'The provided contact URI was invalid',
'malformed': 'The request message was malformed',
'rejectedIdentifier': 'The server will not issue certificates for the identifier',
'orderNotReady': 'The request attempted to finalize an order that is not ready to be finalized',
'rateLimited': 'There were too many requests of a given type',
'serverInternal': 'The server experienced an internal error',
'tls': 'The server experienced a TLS error during domain verification',
'unauthorized': 'The client lacks sufficient authorization',
'unsupportedContact': 'A contact URL for an account used an unsupported protocol scheme',
'unknownHost': 'The server could not resolve a domain name',
'unsupportedIdentifier': 'An identifier is of an unsupported type',
'externalAccountRequired': 'The server requires external account binding',
}
ERROR_TYPE_DESCRIPTIONS = dict(
(ERROR_PREFIX + name, desc) for name, desc in ERROR_CODES.items())
ERROR_TYPE_DESCRIPTIONS.update(dict( # add errors with old prefix, deprecate me
(OLD_ERROR_PREFIX + name, desc) for name, desc in ERROR_CODES.items()))
def is_acme_error(err):
"""Check if argument is an ACME error."""
if isinstance(err, Error) and (err.typ is not None):
return (ERROR_PREFIX in err.typ) or (OLD_ERROR_PREFIX in err.typ)
return False
@six.python_2_unicode_compatible
class Error(jose.JSONObjectWithFields, errors.Error):
"""ACME error.
https://tools.ietf.org/html/draft-ietf-appsawg-http-problem-00
:ivar unicode typ:
:ivar unicode title:
:ivar unicode detail:
"""
typ = jose.Field('type', omitempty=True, default='about:blank')
title = jose.Field('title', omitempty=True)
detail = jose.Field('detail', omitempty=True)
@classmethod
def with_code(cls, code, **kwargs):
"""Create an Error instance with an ACME Error code.
:unicode code: An ACME error code, like 'dnssec'.
:kwargs: kwargs to pass to Error.
"""
if code not in ERROR_CODES:
raise ValueError("The supplied code: %s is not a known ACME error"
" code" % code)
typ = ERROR_PREFIX + code
return cls(typ=typ, **kwargs)
@property
def description(self):
"""Hardcoded error description based on its type.
:returns: Description if standard ACME error or ``None``.
:rtype: unicode
"""
return ERROR_TYPE_DESCRIPTIONS.get(self.typ)
@property
def code(self):
"""ACME error code.
Basically self.typ without the ERROR_PREFIX.
:returns: error code if standard ACME code or ``None``.
:rtype: unicode
"""
code = str(self.typ).split(':')[-1]
if code in ERROR_CODES:
return code
return None
def __str__(self):
return b' :: '.join(
part.encode('ascii', 'backslashreplace') for part in
(self.typ, self.description, self.detail, self.title)
if part is not None).decode()
class _Constant(jose.JSONDeSerializable, Hashable): # type: ignore
"""ACME constant."""
__slots__ = ('name',)
POSSIBLE_NAMES = NotImplemented
def __init__(self, name):
super(_Constant, self).__init__()
self.POSSIBLE_NAMES[name] = self # pylint: disable=unsupported-assignment-operation
self.name = name
def to_partial_json(self):
return self.name
@classmethod
def from_json(cls, jobj):
if jobj not in cls.POSSIBLE_NAMES: # pylint: disable=unsupported-membership-test
raise jose.DeserializationError(
'{0} not recognized'.format(cls.__name__))
return cls.POSSIBLE_NAMES[jobj] # pylint: disable=unsubscriptable-object
def __repr__(self):
return '{0}({1})'.format(self.__class__.__name__, self.name)
def __eq__(self, other):
return isinstance(other, type(self)) and other.name == self.name
def __hash__(self):
return hash((self.__class__, self.name))
def __ne__(self, other):
return not self == other
class Status(_Constant):
"""ACME "status" field."""
POSSIBLE_NAMES = {} # type: dict
STATUS_UNKNOWN = Status('unknown')
STATUS_PENDING = Status('pending')
STATUS_PROCESSING = Status('processing')
STATUS_VALID = Status('valid')
STATUS_INVALID = Status('invalid')
STATUS_REVOKED = Status('revoked')
STATUS_READY = Status('ready')
STATUS_DEACTIVATED = Status('deactivated')
class IdentifierType(_Constant):
"""ACME identifier type."""
POSSIBLE_NAMES = {} # type: dict
IDENTIFIER_FQDN = IdentifierType('dns') # IdentifierDNS in Boulder
class Identifier(jose.JSONObjectWithFields):
"""ACME identifier.
:ivar IdentifierType typ:
:ivar unicode value:
"""
typ = jose.Field('type', decoder=IdentifierType.from_json)
value = jose.Field('value')
class Directory(jose.JSONDeSerializable):
"""Directory."""
_REGISTERED_TYPES = {} # type: dict
class Meta(jose.JSONObjectWithFields):
"""Directory Meta."""
_terms_of_service = jose.Field('terms-of-service', omitempty=True)
_terms_of_service_v2 = jose.Field('termsOfService', omitempty=True)
website = jose.Field('website', omitempty=True)
caa_identities = jose.Field('caaIdentities', omitempty=True)
external_account_required = jose.Field('externalAccountRequired', omitempty=True)
def __init__(self, **kwargs):
kwargs = dict((self._internal_name(k), v) for k, v in kwargs.items())
super(Directory.Meta, self).__init__(**kwargs)
@property
def terms_of_service(self):
"""URL for the CA TOS"""
return self._terms_of_service or self._terms_of_service_v2
def __iter__(self):
# When iterating over fields, use the external name 'terms_of_service' instead of
# the internal '_terms_of_service'.
for name in super(Directory.Meta, self).__iter__():
yield name[1:] if name == '_terms_of_service' else name
def _internal_name(self, name):
return '_' + name if name == 'terms_of_service' else name
@classmethod
def _canon_key(cls, key):
return getattr(key, 'resource_type', key)
@classmethod
def register(cls, resource_body_cls):
"""Register resource."""
resource_type = resource_body_cls.resource_type
assert resource_type not in cls._REGISTERED_TYPES
cls._REGISTERED_TYPES[resource_type] = resource_body_cls
return resource_body_cls
def __init__(self, jobj):
canon_jobj = util.map_keys(jobj, self._canon_key)
# TODO: check that everything is an absolute URL; acme-spec is
# not clear on that
self._jobj = canon_jobj
def __getattr__(self, name):
try:
return self[name.replace('_', '-')]
except KeyError as error:
raise AttributeError(str(error) + ': ' + name)
def __getitem__(self, name):
try:
return self._jobj[self._canon_key(name)]
except KeyError:
raise KeyError('Directory field not found')
def to_partial_json(self):
return self._jobj
@classmethod
def from_json(cls, jobj):
jobj['meta'] = cls.Meta.from_json(jobj.pop('meta', {}))
return cls(jobj)
class Resource(jose.JSONObjectWithFields):
"""ACME Resource.
:ivar acme.messages.ResourceBody body: Resource body.
"""
body = jose.Field('body')
class ResourceWithURI(Resource):
"""ACME Resource with URI.
:ivar unicode uri: Location of the resource.
"""
uri = jose.Field('uri') # no ChallengeResource.uri
class ResourceBody(jose.JSONObjectWithFields):
"""ACME Resource Body."""
class ExternalAccountBinding(object):
"""ACME External Account Binding"""
@classmethod
def from_data(cls, account_public_key, kid, hmac_key, directory):
"""Create External Account Binding Resource from contact details, kid and hmac."""
key_json = json.dumps(account_public_key.to_partial_json()).encode()
decoded_hmac_key = jose.b64.b64decode(hmac_key)
url = directory["newAccount"]
eab = jws.JWS.sign(key_json, jose.jwk.JWKOct(key=decoded_hmac_key),
jose.jwa.HS256, None,
url, kid)
return eab.to_partial_json()
class Registration(ResourceBody):
"""Registration Resource Body.
:ivar josepy.jwk.JWK key: Public key.
:ivar tuple contact: Contact information following ACME spec,
`tuple` of `unicode`.
:ivar unicode agreement:
"""
# on new-reg key server ignores 'key' and populates it based on
# JWS.signature.combined.jwk
key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json)
contact = jose.Field('contact', omitempty=True, default=())
agreement = jose.Field('agreement', omitempty=True)
status = jose.Field('status', omitempty=True)
terms_of_service_agreed = jose.Field('termsOfServiceAgreed', omitempty=True)
only_return_existing = jose.Field('onlyReturnExisting', omitempty=True)
external_account_binding = jose.Field('externalAccountBinding', omitempty=True)
phone_prefix = 'tel:'
email_prefix = 'mailto:'
@classmethod
def from_data(cls, phone=None, email=None, external_account_binding=None, **kwargs):
"""Create registration resource from contact details."""
details = list(kwargs.pop('contact', ()))
if phone is not None:
details.append(cls.phone_prefix + phone)
if email is not None:
details.extend([cls.email_prefix + mail for mail in email.split(',')])
kwargs['contact'] = tuple(details)
if external_account_binding:
kwargs['external_account_binding'] = external_account_binding
return cls(**kwargs)
def _filter_contact(self, prefix):
return tuple(
detail[len(prefix):] for detail in self.contact # pylint: disable=not-an-iterable
if detail.startswith(prefix))
@property
def phones(self):
"""All phones found in the ``contact`` field."""
return self._filter_contact(self.phone_prefix)
@property
def emails(self):
"""All emails found in the ``contact`` field."""
return self._filter_contact(self.email_prefix)
@Directory.register
class NewRegistration(Registration):
"""New registration."""
resource_type = 'new-reg'
resource = fields.Resource(resource_type)
class UpdateRegistration(Registration):
"""Update registration."""
resource_type = 'reg'
resource = fields.Resource(resource_type)
class RegistrationResource(ResourceWithURI):
"""Registration Resource.
:ivar acme.messages.Registration body:
:ivar unicode new_authzr_uri: Deprecated. Do not use.
:ivar unicode terms_of_service: URL for the CA TOS.
"""
body = jose.Field('body', decoder=Registration.from_json)
new_authzr_uri = jose.Field('new_authzr_uri', omitempty=True)
terms_of_service = jose.Field('terms_of_service', omitempty=True)
class ChallengeBody(ResourceBody):
"""Challenge Resource Body.
.. todo::
Confusingly, this has a similar name to `.challenges.Challenge`,
as well as `.achallenges.AnnotatedChallenge`. Please use names
such as ``challb`` to distinguish instances of this class from
``achall``.
:ivar acme.challenges.Challenge: Wrapped challenge.
Conveniently, all challenge fields are proxied, i.e. you can
call ``challb.x`` to get ``challb.chall.x`` contents.
:ivar acme.messages.Status status:
:ivar datetime.datetime validated:
:ivar messages.Error error:
"""
__slots__ = ('chall',)
# ACMEv1 has a "uri" field in challenges. ACMEv2 has a "url" field. This
# challenge object supports either one, but should be accessed through the
# name "uri". In Client.answer_challenge, whichever one is set will be
# used.
_uri = jose.Field('uri', omitempty=True, default=None)
_url = jose.Field('url', omitempty=True, default=None)
status = jose.Field('status', decoder=Status.from_json,
omitempty=True, default=STATUS_PENDING)
validated = fields.RFC3339Field('validated', omitempty=True)
error = jose.Field('error', decoder=Error.from_json,
omitempty=True, default=None)
def __init__(self, **kwargs):
kwargs = dict((self._internal_name(k), v) for k, v in kwargs.items())
super(ChallengeBody, self).__init__(**kwargs)
def encode(self, name):
return super(ChallengeBody, self).encode(self._internal_name(name))
def to_partial_json(self):
jobj = super(ChallengeBody, self).to_partial_json()
jobj.update(self.chall.to_partial_json())
return jobj
@classmethod
def fields_from_json(cls, jobj):
jobj_fields = super(ChallengeBody, cls).fields_from_json(jobj)
jobj_fields['chall'] = challenges.Challenge.from_json(jobj)
return jobj_fields
@property
def uri(self):
"""The URL of this challenge."""
return self._url or self._uri
def __getattr__(self, name):
return getattr(self.chall, name)
def __iter__(self):
# When iterating over fields, use the external name 'uri' instead of
# the internal '_uri'.
for name in super(ChallengeBody, self).__iter__():
yield name[1:] if name == '_uri' else name
def _internal_name(self, name):
return '_' + name if name == 'uri' else name
class ChallengeResource(Resource):
"""Challenge Resource.
:ivar acme.messages.ChallengeBody body:
:ivar unicode authzr_uri: URI found in the 'up' ``Link`` header.
"""
body = jose.Field('body', decoder=ChallengeBody.from_json)
authzr_uri = jose.Field('authzr_uri')
@property
def uri(self):
"""The URL of the challenge body."""
# pylint: disable=function-redefined,no-member
return self.body.uri
class Authorization(ResourceBody):
"""Authorization Resource Body.
:ivar acme.messages.Identifier identifier:
:ivar list challenges: `list` of `.ChallengeBody`
:ivar tuple combinations: Challenge combinations (`tuple` of `tuple`
of `int`, as opposed to `list` of `list` from the spec).
:ivar acme.messages.Status status:
:ivar datetime.datetime expires:
"""
identifier = jose.Field('identifier', decoder=Identifier.from_json, omitempty=True)
challenges = jose.Field('challenges', omitempty=True)
combinations = jose.Field('combinations', omitempty=True)
status = jose.Field('status', omitempty=True, decoder=Status.from_json)
# TODO: 'expires' is allowed for Authorization Resources in
# general, but for Key Authorization '[t]he "expires" field MUST
# be absent'... then acme-spec gives example with 'expires'
# present... That's confusing!
expires = fields.RFC3339Field('expires', omitempty=True)
wildcard = jose.Field('wildcard', omitempty=True)
@challenges.decoder
def challenges(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(ChallengeBody.from_json(chall) for chall in value)
@property
def resolved_combinations(self):
"""Combinations with challenges instead of indices."""
return tuple(tuple(self.challenges[idx] for idx in combo)
for combo in self.combinations) # pylint: disable=not-an-iterable
@Directory.register
class NewAuthorization(Authorization):
"""New authorization."""
resource_type = 'new-authz'
resource = fields.Resource(resource_type)
class UpdateAuthorization(Authorization):
"""Update authorization."""
resource_type = 'authz'
resource = fields.Resource(resource_type)
class AuthorizationResource(ResourceWithURI):
"""Authorization Resource.
:ivar acme.messages.Authorization body:
:ivar unicode new_cert_uri: Deprecated. Do not use.
"""
body = jose.Field('body', decoder=Authorization.from_json)
new_cert_uri = jose.Field('new_cert_uri', omitempty=True)
@Directory.register
class CertificateRequest(jose.JSONObjectWithFields):
"""ACME new-cert request.
:ivar josepy.util.ComparableX509 csr:
`OpenSSL.crypto.X509Req` wrapped in `.ComparableX509`
"""
resource_type = 'new-cert'
resource = fields.Resource(resource_type)
csr = jose.Field('csr', decoder=jose.decode_csr, encoder=jose.encode_csr)
class CertificateResource(ResourceWithURI):
"""Certificate Resource.
:ivar josepy.util.ComparableX509 body:
`OpenSSL.crypto.X509` wrapped in `.ComparableX509`
:ivar unicode cert_chain_uri: URI found in the 'up' ``Link`` header
:ivar tuple authzrs: `tuple` of `AuthorizationResource`.
"""
cert_chain_uri = jose.Field('cert_chain_uri')
authzrs = jose.Field('authzrs')
@Directory.register
class Revocation(jose.JSONObjectWithFields):
"""Revocation message.
:ivar .ComparableX509 certificate: `OpenSSL.crypto.X509` wrapped in
`.ComparableX509`
"""
resource_type = 'revoke-cert'
resource = fields.Resource(resource_type)
certificate = jose.Field(
'certificate', decoder=jose.decode_cert, encoder=jose.encode_cert)
reason = jose.Field('reason')
class Order(ResourceBody):
"""Order Resource Body.
:ivar list of .Identifier: List of identifiers for the certificate.
:ivar acme.messages.Status status:
:ivar list of str authorizations: URLs of authorizations.
:ivar str certificate: URL to download certificate as a fullchain PEM.
:ivar str finalize: URL to POST to to request issuance once all
authorizations have "valid" status.
:ivar datetime.datetime expires: When the order expires.
:ivar .Error error: Any error that occurred during finalization, if applicable.
"""
identifiers = jose.Field('identifiers', omitempty=True)
status = jose.Field('status', decoder=Status.from_json,
omitempty=True)
authorizations = jose.Field('authorizations', omitempty=True)
certificate = jose.Field('certificate', omitempty=True)
finalize = jose.Field('finalize', omitempty=True)
expires = fields.RFC3339Field('expires', omitempty=True)
error = jose.Field('error', omitempty=True, decoder=Error.from_json)
@identifiers.decoder
def identifiers(value): # pylint: disable=missing-docstring,no-self-argument
return tuple(Identifier.from_json(identifier) for identifier in value)
class OrderResource(ResourceWithURI):
"""Order Resource.
:ivar acme.messages.Order body:
:ivar str csr_pem: The CSR this Order will be finalized with.
:ivar list of acme.messages.AuthorizationResource authorizations:
Fully-fetched AuthorizationResource objects.
:ivar str fullchain_pem: The fetched contents of the certificate URL
produced once the order was finalized, if it's present.
"""
body = jose.Field('body', decoder=Order.from_json)
csr_pem = jose.Field('csr_pem', omitempty=True)
authorizations = jose.Field('authorizations')
fullchain_pem = jose.Field('fullchain_pem', omitempty=True)
@Directory.register
class NewOrder(Order):
"""New order."""
resource_type = 'new-order'

View File

@@ -1,17 +1,16 @@
"""Tests for acme.messages."""
import sys
import json
import unittest
from unittest import mock
import josepy as jose
import pytest
import mock
from acme import challenges
from acme._internal.tests import test_util
from acme import test_util
from acme.magic_typing import Dict # pylint: disable=unused-import, no-name-in-module
CERT = test_util.load_cert('cert.der')
CSR = test_util.load_csr('csr.der')
CERT = test_util.load_comparable_cert('cert.der')
CSR = test_util.load_comparable_csr('csr.der')
KEY = test_util.load_rsa_private_key('rsa512_key.pem')
@@ -19,95 +18,65 @@ class ErrorTest(unittest.TestCase):
"""Tests for acme.messages.Error."""
def setUp(self):
from acme.messages import Error
from acme.messages import ERROR_PREFIX
from acme.messages import Identifier
from acme.messages import IDENTIFIER_FQDN
self.error = Error.with_code('malformed', detail='foo', title='title')
from acme.messages import Error, ERROR_PREFIX
self.error = Error(
detail='foo', typ=ERROR_PREFIX + 'malformed', title='title')
self.jobj = {
'detail': 'foo',
'title': 'some title',
'type': ERROR_PREFIX + 'malformed',
}
self.error_custom = Error(typ='custom', detail='bar')
self.identifier = Identifier(typ=IDENTIFIER_FQDN, value='example.com')
self.subproblem = Error.with_code('caa', detail='bar', title='title', identifier=self.identifier)
self.error_with_subproblems = Error.with_code('malformed', detail='foo', title='title', subproblems=[self.subproblem])
self.empty_error = Error()
self.jobj_custom = {'type': 'custom', 'detail': 'bar'}
def test_default_typ(self):
from acme.messages import Error
assert Error().typ == 'about:blank'
self.assertEqual(Error().typ, 'about:blank')
def test_from_json_empty(self):
from acme.messages import Error
assert Error() == Error.from_json('{}')
self.assertEqual(Error(), Error.from_json('{}'))
def test_from_json_hashable(self):
from acme.messages import Error
hash(Error.from_json(self.error.to_json()))
def test_from_json_with_subproblems(self):
from acme.messages import Error
parsed_error = Error.from_json(self.error_with_subproblems.to_json())
assert 1 == len(parsed_error.subproblems)
assert self.subproblem == parsed_error.subproblems[0]
def test_description(self):
assert 'The request message was malformed' == self.error.description
assert self.error_custom.description is None
self.assertEqual(
'The request message was malformed', self.error.description)
self.assertTrue(self.error_custom.description is None)
def test_code(self):
from acme.messages import Error
assert 'malformed' == self.error.code
assert self.error_custom.code is None
assert Error().code is None
self.assertEqual('malformed', self.error.code)
self.assertEqual(None, self.error_custom.code)
self.assertEqual(None, Error().code)
def test_is_acme_error(self):
from acme.messages import Error
from acme.messages import is_acme_error
assert is_acme_error(self.error)
assert not is_acme_error(self.error_custom)
assert not is_acme_error(Error())
assert not is_acme_error(self.empty_error)
assert not is_acme_error("must pet all the {dogs|rabbits}")
self.assertTrue(is_acme_error(self.error))
self.assertFalse(is_acme_error(self.error_custom))
self.assertFalse(is_acme_error(self.empty_error))
self.assertFalse(is_acme_error("must pet all the {dogs|rabbits}"))
def test_unicode_error(self):
from acme.messages import Error
from acme.messages import is_acme_error
arabic_error = Error.with_code(
'malformed', detail=u'\u0639\u062f\u0627\u0644\u0629', title='title')
assert is_acme_error(arabic_error)
from acme.messages import Error, ERROR_PREFIX, is_acme_error
arabic_error = Error(
detail=u'\u0639\u062f\u0627\u0644\u0629', typ=ERROR_PREFIX + 'malformed',
title='title')
self.assertTrue(is_acme_error(arabic_error))
def test_with_code(self):
from acme.messages import Error
from acme.messages import is_acme_error
assert is_acme_error(Error.with_code('badCSR'))
with pytest.raises(ValueError):
Error.with_code('not an ACME error code')
from acme.messages import Error, is_acme_error
self.assertTrue(is_acme_error(Error.with_code('badCSR')))
self.assertRaises(ValueError, Error.with_code, 'not an ACME error code')
def test_str(self):
assert str(self.error) == \
u"{0.typ} :: {0.description} :: {0.detail} :: {0.title}" \
.format(self.error)
assert str(self.error_with_subproblems) == \
(u"{0.typ} :: {0.description} :: {0.detail} :: {0.title}\n"+
u"Problem for {1.identifier.value}: {1.typ} :: {1.description} :: {1.detail} :: {1.title}").format(
self.error_with_subproblems, self.subproblem)
# this test is based on a minimal reproduction of a contextmanager/immutable
# exception related error: https://github.com/python/cpython/issues/99856
def test_setting_traceback(self):
assert self.error_custom.__traceback__ is None
try:
1/0
except ZeroDivisionError as e:
self.error_custom.__traceback__ = e.__traceback__
assert self.error_custom.__traceback__ is not None
self.assertEqual(
str(self.error),
u"{0.typ} :: {0.description} :: {0.detail} :: {0.title}"
.format(self.error))
class ConstantTest(unittest.TestCase):
@@ -117,35 +86,35 @@ class ConstantTest(unittest.TestCase):
from acme.messages import _Constant
class MockConstant(_Constant): # pylint: disable=missing-docstring
POSSIBLE_NAMES: dict = {}
POSSIBLE_NAMES = {} # type: Dict
self.MockConstant = MockConstant # pylint: disable=invalid-name
self.const_a = MockConstant('a')
self.const_b = MockConstant('b')
def test_to_partial_json(self):
assert 'a' == self.const_a.to_partial_json()
assert 'b' == self.const_b.to_partial_json()
self.assertEqual('a', self.const_a.to_partial_json())
self.assertEqual('b', self.const_b.to_partial_json())
def test_from_json(self):
assert self.const_a == self.MockConstant.from_json('a')
with pytest.raises(jose.DeserializationError):
self.MockConstant.from_json('c')
self.assertEqual(self.const_a, self.MockConstant.from_json('a'))
self.assertRaises(
jose.DeserializationError, self.MockConstant.from_json, 'c')
def test_from_json_hashable(self):
hash(self.MockConstant.from_json('a'))
def test_repr(self):
assert 'MockConstant(a)' == repr(self.const_a)
assert 'MockConstant(b)' == repr(self.const_b)
self.assertEqual('MockConstant(a)', repr(self.const_a))
self.assertEqual('MockConstant(b)', repr(self.const_b))
def test_equality(self):
const_a_prime = self.MockConstant('a')
assert self.const_a != self.const_b
assert self.const_a == const_a_prime
self.assertFalse(self.const_a == self.const_b)
self.assertTrue(self.const_a == const_a_prime)
assert self.const_a != self.const_b
assert self.const_a == const_a_prime
self.assertTrue(self.const_a != self.const_b)
self.assertFalse(self.const_a != const_a_prime)
class DirectoryTest(unittest.TestCase):
@@ -154,16 +123,12 @@ class DirectoryTest(unittest.TestCase):
def setUp(self):
from acme.messages import Directory
self.dir = Directory({
'newReg': 'reg',
'newCert': 'cert',
'new-reg': 'reg',
mock.MagicMock(resource_type='new-cert'): 'cert',
'meta': Directory.Meta(
terms_of_service='https://example.com/acme/terms',
website='https://www.example.com/',
caa_identities=['example.com'],
profiles={
"example": "some profile",
"other example": "a different profile"
}
),
})
@@ -172,33 +137,30 @@ class DirectoryTest(unittest.TestCase):
Directory({'foo': 'bar'})
def test_getitem(self):
assert 'reg' == self.dir['newReg']
self.assertEqual('reg', self.dir['new-reg'])
from acme.messages import NewRegistration
self.assertEqual('reg', self.dir[NewRegistration])
self.assertEqual('reg', self.dir[NewRegistration()])
def test_getitem_fails_with_key_error(self):
with pytest.raises(KeyError):
self.dir.__getitem__('foo')
self.assertRaises(KeyError, self.dir.__getitem__, 'foo')
def test_getattr(self):
assert 'reg' == self.dir.newReg
self.assertEqual('reg', self.dir.new_reg)
def test_getattr_fails_with_attribute_error(self):
with pytest.raises(AttributeError):
self.dir.__getattr__('foo')
self.assertRaises(AttributeError, self.dir.__getattr__, 'foo')
def test_to_json(self):
assert self.dir.to_json() == {
'newReg': 'reg',
'newCert': 'cert',
self.assertEqual(self.dir.to_json(), {
'new-reg': 'reg',
'new-cert': 'cert',
'meta': {
'termsOfService': 'https://example.com/acme/terms',
'terms-of-service': 'https://example.com/acme/terms',
'website': 'https://www.example.com/',
'caaIdentities': ['example.com'],
'profiles': {
'example': 'some profile',
'other example': 'a different profile'
}
},
}
})
def test_from_json_deserialization_unknown_key_success(self): # pylint: disable=no-self-use
from acme.messages import Directory
@@ -209,7 +171,7 @@ class DirectoryTest(unittest.TestCase):
for k in self.dir.meta:
if k == 'terms_of_service':
result = self.dir.meta[k] == 'https://example.com/acme/terms'
assert result
self.assertTrue(result)
class ExternalAccountBindingTest(unittest.TestCase):
@@ -218,43 +180,17 @@ class ExternalAccountBindingTest(unittest.TestCase):
self.key = jose.jwk.JWKRSA(key=KEY.public_key())
self.kid = "kid-for-testing"
self.hmac_key = "hmac-key-for-testing"
self.hmac_alg = "HS256"
self.dir = Directory({
'newAccount': 'http://url/acme/new-account',
})
def test_from_data(self):
from acme.messages import ExternalAccountBinding
eab = ExternalAccountBinding.from_data(self.key, self.kid, self.hmac_key, self.dir, self.hmac_alg)
eab = ExternalAccountBinding.from_data(self.key, self.kid, self.hmac_key, self.dir)
assert len(eab) == 3
assert sorted(eab.keys()) == sorted(['protected', 'payload', 'signature'])
self.assertEqual(len(eab), 3)
self.assertEqual(sorted(eab.keys()), sorted(['protected', 'payload', 'signature']))
def test_from_data_invalid_hmac_alg(self):
from acme.messages import ExternalAccountBinding
invalid_alg = "HS9999"
with pytest.raises(ValueError) as info:
ExternalAccountBinding.from_data(self.key, self.kid, self.hmac_key, self.dir, invalid_alg)
assert "Invalid value for hmac_alg" in str(info.value)
def test_from_data_default_hmac_alg(self):
from acme.messages import ExternalAccountBinding
eab_default = ExternalAccountBinding.from_data(self.key, self.kid, self.hmac_key, self.dir)
assert len(eab_default) == 3
assert sorted(eab_default.keys()) == sorted(['protected', 'payload', 'signature'])
eab_explicit = ExternalAccountBinding.from_data(
self.key, self.kid, self.hmac_key, self.dir, "HS256"
)
assert eab_default == eab_explicit
protected_default = json.loads(
jose.b64.b64decode(eab_default['protected']).decode()
)
assert protected_default['alg'] == 'HS256'
class RegistrationTest(unittest.TestCase):
"""Tests for acme.messages.Registration."""
@@ -282,60 +218,44 @@ class RegistrationTest(unittest.TestCase):
def test_from_data(self):
from acme.messages import Registration
reg = Registration.from_data(phone='1234', email='admin@foo.com')
assert reg.contact == (
self.assertEqual(reg.contact, (
'tel:1234',
'mailto:admin@foo.com',
)
))
def test_new_registration_from_data_with_eab(self):
from acme.messages import Directory
from acme.messages import ExternalAccountBinding
from acme.messages import NewRegistration
from acme.messages import NewRegistration, ExternalAccountBinding, Directory
key = jose.jwk.JWKRSA(key=KEY.public_key())
kid = "kid-for-testing"
hmac_key = "hmac-key-for-testing"
hmac_alg = "HS256"
directory = Directory({
'newAccount': 'http://url/acme/new-account',
})
eab = ExternalAccountBinding.from_data(key, kid, hmac_key, directory, hmac_alg)
eab = ExternalAccountBinding.from_data(key, kid, hmac_key, directory)
reg = NewRegistration.from_data(email='admin@foo.com', external_account_binding=eab)
assert reg.contact == (
self.assertEqual(reg.contact, (
'mailto:admin@foo.com',
)
assert sorted(reg.external_account_binding.keys()) == \
sorted(['protected', 'payload', 'signature'])
))
self.assertEqual(sorted(reg.external_account_binding.keys()),
sorted(['protected', 'payload', 'signature']))
def test_phones(self):
assert ('1234',) == self.reg.phones
self.assertEqual(('1234',), self.reg.phones)
def test_emails(self):
assert ('admin@foo.com',) == self.reg.emails
self.assertEqual(('admin@foo.com',), self.reg.emails)
def test_to_partial_json(self):
assert self.jobj_to == self.reg.to_partial_json()
self.assertEqual(self.jobj_to, self.reg.to_partial_json())
def test_from_json(self):
from acme.messages import Registration
assert self.reg == Registration.from_json(self.jobj_from)
self.assertEqual(self.reg, Registration.from_json(self.jobj_from))
def test_from_json_hashable(self):
from acme.messages import Registration
hash(Registration.from_json(self.jobj_from))
def test_default_not_transmitted(self):
from acme.messages import NewRegistration
empty_new_reg = NewRegistration()
new_reg_with_contact = NewRegistration(contact=())
assert empty_new_reg.contact == ()
assert new_reg_with_contact.contact == ()
assert 'contact' not in empty_new_reg.to_partial_json()
assert 'contact' not in empty_new_reg.fields_to_partial_json()
assert 'contact' in new_reg_with_contact.to_partial_json()
assert 'contact' in new_reg_with_contact.fields_to_partial_json()
class UpdateRegistrationTest(unittest.TestCase):
"""Tests for acme.messages.UpdateRegistration."""
@@ -343,8 +263,9 @@ class UpdateRegistrationTest(unittest.TestCase):
def test_empty(self):
from acme.messages import UpdateRegistration
jstring = '{"resource": "reg"}'
assert '{}' == UpdateRegistration().json_dumps()
assert UpdateRegistration() == UpdateRegistration.json_loads(jstring)
self.assertEqual(jstring, UpdateRegistration().json_dumps())
self.assertEqual(
UpdateRegistration(), UpdateRegistration.json_loads(jstring))
class RegistrationResourceTest(unittest.TestCase):
@@ -357,11 +278,11 @@ class RegistrationResourceTest(unittest.TestCase):
terms_of_service=mock.sentinel.terms_of_service)
def test_to_partial_json(self):
assert self.regr.to_json() == {
self.assertEqual(self.regr.to_json(), {
'body': mock.sentinel.body,
'uri': mock.sentinel.uri,
'terms_of_service': mock.sentinel.terms_of_service,
}
})
class ChallengeResourceTest(unittest.TestCase):
@@ -369,8 +290,8 @@ class ChallengeResourceTest(unittest.TestCase):
def test_uri(self):
from acme.messages import ChallengeResource
assert 'http://challb' == ChallengeResource(body=mock.MagicMock(
uri='http://challb'), authzr_uri='http://authz').uri
self.assertEqual('http://challb', ChallengeResource(body=mock.MagicMock(
uri='http://challb'), authzr_uri='http://authz').uri)
class ChallengeBodyTest(unittest.TestCase):
@@ -384,13 +305,14 @@ class ChallengeBodyTest(unittest.TestCase):
from acme.messages import Error
from acme.messages import STATUS_INVALID
self.status = STATUS_INVALID
error = Error.with_code('serverInternal', detail='Unable to communicate with DNS server')
error = Error(typ='urn:ietf:params:acme:error:serverInternal',
detail='Unable to communicate with DNS server')
self.challb = ChallengeBody(
uri='http://challb', chall=self.chall, status=self.status,
error=error)
self.jobj_to = {
'url': 'http://challb',
'uri': 'http://challb',
'status': self.status,
'type': 'dns',
'token': 'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA',
@@ -404,22 +326,22 @@ class ChallengeBodyTest(unittest.TestCase):
}
def test_encode(self):
assert self.challb.encode('uri') == self.challb.uri
self.assertEqual(self.challb.encode('uri'), self.challb.uri)
def test_to_partial_json(self):
assert self.jobj_to == self.challb.to_partial_json()
self.assertEqual(self.jobj_to, self.challb.to_partial_json())
def test_from_json(self):
from acme.messages import ChallengeBody
assert self.challb == ChallengeBody.from_json(self.jobj_from)
self.assertEqual(self.challb, ChallengeBody.from_json(self.jobj_from))
def test_from_json_hashable(self):
from acme.messages import ChallengeBody
hash(ChallengeBody.from_json(self.jobj_from))
def test_proxy(self):
assert jose.b64decode(
'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA') == self.challb.token
self.assertEqual(jose.b64decode(
'evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA'), self.challb.token)
class AuthorizationTest(unittest.TestCase):
@@ -437,17 +359,20 @@ class AuthorizationTest(unittest.TestCase):
chall=challenges.DNS(
token=b'DGyRejmCefe7v4NfDGDKfA')),
)
combinations = ((0,), (1,))
from acme.messages import Authorization
from acme.messages import Identifier
from acme.messages import IDENTIFIER_FQDN
identifier = Identifier(typ=IDENTIFIER_FQDN, value='example.com')
self.authz = Authorization(
identifier=identifier, challenges=self.challbs)
identifier=identifier, combinations=combinations,
challenges=self.challbs)
self.jobj_from = {
'identifier': identifier.to_json(),
'challenges': [challb.to_json() for challb in self.challbs],
'combinations': combinations,
}
def test_from_json(self):
@@ -458,6 +383,12 @@ class AuthorizationTest(unittest.TestCase):
from acme.messages import Authorization
hash(Authorization.from_json(self.jobj_from))
def test_resolved_combinations(self):
self.assertEqual(self.authz.resolved_combinations, (
(self.challbs[0],),
(self.challbs[1],),
))
class AuthorizationResourceTest(unittest.TestCase):
"""Tests for acme.messages.AuthorizationResource."""
@@ -467,7 +398,7 @@ class AuthorizationResourceTest(unittest.TestCase):
authzr = AuthorizationResource(
uri=mock.sentinel.uri,
body=mock.sentinel.body)
assert isinstance(authzr, jose.JSONDeSerializable)
self.assertTrue(isinstance(authzr, jose.JSONDeSerializable))
class CertificateRequestTest(unittest.TestCase):
@@ -478,9 +409,10 @@ class CertificateRequestTest(unittest.TestCase):
self.req = CertificateRequest(csr=CSR)
def test_json_de_serializable(self):
assert isinstance(self.req, jose.JSONDeSerializable)
self.assertTrue(isinstance(self.req, jose.JSONDeSerializable))
from acme.messages import CertificateRequest
assert self.req == CertificateRequest.from_json(self.req.to_json())
self.assertEqual(
self.req, CertificateRequest.from_json(self.req.to_json()))
class CertificateResourceTest(unittest.TestCase):
@@ -493,9 +425,10 @@ class CertificateResourceTest(unittest.TestCase):
cert_chain_uri=mock.sentinel.cert_chain_uri)
def test_json_de_serializable(self):
assert isinstance(self.certr, jose.JSONDeSerializable)
self.assertTrue(isinstance(self.certr, jose.JSONDeSerializable))
from acme.messages import CertificateResource
assert self.certr == CertificateResource.from_json(self.certr.to_json())
self.assertEqual(
self.certr, CertificateResource.from_json(self.certr.to_json()))
class RevocationTest(unittest.TestCase):
@@ -519,79 +452,25 @@ class OrderResourceTest(unittest.TestCase):
body=mock.sentinel.body, uri=mock.sentinel.uri)
def test_to_partial_json(self):
assert self.regr.to_json() == {
self.assertEqual(self.regr.to_json(), {
'body': mock.sentinel.body,
'uri': mock.sentinel.uri,
'authorizations': None,
}
def test_json_de_serializable(self):
from acme.messages import ChallengeBody
from acme.messages import STATUS_PENDING
challbs = (
ChallengeBody(
uri='http://challb1', status=STATUS_PENDING,
chall=challenges.HTTP01(token=b'IlirfxKKXAsHtmzK29Pj8A')),
ChallengeBody(uri='http://challb2', status=STATUS_PENDING,
chall=challenges.DNS(
token=b'DGyRejmCefe7v4NfDGDKfA')),
)
from acme.messages import Authorization
from acme.messages import AuthorizationResource
from acme.messages import Identifier
from acme.messages import IDENTIFIER_FQDN
identifier = Identifier(typ=IDENTIFIER_FQDN, value='example.com')
authz = AuthorizationResource(uri="http://authz1",
body=Authorization(
identifier=identifier,
challenges=challbs))
from acme.messages import Order
body = Order(identifiers=(identifier,), status=STATUS_PENDING,
authorizations=tuple(challb.uri for challb in challbs))
from acme.messages import OrderResource
orderr = OrderResource(uri="http://order1", body=body,
csr_pem=b'test blob',
authorizations=(authz,))
self.assertEqual(orderr,
OrderResource.from_json(orderr.to_json()))
})
class NewOrderTest(unittest.TestCase):
"""Tests for acme.messages.NewOrder."""
def setUp(self):
from acme.messages import NewOrder
self.order = NewOrder(
self.reg = NewOrder(
identifiers=mock.sentinel.identifiers)
def test_to_partial_json(self):
assert self.order.to_json() == {
self.assertEqual(self.reg.to_json(), {
'identifiers': mock.sentinel.identifiers,
}
def test_default_profile_empty(self):
assert self.order.profile is None
def test_non_empty_profile(self):
from acme.messages import NewOrder
order = NewOrder(identifiers=mock.sentinel.identifiers, profile='example')
assert order.to_json() == {
'identifiers': mock.sentinel.identifiers,
'profile': 'example',
}
class JWSPayloadRFC8555Compliant(unittest.TestCase):
"""Test for RFC8555 compliance of JWS generated from resources/challenges"""
def test_message_payload(self):
from acme.messages import NewAuthorization
new_order = NewAuthorization()
jobj = new_order.json_dumps(indent=2).encode()
# RFC8555 states that JWS bodies must not have a resource field.
assert jobj == b'{}'
})
if __name__ == '__main__':
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover
unittest.main() # pragma: no cover

View File

@@ -1,30 +1,58 @@
"""Support for standalone client challenge solvers. """
from __future__ import annotations
import collections
import functools
import http.client as http_client
import http.server as BaseHTTPServer
import logging
import socket
import socketserver
import threading
from typing import Any
from typing import Optional
from six.moves import BaseHTTPServer # type: ignore # pylint: disable=import-error
from six.moves import http_client # pylint: disable=import-error
from six.moves import socketserver # type: ignore # pylint: disable=import-error
from acme import challenges
from acme import crypto_util
from acme.magic_typing import List # pylint: disable=unused-import, no-name-in-module
logger = logging.getLogger(__name__)
# six.moves.* | pylint: disable=no-member,attribute-defined-outside-init
# pylint: disable=no-init
class ACMEServerMixin:
class TLSServer(socketserver.TCPServer):
"""Generic TLS Server."""
def __init__(self, *args, **kwargs):
self.ipv6 = kwargs.pop("ipv6", False)
if self.ipv6:
self.address_family = socket.AF_INET6
else:
self.address_family = socket.AF_INET
self.certs = kwargs.pop("certs", {})
self.method = kwargs.pop(
# pylint: disable=protected-access
"method", crypto_util._DEFAULT_SSL_METHOD)
self.allow_reuse_address = kwargs.pop("allow_reuse_address", True)
socketserver.TCPServer.__init__(self, *args, **kwargs)
def _wrap_sock(self):
self.socket = crypto_util.SSLSocket(
self.socket, certs=self.certs, method=self.method)
def server_bind(self): # pylint: disable=missing-docstring
self._wrap_sock()
return socketserver.TCPServer.server_bind(self)
class ACMEServerMixin: # pylint: disable=old-style-class
"""ACME server common settings mixin."""
# TODO: c.f. #858
server_version = "ACME client standalone challenge solver"
allow_reuse_address = True
class BaseDualNetworkedServers:
class BaseDualNetworkedServers(object):
"""Base class for a pair of IPv6 and IPv4 servers that tries to do everything
it's asked for both servers, but where failures in one server don't
affect the other.
@@ -32,14 +60,10 @@ class BaseDualNetworkedServers:
If two servers are instantiated, they will serve on the same port.
"""
def __init__(self, ServerClass: type[socketserver.TCPServer], server_address: tuple[str, int],
*remaining_args: Any, **kwargs: Any) -> None:
def __init__(self, ServerClass, server_address, *remaining_args, **kwargs):
port = server_address[1]
self.threads: list[threading.Thread] = []
self.servers: list[socketserver.BaseServer] = []
# Preserve socket error for re-raising, if no servers can be started
last_socket_err: Optional[socket.error] = None
self.threads = [] # type: List[threading.Thread]
self.servers = [] # type: List[ACMEServerMixin]
# Must try True first.
# Ubuntu, for example, will fail to bind to IPv4 if we've already bound
@@ -57,8 +81,7 @@ class BaseDualNetworkedServers:
logger.debug(
"Successfully bound to %s:%s using %s", new_address[0],
new_address[1], "IPv6" if ip_version else "IPv4")
except OSError as e:
last_socket_err = e
except socket.error:
if self.servers:
# Already bound using IPv6.
logger.debug(
@@ -77,24 +100,22 @@ class BaseDualNetworkedServers:
# bind to the same port for both servers.
port = server.socket.getsockname()[1]
if not self.servers:
if last_socket_err:
raise last_socket_err
else: # pragma: no cover
raise OSError("Could not bind to IPv4 or IPv6.")
raise socket.error("Could not bind to IPv4 or IPv6.")
def serve_forever(self) -> None:
def serve_forever(self):
"""Wraps socketserver.TCPServer.serve_forever"""
for server in self.servers:
thread = threading.Thread(
# pylint: disable=no-member
target=server.serve_forever)
thread.start()
self.threads.append(thread)
def getsocknames(self) -> list[tuple[str, int]]:
def getsocknames(self):
"""Wraps socketserver.TCPServer.socket.getsockname"""
return [server.socket.getsockname() for server in self.servers]
def shutdown_and_server_close(self) -> None:
def shutdown_and_server_close(self):
"""Wraps socketserver.TCPServer.shutdown, socketserver.TCPServer.server_close, and
threading.Thread.join"""
for server in self.servers:
@@ -108,32 +129,30 @@ class BaseDualNetworkedServers:
class HTTPServer(BaseHTTPServer.HTTPServer):
"""Generic HTTP Server."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
def __init__(self, *args, **kwargs):
self.ipv6 = kwargs.pop("ipv6", False)
if self.ipv6:
self.address_family = socket.AF_INET6
else:
self.address_family = socket.AF_INET
super().__init__(*args, **kwargs)
BaseHTTPServer.HTTPServer.__init__(self, *args, **kwargs)
class HTTP01Server(HTTPServer, ACMEServerMixin):
"""HTTP01 Server."""
def __init__(self, server_address: tuple[str, int],
resources: set[HTTP01RequestHandler.HTTP01Resource],
ipv6: bool = False, timeout: int = 30) -> None:
super().__init__(
server_address, HTTP01RequestHandler.partial_init(
simple_http_resources=resources, timeout=timeout), ipv6=ipv6)
def __init__(self, server_address, resources, ipv6=False):
HTTPServer.__init__(
self, server_address, HTTP01RequestHandler.partial_init(
simple_http_resources=resources), ipv6=ipv6)
class HTTP01DualNetworkedServers(BaseDualNetworkedServers):
"""HTTP01Server Wrapper. Tries everything for both. Failures for one don't
affect the other."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(HTTP01Server, *args, **kwargs)
def __init__(self, *args, **kwargs):
BaseDualNetworkedServers.__init__(self, HTTP01Server, *args, **kwargs)
class HTTP01RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
@@ -148,37 +167,20 @@ class HTTP01RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
HTTP01Resource = collections.namedtuple(
"HTTP01Resource", "chall response validation")
def __init__(self, *args: Any, **kwargs: Any) -> None:
def __init__(self, *args, **kwargs):
self.simple_http_resources = kwargs.pop("simple_http_resources", set())
self._timeout = kwargs.pop('timeout', 30)
super().__init__(*args, **kwargs)
self.server: HTTP01Server
BaseHTTPServer.BaseHTTPRequestHandler.__init__(self, *args, **kwargs)
# In parent class BaseHTTPRequestHandler, 'timeout' is a class-level property but we
# need to define its value during the initialization phase in HTTP01RequestHandler.
# However MyPy does not appreciate that we dynamically shadow a class-level property
# with an instance-level property (eg. self.timeout = ... in __init__()). So to make
# everyone happy, we statically redefine 'timeout' as a method property, and set the
# timeout value in a new internal instance-level property _timeout.
@property
def timeout(self) -> int: # type: ignore[override]
"""
The default timeout this server should apply to requests.
:return: timeout to apply
:rtype: int
"""
return self._timeout
def log_message(self, format: str, *args: Any) -> None: # pylint: disable=redefined-builtin
def log_message(self, format, *args): # pylint: disable=redefined-builtin
"""Log arbitrary message."""
logger.debug("%s - - %s", self.client_address[0], format % args)
def handle(self) -> None:
def handle(self):
"""Handle request."""
self.log_message("Incoming request")
BaseHTTPServer.BaseHTTPRequestHandler.handle(self)
def do_GET(self) -> None: # pylint: disable=invalid-name,missing-function-docstring
def do_GET(self): # pylint: disable=invalid-name,missing-docstring
if self.path == "/":
self.handle_index()
elif self.path.startswith("/" + challenges.HTTP01.URI_ROOT_PATH):
@@ -186,21 +188,21 @@ class HTTP01RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
else:
self.handle_404()
def handle_index(self) -> None:
def handle_index(self):
"""Handle index page."""
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(self.server.server_version.encode())
def handle_404(self) -> None:
def handle_404(self):
"""Handler 404 Not Found errors."""
self.send_response(http_client.NOT_FOUND, message="Not Found")
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(b"404")
def handle_simple_http_resource(self) -> None:
def handle_simple_http_resource(self):
"""Handle HTTP01 provisioned resources."""
for resource in self.simple_http_resources:
if resource.chall.path == self.path:
@@ -216,8 +218,7 @@ class HTTP01RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
self.path)
@classmethod
def partial_init(cls, simple_http_resources: set[HTTP01RequestHandler.HTTP01Resource],
timeout: int) -> functools.partial[HTTP01RequestHandler]:
def partial_init(cls, simple_http_resources):
"""Partially initialize this handler.
This is useful because `socketserver.BaseServer` takes
@@ -226,5 +227,4 @@ class HTTP01RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
"""
return functools.partial(
cls, simple_http_resources=simple_http_resources,
timeout=timeout)
cls, simple_http_resources=simple_http_resources)

View File

@@ -1,18 +1,36 @@
"""Tests for acme.standalone."""
import http.client as http_client
import socket
import socketserver
import sys
import threading
import unittest
from unittest import mock
from six.moves import http_client # pylint: disable=import-error
from six.moves import socketserver # type: ignore # pylint: disable=import-error
import josepy as jose
import pytest
import mock
import requests
from acme import challenges
from acme._internal.tests import test_util
from acme import test_util
from acme.magic_typing import Set # pylint: disable=unused-import, no-name-in-module
class TLSServerTest(unittest.TestCase):
"""Tests for acme.standalone.TLSServer."""
def test_bind(self): # pylint: disable=no-self-use
from acme.standalone import TLSServer
server = TLSServer(
('', 0), socketserver.BaseRequestHandler, bind_and_activate=True)
server.server_close()
def test_ipv6(self):
if socket.has_ipv6:
from acme.standalone import TLSServer
server = TLSServer(
('', 0), socketserver.BaseRequestHandler, bind_and_activate=True, ipv6=True)
server.server_close()
class HTTP01ServerTest(unittest.TestCase):
@@ -22,7 +40,7 @@ class HTTP01ServerTest(unittest.TestCase):
def setUp(self):
self.account_key = jose.JWK.load(
test_util.load_vector('rsa1024_key.pem'))
self.resources: set = set()
self.resources = set() # type: Set
from acme.standalone import HTTP01Server
self.server = HTTP01Server(('', 0), resources=self.resources)
@@ -34,18 +52,18 @@ class HTTP01ServerTest(unittest.TestCase):
def tearDown(self):
self.server.shutdown()
self.thread.join()
self.server.server_close()
def test_index(self):
response = requests.get(
'http://localhost:{0}'.format(self.port), verify=False)
assert response.text == 'ACME client standalone challenge solver'
assert response.ok
self.assertEqual(
response.text, 'ACME client standalone challenge solver')
self.assertTrue(response.ok)
def test_404(self):
response = requests.get(
'http://localhost:{0}/foo'.format(self.port), verify=False)
assert response.status_code == http_client.NOT_FOUND
self.assertEqual(response.status_code, http_client.NOT_FOUND)
def _test_http01(self, add):
chall = challenges.HTTP01(token=(b'x' * 16))
@@ -61,37 +79,16 @@ class HTTP01ServerTest(unittest.TestCase):
port=self.port)
def test_http01_found(self):
assert self._test_http01(add=True)
self.assertTrue(self._test_http01(add=True))
def test_http01_not_found(self):
assert not self._test_http01(add=False)
def test_timely_shutdown(self):
from acme.standalone import HTTP01Server
with HTTP01Server(('', 0), resources=set(), timeout=0.05) as server:
server_thread = threading.Thread(target=server.serve_forever)
server_thread.start()
with socket.socket() as client:
client.connect(('localhost', server.socket.getsockname()[1]))
stop_thread = threading.Thread(target=server.shutdown)
stop_thread.start()
server_thread.join(5.)
is_hung = server_thread.is_alive()
try:
client.shutdown(socket.SHUT_RDWR)
except: # pragma: no cover, pylint: disable=bare-except
# may raise error because socket could already be closed
pass
assert not is_hung, 'Server shutdown should not be hung'
self.assertFalse(self._test_http01(add=False))
class BaseDualNetworkedServersTest(unittest.TestCase):
"""Test for acme.standalone.BaseDualNetworkedServers."""
class SingleProtocolServer(socketserver.TCPServer):
"""Server that only serves on a single protocol. FreeBSD has this behavior for AF_INET6."""
def __init__(self, *args, **kwargs):
@@ -101,7 +98,7 @@ class BaseDualNetworkedServersTest(unittest.TestCase):
kwargs["bind_and_activate"] = False
else:
self.address_family = socket.AF_INET
super().__init__(*args, **kwargs)
socketserver.TCPServer.__init__(self, *args, **kwargs)
if ipv6:
# NB: On Windows, socket.IPPROTO_IPV6 constant may be missing.
# We use the corresponding value (41) instead.
@@ -116,18 +113,12 @@ class BaseDualNetworkedServersTest(unittest.TestCase):
@mock.patch("socket.socket.bind")
def test_fail_to_bind(self, mock_bind):
from errno import EADDRINUSE
mock_bind.side_effect = socket.error
from acme.standalone import BaseDualNetworkedServers
mock_bind.side_effect = OSError(EADDRINUSE, "Fake addr in use error")
with pytest.raises(socket.error) as exc_info:
BaseDualNetworkedServers(
BaseDualNetworkedServersTest.SingleProtocolServer,
('', 0), socketserver.BaseRequestHandler)
assert exc_info.value.errno == EADDRINUSE
self.assertRaises(socket.error, BaseDualNetworkedServers,
BaseDualNetworkedServersTest.SingleProtocolServer,
('', 0),
socketserver.BaseRequestHandler)
def test_ports_equal(self):
from acme.standalone import BaseDualNetworkedServers
@@ -141,19 +132,18 @@ class BaseDualNetworkedServersTest(unittest.TestCase):
for sockname in socknames:
port = sockname[1]
if prev_port:
assert prev_port == port
self.assertEqual(prev_port, port)
prev_port = port
for server in servers.servers:
server.server_close()
class HTTP01DualNetworkedServersTest(unittest.TestCase):
"""Tests for acme.standalone.HTTP01DualNetworkedServers."""
def setUp(self):
self.account_key = jose.JWK.load(
test_util.load_vector('rsa1024_key.pem'))
self.resources: set = set()
self.resources = set() # type: Set
from acme.standalone import HTTP01DualNetworkedServers
self.servers = HTTP01DualNetworkedServers(('', 0), resources=self.resources)
@@ -167,13 +157,14 @@ class HTTP01DualNetworkedServersTest(unittest.TestCase):
def test_index(self):
response = requests.get(
'http://localhost:{0}'.format(self.port), verify=False)
assert response.text == 'ACME client standalone challenge solver'
assert response.ok
self.assertEqual(
response.text, 'ACME client standalone challenge solver')
self.assertTrue(response.ok)
def test_404(self):
response = requests.get(
'http://localhost:{0}/foo'.format(self.port), verify=False)
assert response.status_code == http_client.NOT_FOUND
self.assertEqual(response.status_code, http_client.NOT_FOUND)
def _test_http01(self, add):
chall = challenges.HTTP01(token=(b'x' * 16))
@@ -189,11 +180,11 @@ class HTTP01DualNetworkedServersTest(unittest.TestCase):
port=self.port)
def test_http01_found(self):
assert self._test_http01(add=True)
self.assertTrue(self._test_http01(add=True))
def test_http01_not_found(self):
assert not self._test_http01(add=False)
self.assertFalse(self._test_http01(add=False))
if __name__ == "__main__":
sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover
unittest.main() # pragma: no cover

68
acme/acme/test_util.py Normal file
View File

@@ -0,0 +1,68 @@
"""Test utilities.
.. warning:: This module is not part of the public API.
"""
import os
import pkg_resources
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import josepy as jose
from OpenSSL import crypto
def load_vector(*names):
"""Load contents of a test vector."""
# luckily, resource_string opens file in binary mode
return pkg_resources.resource_string(
__name__, os.path.join('testdata', *names))
def _guess_loader(filename, loader_pem, loader_der):
_, ext = os.path.splitext(filename)
if ext.lower() == '.pem':
return loader_pem
elif ext.lower() == '.der':
return loader_der
else: # pragma: no cover
raise ValueError("Loader could not be recognized based on extension")
def load_cert(*names):
"""Load certificate."""
loader = _guess_loader(
names[-1], crypto.FILETYPE_PEM, crypto.FILETYPE_ASN1)
return crypto.load_certificate(loader, load_vector(*names))
def load_comparable_cert(*names):
"""Load ComparableX509 cert."""
return jose.ComparableX509(load_cert(*names))
def load_csr(*names):
"""Load certificate request."""
loader = _guess_loader(
names[-1], crypto.FILETYPE_PEM, crypto.FILETYPE_ASN1)
return crypto.load_certificate_request(loader, load_vector(*names))
def load_comparable_csr(*names):
"""Load ComparableX509 certificate request."""
return jose.ComparableX509(load_csr(*names))
def load_rsa_private_key(*names):
"""Load RSA private key."""
loader = _guess_loader(names[-1], serialization.load_pem_private_key,
serialization.load_der_private_key)
return jose.ComparableRSAKey(loader(
load_vector(*names), password=None, backend=default_backend()))
def load_pyopenssl_private_key(*names):
"""Load pyOpenSSL private key."""
loader = _guess_loader(
names[-1], crypto.FILETYPE_PEM, crypto.FILETYPE_ASN1)
return crypto.load_privatekey(loader, load_vector(*names))

15
acme/acme/testdata/README vendored Normal file
View File

@@ -0,0 +1,15 @@
In order for acme.test_util._guess_loader to work properly, make sure
to use appropriate extension for vector filenames: .pem for PEM and
.der for DER.
The following command has been used to generate test keys:
for x in 256 512 1024 2048; do openssl genrsa -out rsa${k}_key.pem $k; done
and for the CSR:
openssl req -key rsa2048_key.pem -new -subj '/CN=example.com' -outform DER > csr.der
and for the certificate:
openssl req -key rsa2047_key.pem -new -subj '/CN=example.com' -x509 -outform DER > cert.der

7
acme/acme/util.py Normal file
View File

@@ -0,0 +1,7 @@
"""ACME utilities."""
import six
def map_keys(dikt, func):
"""Map dictionary keys."""
return dict((func(key), value) for key, value in six.iteritems(dikt))

16
acme/acme/util_test.py Normal file
View File

@@ -0,0 +1,16 @@
"""Tests for acme.util."""
import unittest
class MapKeysTest(unittest.TestCase):
"""Tests for acme.util.map_keys."""
def test_it(self):
from acme.util import map_keys
self.assertEqual({'a': 'b', 'c': 'd'},
map_keys({'a': 'b', 'c': 'd'}, lambda key: key))
self.assertEqual({2: 2, 4: 4}, map_keys({1: 2, 3: 4}, lambda x: x + 1))
if __name__ == '__main__':
unittest.main() # pragma: no cover

View File

@@ -9,7 +9,7 @@ BUILDDIR = _build
# User-friendly check for sphinx-build
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from https://www.sphinx-doc.org/)
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
endif
# Internal variables.

View File

@@ -1,5 +0,0 @@
Crypto_util
-----------
.. automodule:: acme.crypto_util
:members:

View File

@@ -1,5 +0,0 @@
JWS
---
.. automodule:: acme.jws
:members:

View File

@@ -1,5 +0,0 @@
Util
----
.. automodule:: acme.util
:members:

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