././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1770112840.758825 certbot_dns_hetzner_cloud-1.0.5/0000755000175100017510000000000015140343511016413 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1770112840.7528253 certbot_dns_hetzner_cloud-1.0.5/.github/0000755000175100017510000000000015140343511017753 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1770112840.754825 certbot_dns_hetzner_cloud-1.0.5/.github/workflows/0000755000175100017510000000000015140343511022010 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/.github/workflows/build-release.yml0000644000175100017510000000231315140343503025250 0ustar00runnerrunnername: Build Release on: push: tags: - 'v[0-9]+.[0-9]+.[0-9]+' permissions: contents: read jobs: test-with-coverage: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.12' cache: 'pip' - name: "Install Dependencies" run: | python -m pip install -U pip pip install -e .[dev] - name: "Test with coverage" run: | pytest --cov --cov-branch --cov-report=xml - name: "Upload coverage" uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} release-build: needs: test-with-coverage runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.12' cache: 'pip' - name: "Build release" run: | python -m pip install build python -m build - name: "Upload release artifacts" uses: actions/upload-artifact@v4 with: name: release-dists path: dist/** if-no-files-found: error retention-days: 1././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/.github/workflows/publish-github.yml0000644000175100017510000000511015140343503025457 0ustar00runnerrunnername: "Publish to GitHub" on: workflow_run: workflows: [ "Build Release" ] types: [ completed ] permissions: contents: write actions: read jobs: github-release: if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest steps: - name: "Download release artifacts" uses: actions/download-artifact@v4 with: name: release-dists path: dist/ repository: ${{ github.repository }} run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} - name: "Extract Version from Tag" id: extract_tag uses: actions/github-script@v7 with: script: | const {owner, repo} = context.repo; // 1) Run-Details des auslösenden Workflow-Runs holen const runId = context.payload.workflow_run?.id ?? ${{ github.event.workflow_run.id }}; const { data: run } = await github.rest.actions.getWorkflowRun({ owner, repo, run_id: runId }); const headSha = run.head_sha; if (!headSha) { throw new Error("No head_sha on workflow_run."); } // 2) Tags holen und den Tag finden, dessen Commit auf head_sha zeigt // Hinweis: listTags -> t.commit.sha ist der Commit, auf den das Tag zeigt const allTags = []; let page = 1; while (true) { const { data } = await github.rest.repos.listTags({ owner, repo, per_page: 100, page }); if (!data.length) break; allTags.push(...data); page++; } const match = allTags.find(t => t.commit?.sha === headSha); if (!match) { // Fallback: Wenn der Upstream-Run ein Tag-Push war, ist head_branch oft schon der Tag-Name if (run.head_branch && /^v?\d+\.\d+\.\d+/.test(run.head_branch)) { core.setOutput("tag_name", run.head_branch); } else { throw new Error("Could not determine tag for head_sha " + headSha); } } else { core.setOutput("tag_name", match.name); } - name: "Create GitHub Release" uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.extract_tag.outputs.tag_name }} name: ${{ steps.extract_tag.outputs.tag_name }} generate_release_notes: true files: | dist/*.whl dist/*.tar.gz dist/SHA256SUMS.txt ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/.github/workflows/publish-pipy.yml0000644000175100017510000000133515140343503025163 0ustar00runnerrunnername: Publish to PyPI on: workflow_run: workflows: ["Build Release"] types: [completed] permissions: contents: read actions: read id-token: write jobs: publish: if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest environment: pypi steps: - name: "Download release artifacts" uses: actions/download-artifact@v4 with: name: release-dists path: dist/ repository: ${{ github.repository }} run-id: ${{ github.event.workflow_run.id }} github-token: ${{ github.token }} - name: "Publish to PyPI" uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: dist/././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/.github/workflows/publish-snapcraft.yml0000644000175100017510000000176115140343503026166 0ustar00runnerrunnername: "Publish to Snapcraft" on: workflow_run: workflows: [ "Build Release" ] types: [ completed ] permissions: contents: read jobs: snap: runs-on: ubuntu-latest env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_TOKEN }} steps: - name: "Checkout" uses: actions/checkout@v4 with: fetch-depth: 0 - name: "Install Snapcraft" run: | sudo snap install snapcraft --classic snapcraft --version - name: "Build Snap" id: build uses: snapcore/action-build@v1 - name: "Upload Snap Artifact" uses: actions/upload-artifact@v4 with: name: built-snap path: ${{ steps.build.outputs.snap }} - name: "Publish Snap to Store" uses: snapcore/action-publish@v1 env: SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_TOKEN }} with: snap: ${{ steps.build.outputs.snap }} release: stable ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/.gitignore0000644000175100017510000002727415140343503020420 0ustar00runnerrunner### VisualStudioCode template .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json !.vscode/*.code-snippets # Local History for Visual Studio Code .history/ # Built Visual Studio Code Extensions *.vsix ### PyCharm template # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff .idea/**/workspace.xml .idea/**/tasks.xml .idea/**/usage.statistics.xml .idea/**/dictionaries .idea/**/shelf # AWS User-specific .idea/**/aws.xml # Generated files .idea/**/contentModel.xml # Sensitive or high-churn files .idea/**/dataSources/ .idea/**/dataSources.ids .idea/**/dataSources.local.xml .idea/**/sqlDataSources.xml .idea/**/dynamic.xml .idea/**/uiDesigner.xml .idea/**/dbnavigator.xml # Gradle .idea/**/gradle.xml .idea/**/libraries # Gradle and Maven with auto-import # When using Gradle or Maven with auto-import, you should exclude module files, # since they will be recreated, and may cause churn. Uncomment if using # auto-import. # .idea/artifacts # .idea/compiler.xml # .idea/jarRepositories.xml # .idea/modules.xml # .idea/*.iml # .idea/modules # *.iml # *.ipr # CMake cmake-build-*/ # Mongo Explorer plugin .idea/**/mongoSettings.xml # File-based project format *.iws # IntelliJ out/ # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Cursive Clojure plugin .idea/replstate.xml # SonarLint plugin .idea/sonarlint/ # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties # Editor-based Rest Client .idea/httpRequests # Android studio 3.1+ serialized cache file .idea/caches/build_file_checksums.ser ### VisualStudio template ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore # User-specific files *.rsuser *.suo *.user *.userosscache *.sln.docstates # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs # Mono auto generated files mono_crash.* # Build results [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ x64/ x86/ [Ww][Ii][Nn]32/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ bld/ [Bb]in/ [Oo]bj/ [Ll]og/ [Ll]ogs/ # Visual Studio 2015/2017 cache/options directory .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ # Visual Studio 2017 auto generated files Generated\ Files/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* # NUnit *.VisualState.xml TestResult.xml nunit-*.xml # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ dlldata.c # Benchmark Results BenchmarkDotNet.Artifacts/ # .NET Core project.lock.json project.fragment.lock.json artifacts/ # ASP.NET Scaffolding ScaffoldingReadMe.txt # StyleCop StyleCopReport.xml # Files built by Visual Studio *_i.c *_p.c *_h.h *.ilk *.meta *.obj *.iobj *.pch *.pdb *.ipdb *.pgc *.pgd *.rsp *.sbr *.tlb *.tli *.tlh *.tmp *.tmp_proj *_wpftmp.csproj *.log *.tlog *.vspscc *.vssscc .builds *.pidb *.svclog *.scc # Chutzpah Test files _Chutzpah* # Visual C++ cache files ipch/ *.aps *.ncb *.opendb *.opensdf *.sdf *.cachefile *.VC.db *.VC.VC.opendb # Visual Studio profiler *.psess *.vsp *.vspx *.sap # Visual Studio Trace Files *.e2e # TFS 2012 Local Workspace $tf/ # Guidance Automation Toolkit *.gpState # ReSharper is a .NET coding add-in _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user # TeamCity is a build add-in _TeamCity* # DotCover is a Code Coverage Tool *.dotCover # AxoCover is a Code Coverage Tool .axoCover/* !.axoCover/settings.json # Coverlet is a free, cross platform Code Coverage Tool coverage*.json coverage*.xml coverage*.info # Visual Studio code coverage results *.coverage *.coveragexml # NCrunch _NCrunch_* .*crunch*.local.xml nCrunchTemp_* # MightyMoose *.mm.* AutoTest.Net/ # Web workbench (sass) .sass-cache/ # Installshield output folder [Ee]xpress/ # DocProject is a documentation generator add-in DocProject/buildhelp/ DocProject/Help/*.HxT DocProject/Help/*.HxC DocProject/Help/*.hhc DocProject/Help/*.hhk DocProject/Help/*.hhp DocProject/Help/Html2 DocProject/Help/html # Click-Once directory publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml # Note: Comment the next line if you want to checkin your web deploy settings, # but database connection strings (with potential passwords) will be unencrypted *.pubxml *.publishproj # Microsoft Azure Web App publish settings. Comment the next line if you want to # checkin your Azure Web App publish settings, but sensitive information contained # in these scripts will be unencrypted PublishScripts/ # NuGet Packages *.nupkg # NuGet Symbol Packages *.snupkg # The packages folder can be ignored because of Package Restore **/[Pp]ackages/* # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ # Uncomment if necessary however generally it will be regenerated when needed #!**/[Pp]ackages/repositories.config # NuGet v3's project.json files produces more ignorable files *.nuget.props *.nuget.targets # Microsoft Azure Build Output csx/ *.build.csdef # Microsoft Azure Emulator ecf/ rcf/ # Windows Store app package directories and files AppPackages/ BundleArtifacts/ Package.StoreAssociation.xml _pkginfo.txt *.appx *.appxbundle *.appxupload # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache !?*.[Cc]ache/ # Others ClientBin/ ~$* *~ *.dbmdl *.dbproj.schemaview *.jfm *.pfx *.publishsettings orleans.codegen.cs # Including strong name files can present a security risk # (https://github.com/github/gitignore/pull/2483#issue-259490424) #*.snk # Since there are multiple workflows, uncomment next line to ignore bower_components # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) #bower_components/ # RIA/Silverlight projects Generated_Code/ # Backup & report files from converting an old project file # to a newer Visual Studio version. Backup files are not needed, # because we have git ;-) _UpgradeReport_Files/ Backup*/ UpgradeLog*.XML UpgradeLog*.htm ServiceFabricBackup/ *.rptproj.bak # SQL Server files *.mdf *.ldf *.ndf # Business Intelligence projects *.rdl.data *.bim.layout *.bim_*.settings *.rptproj.rsuser *- [Bb]ackup.rdl *- [Bb]ackup ([0-9]).rdl *- [Bb]ackup ([0-9][0-9]).rdl # Microsoft Fakes FakesAssemblies/ # GhostDoc plugin setting file *.GhostDoc.xml # Node.js Tools for Visual Studio .ntvs_analysis.dat node_modules/ # Visual Studio 6 build log *.plg # Visual Studio 6 workspace options file *.opt # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw # Visual Studio 6 auto-generated project file (contains which files were open etc.) *.vbp # Visual Studio 6 workspace and project file (working project files containing files to include in project) *.dsw *.dsp # Visual Studio 6 technical files # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts **/*.DesktopClient/ModelManifest.xml **/*.Server/GeneratedArtifacts **/*.Server/ModelManifest.xml _Pvt_Extensions # Paket dependency manager .paket/paket.exe paket-files/ # FAKE - F# Make .fake/ # CodeRush personal settings .cr/personal # Python Tools for Visual Studio (PTVS) __pycache__/ *.pyc # Cake - Uncomment if you are using it # tools/** # !tools/packages.config # Tabs Studio *.tss # Telerik's JustMock configuration file *.jmconfig # BizTalk build output *.btp.cs *.btm.cs *.odx.cs *.xsd.cs # OpenCover UI analysis results OpenCover/ # Azure Stream Analytics local run output ASALocalRun/ # MSBuild Binary and Structured Log *.binlog # NVidia Nsight GPU debugger configuration file *.nvuser # MFractors (Xamarin productivity tool) working folder .mfractor/ # Local History for Visual Studio .localhistory/ # Visual Studio History (VSHistory) files .vshistory/ # BeatPulse healthcheck temp database healthchecksdb # Backup folder for Package Reference Convert tool in Visual Studio 2017 MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ # Fody - auto-generated XML schema FodyWeavers.xsd # VS Code files for those working on multiple tools *.code-workspace # Local History for Visual Studio Code # Windows Installer files from build outputs *.cab *.msi *.msix *.msm *.msp # JetBrains Rider *.sln.iml ### Python template # Byte-compiled / optimized / DLL files *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/latest/usage/project/#working-with-version-control .pdm.toml .pdm-python .pdm-build/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ .idea/.gitignore .idea/*.iml .idea/copilot* .idea/misc.xml .idea/modules.xml .idea/vcs.xml .idea/inspectionProfiles/* /shelf/ /workspace.xml /httpRequests/ /dataSources/ /dataSources.local.xml ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/LICENSE.txt0000644000175100017510000000206415140343503020241 0ustar00runnerrunnerMIT License Copyright (c) 2025 Rüdiger Olschewsky Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1770112840.758825 certbot_dns_hetzner_cloud-1.0.5/PKG-INFO0000644000175100017510000001005315140343511017507 0ustar00runnerrunnerMetadata-Version: 2.4 Name: certbot-dns-hetzner-cloud Version: 1.0.5 Summary: Certbot DNS Plugin for Hetzner Cloud DNS Author: Rüdiger Olschewsky License: MIT Project-URL: Homepage, https://github.com/rolschewsky/certbot-dns-hetzner-cloud Project-URL: Repository, https://github.com/rolschewsky/certbot-dns-hetzner-cloud Keywords: certbot,dns,hetzner,acme,letsencrypt Requires-Python: >=3.10 Description-Content-Type: text/markdown License-File: LICENSE.txt Requires-Dist: certbot>=2.0 Requires-Dist: tldextract>=5.3.0 Requires-Dist: hcloud>=2.8.0 Provides-Extra: dev Requires-Dist: pytest; extra == "dev" Requires-Dist: pytest-cov; extra == "dev" Dynamic: license-file [![GitHub Release](https://img.shields.io/github/v/release/rolschewsky/certbot-dns-hetzner-cloud)](https://github.com/rolschewsky/certbot-dns-hetzner-cloud/releases) [![PyPI Version](https://img.shields.io/pypi/v/certbot-dns-hetzner-cloud)](https://pypi.org/project/certbot-dns-hetzner-cloud/) [![Snapcraft Version](https://img.shields.io/snapcraft/v/certbot-dns-hetzner-cloud/latest/stable)](https://snapcraft.io/certbot-dns-hetzner-cloud) [![License](https://img.shields.io/github/license/rolschewsky/certbot-dns-hetzner-cloud)](https://github.com/rolschewsky/certbot-dns-hetzner-cloud/blob/main/LICENSE.txt) [![Build Release](https://github.com/rolschewsky/certbot-dns-hetzner-cloud/actions/workflows/build-release.yml/badge.svg)](https://github.com/rolschewsky/certbot-dns-hetzner-cloud/actions/workflows/build-release.yml) [![codecov](https://codecov.io/gh/rolschewsky/certbot-dns-hetzner-cloud/graph/badge.svg?token=8RDFM8FWDU)](https://codecov.io/gh/rolschewsky/certbot-dns-hetzner-cloud) # Certbot DNS Plugin for Hetzner Cloud DNS This is a Certbot DNS plugin for the new Hetzner Cloud DNS, which allows you to automate the process of obtaining and renewing SSL/TLS certificates using the DNS-01 challenge method. This Plugin is not compatible with the old Hetzner DNS Console and you might want to take a look at the [certbot-dns-hetzner][1] plugin instead. ## Setup ### Installation To install the Certbot DNS plugin for Hetzner Cloud DNS, you can either use `pip` or `snap`. #### Installation using *pip* If you installed Certbot within a virtual environment (e.g., `/opt/certbot`) as per [official Certbot instructions][2] you can install the plugin using the following command: ```bash /opt/certbot/bin/pip install certbot-dns-hetzner-cloud ``` #### Installation using *snap* If you installed Certbot using `snap`, you can install the plugin with the following commands: ```bash sudo snap install certbot-dns-hetzner-cloud sudo snap set certbot trust-plugin-with-root=ok sudo snap connect certbot:plugin certbot-dns-hetzner-cloud ``` #### Verify installation After installation, you can verify that the plugin is available by running: ```bash certbot plugins ``` you should see `dns-hetzner-cloud` listed among the available plugins. ### Storing the API Token Create a configuration file under `/etc/letsencrypt/hetzner-cloud.ini` with the following content: ```ini # Hetzner Cloud API Token dns_hetzner_cloud_api_token = your_api_token_here ``` Make sure to set the correct permissions for the configuration file to protect your API token: ```bash sudo chmod 600 /etc/letsencrypt/hetzner_cloud.ini ``` If you want to use a different path for the configuration file, you can specify it using the `--dns-hetzner-cloud-credentials` option when running Certbot. ## Usage You can use the plugin with Certbot by specifying the `dns-hetzner-cloud` authenticator. Here is an example command to obtain a certificate for a wildcard subdomain: ```bash certbot certonly --agree-tos \ --authenticator dns-hetzner-cloud \ -d '*.example.eu' ``` If you want to use a different path for the configuration file, you can specify it using the `--dns-hetzner-cloud-credentials` option. ```bash certbot certonly --agree-tos \ --authenticator dns-hetzner-cloud \ --dns-hetzner-cloud-credentials /path/to/your/hetzner_cloud.ini \ -d '*.example.eu' ``` [1]:https://github.com/ctrlaltcoop/certbot-dns-hetzner [2]:https://certbot.eff.org/instructions ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/README.md0000644000175100017510000000661315140343503017701 0ustar00runnerrunner[![GitHub Release](https://img.shields.io/github/v/release/rolschewsky/certbot-dns-hetzner-cloud)](https://github.com/rolschewsky/certbot-dns-hetzner-cloud/releases) [![PyPI Version](https://img.shields.io/pypi/v/certbot-dns-hetzner-cloud)](https://pypi.org/project/certbot-dns-hetzner-cloud/) [![Snapcraft Version](https://img.shields.io/snapcraft/v/certbot-dns-hetzner-cloud/latest/stable)](https://snapcraft.io/certbot-dns-hetzner-cloud) [![License](https://img.shields.io/github/license/rolschewsky/certbot-dns-hetzner-cloud)](https://github.com/rolschewsky/certbot-dns-hetzner-cloud/blob/main/LICENSE.txt) [![Build Release](https://github.com/rolschewsky/certbot-dns-hetzner-cloud/actions/workflows/build-release.yml/badge.svg)](https://github.com/rolschewsky/certbot-dns-hetzner-cloud/actions/workflows/build-release.yml) [![codecov](https://codecov.io/gh/rolschewsky/certbot-dns-hetzner-cloud/graph/badge.svg?token=8RDFM8FWDU)](https://codecov.io/gh/rolschewsky/certbot-dns-hetzner-cloud) # Certbot DNS Plugin for Hetzner Cloud DNS This is a Certbot DNS plugin for the new Hetzner Cloud DNS, which allows you to automate the process of obtaining and renewing SSL/TLS certificates using the DNS-01 challenge method. This Plugin is not compatible with the old Hetzner DNS Console and you might want to take a look at the [certbot-dns-hetzner][1] plugin instead. ## Setup ### Installation To install the Certbot DNS plugin for Hetzner Cloud DNS, you can either use `pip` or `snap`. #### Installation using *pip* If you installed Certbot within a virtual environment (e.g., `/opt/certbot`) as per [official Certbot instructions][2] you can install the plugin using the following command: ```bash /opt/certbot/bin/pip install certbot-dns-hetzner-cloud ``` #### Installation using *snap* If you installed Certbot using `snap`, you can install the plugin with the following commands: ```bash sudo snap install certbot-dns-hetzner-cloud sudo snap set certbot trust-plugin-with-root=ok sudo snap connect certbot:plugin certbot-dns-hetzner-cloud ``` #### Verify installation After installation, you can verify that the plugin is available by running: ```bash certbot plugins ``` you should see `dns-hetzner-cloud` listed among the available plugins. ### Storing the API Token Create a configuration file under `/etc/letsencrypt/hetzner-cloud.ini` with the following content: ```ini # Hetzner Cloud API Token dns_hetzner_cloud_api_token = your_api_token_here ``` Make sure to set the correct permissions for the configuration file to protect your API token: ```bash sudo chmod 600 /etc/letsencrypt/hetzner_cloud.ini ``` If you want to use a different path for the configuration file, you can specify it using the `--dns-hetzner-cloud-credentials` option when running Certbot. ## Usage You can use the plugin with Certbot by specifying the `dns-hetzner-cloud` authenticator. Here is an example command to obtain a certificate for a wildcard subdomain: ```bash certbot certonly --agree-tos \ --authenticator dns-hetzner-cloud \ -d '*.example.eu' ``` If you want to use a different path for the configuration file, you can specify it using the `--dns-hetzner-cloud-credentials` option. ```bash certbot certonly --agree-tos \ --authenticator dns-hetzner-cloud \ --dns-hetzner-cloud-credentials /path/to/your/hetzner_cloud.ini \ -d '*.example.eu' ``` [1]:https://github.com/ctrlaltcoop/certbot-dns-hetzner [2]:https://certbot.eff.org/instructions ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/pyproject.toml0000644000175100017510000000163115140343503021331 0ustar00runnerrunner[project] dynamic = ["version"] name = "certbot-dns-hetzner-cloud" description = "Certbot DNS Plugin for Hetzner Cloud DNS" keywords = [ "certbot", "dns", "hetzner", "acme", "letsencrypt" ] authors = [ { name = "Rüdiger Olschewsky" } ] license = { text = "MIT" } readme = "README.md" requires-python = ">=3.10" dependencies = [ "certbot>=2.0", "tldextract>=5.3.0", "hcloud>=2.8.0" ] [project.optional-dependencies] dev = [ "pytest", "pytest-cov" ] [project.urls] Homepage = "https://github.com/rolschewsky/certbot-dns-hetzner-cloud" Repository = "https://github.com/rolschewsky/certbot-dns-hetzner-cloud" [project.entry-points."certbot.plugins"] dns-hetzner-cloud = "certbot_dns_hetzner_cloud.authenticator:HetznerCloudDNSAuthenticator" [build-system] requires = [ "setuptools>=80", "setuptools_scm[simple]>=8", "wheel" ] build-backend = "setuptools.build_meta"././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1770112840.758825 certbot_dns_hetzner_cloud-1.0.5/setup.cfg0000644000175100017510000000004615140343511020234 0ustar00runnerrunner[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1770112840.7558253 certbot_dns_hetzner_cloud-1.0.5/snap/0000755000175100017510000000000015140343511017354 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/snap/snapcraft.yaml0000644000175100017510000000205615140343503022225 0ustar00runnerrunner# snap/snapcraft.yaml name: certbot-dns-hetzner-cloud title: Certbot Hetzner Cloud DNS Plugin base: core24 summary: Certbot DNS-01 authenticator for Hetzner Cloud DNS. description: | This is a Certbot DNS plugin for the new Hetzner Cloud DNS, which allows you to automate the process of obtaining and renewing SSL/TLS certificates using the DNS-01 challenge method. This Plugin is not compatible with the old Hetzner DNS grade: stable confinement: strict license: MIT contact: https://github.com/rolschewsky/certbot-dns-hetzner-cloud source-code: https://github.com/rolschewsky/certbot-dns-hetzner-cloud website: https://github.com/rolschewsky/certbot-dns-hetzner-cloud donation: https://www.sos-kinderdorf.de/spenden adopt-info: plugin parts: plugin: plugin: python source: . build-packages: - git override-pull: | craftctl default version=$(git describe --tags --abbrev=0) craftctl set version="$version" slots: plugin: interface: content content: certbot-1 read: - $SNAP/lib/python3.12/site-packages././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1770112840.7538252 certbot_dns_hetzner_cloud-1.0.5/src/0000755000175100017510000000000015140343511017202 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1770112840.7558253 certbot_dns_hetzner_cloud-1.0.5/src/certbot_dns_hetzner_cloud/0000755000175100017510000000000015140343511024435 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/src/certbot_dns_hetzner_cloud/__init__.py0000644000175100017510000000000015140343503026535 0ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/src/certbot_dns_hetzner_cloud/authenticator.py0000644000175100017510000000557315140343503027674 0ustar00runnerrunnerimport logging import tldextract from certbot import errors from certbot.plugins import dns_common from datetime import datetime, timezone from certbot_dns_hetzner_cloud.hetzner_cloud_helper import HetznerCloudHelper DEFAULT_CREDENTIALS_PATH = "/etc/letsencrypt/hetzner-cloud.ini" def split_validation_name(validation_name: str) -> tuple[str, str]: extract = tldextract.extract(validation_name) zone_name = extract.top_domain_under_public_suffix record_name = validation_name[:-len(zone_name) - 1].rstrip(".") return zone_name, record_name class HetznerCloudDNSAuthenticator(dns_common.DNSAuthenticator): description = "Plugin for handling DNS-01 challenges via Hetzner Cloud DNS API." def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.logger = logging.getLogger(self.__class__.__name__) self.hetzner_dns_helper: HetznerCloudHelper | None = None @classmethod def add_parser_arguments(cls, add, default_propagation_seconds=30): super().add_parser_arguments(add, default_propagation_seconds) add("credentials", help="Path to Hetzner Cloud plugin configuration file.", default=DEFAULT_CREDENTIALS_PATH) def more_info(self) -> str: return f"""\ {self.description} You must provide an API token via a credentials INI file (default: {DEFAULT_CREDENTIALS_PATH}). See https://github.com/rolschewsky/certbot-dns-hetzner-cloud for details. """ def _setup_credentials(self) -> None: credentials = self._configure_credentials("credentials", "Hetzner Cloud plugin configuration file", { "api_token": "Hetzner Cloud API Token" }) api_token = credentials.conf("api_token") self.hetzner_dns_helper = HetznerCloudHelper(api_token) def _perform(self, domain: str, validation_name: str, validation: str) -> None: if not self.hetzner_dns_helper: raise errors.PluginError("Hetzner DNS helper not initialized.") zone_name, record_name = split_validation_name(validation_name) self.logger.info("Adding TXT record %s to zone %s", record_name, zone_name) timestamp = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') self.hetzner_dns_helper.put_txt_record( zone=zone_name, name=record_name, value=validation, comment=f"created by hetzner cloud plugin at {timestamp}" ) def _cleanup(self, domain: str, validation_name: str, validation: str) -> None: if not self.hetzner_dns_helper: return zone_name, record_name = split_validation_name(validation_name) self.logger.info("Removing TXT record %s from zone %s", record_name, zone_name) self.hetzner_dns_helper.delete_txt_record( zone=zone_name, name=record_name, value=validation ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/src/certbot_dns_hetzner_cloud/hetzner_cloud_helper.py0000644000175100017510000000620515140343503031217 0ustar00runnerrunnerfrom typing import Union from hcloud import Client from hcloud.zones import BoundZone, ZoneRecord from hcloud.zones.domain import CreateZoneRRSetResponse class HetznerCloudHelper: """Helper class to manage Hetzner Cloud DNS records.""" def __init__(self, api_key: str) -> None: self.client = Client(api_key) def _ensure_zone(self, zone: Union[str, BoundZone]) -> BoundZone: if isinstance(zone, BoundZone): return zone return self.client.zones.get(zone) def delete_txt_record(self, zone: Union[str, BoundZone], name: str, value: str | None = None) -> None: """Delete a TXT record or a specific value from it. If value is provided, only removes that specific value from the record set. If value is None, deletes all TXT records with the given name. """ # ensure value is quoted if provided if value is not None and not (value.startswith("\"") and value.endswith("\"")): value = f'"{value}"' # load zone object bound_zone = self._ensure_zone(zone) # search for an existing TXT record query_result = self.client.zones.get_rrset_list(zone=bound_zone, name=name, type="TXT") # delete if exists if len(query_result.rrsets) > 0: if value is None: # delete entire rrset self.client.zones.delete_rrset(query_result.rrsets[0]) else: # remove only the specific value remaining_records = [record for record in query_result.rrsets[0].records if record.value != value] self.client.zones.delete_rrset(query_result.rrsets[0]) # recreate with remaining records if any if remaining_records: self.client.zones.create_rrset( zone=bound_zone, name=name, type="TXT", records=remaining_records ) def put_txt_record(self, zone: Union[str, BoundZone], name: str, value: str, comment: str | None = None) -> CreateZoneRRSetResponse: """Create or update a TXT record.""" # ensure value is quoted if not (value.startswith("\"") and value.endswith("\"")): value = f'"{value}"' # load zone object bound_zone = self._ensure_zone(zone) # check for existing TXT records query_result = self.client.zones.get_rrset_list(zone=bound_zone, name=name, type="TXT") existing_records = [] if len(query_result.rrsets) > 0: # preserve existing records and delete the rrset existing_records = [record for record in query_result.rrsets[0].records if record.value != value] self.client.zones.delete_rrset(query_result.rrsets[0]) # create new TXT record with all values (existing + new) all_records = existing_records + [ZoneRecord(value=value, comment=comment)] return self.client.zones.create_rrset( zone=bound_zone, name=name, type="TXT", records=all_records )././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1770112840.7578251 certbot_dns_hetzner_cloud-1.0.5/src/certbot_dns_hetzner_cloud.egg-info/0000755000175100017510000000000015140343511026127 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112840.0 certbot_dns_hetzner_cloud-1.0.5/src/certbot_dns_hetzner_cloud.egg-info/PKG-INFO0000644000175100017510000001005315140343510027222 0ustar00runnerrunnerMetadata-Version: 2.4 Name: certbot-dns-hetzner-cloud Version: 1.0.5 Summary: Certbot DNS Plugin for Hetzner Cloud DNS Author: Rüdiger Olschewsky License: MIT Project-URL: Homepage, https://github.com/rolschewsky/certbot-dns-hetzner-cloud Project-URL: Repository, https://github.com/rolschewsky/certbot-dns-hetzner-cloud Keywords: certbot,dns,hetzner,acme,letsencrypt Requires-Python: >=3.10 Description-Content-Type: text/markdown License-File: LICENSE.txt Requires-Dist: certbot>=2.0 Requires-Dist: tldextract>=5.3.0 Requires-Dist: hcloud>=2.8.0 Provides-Extra: dev Requires-Dist: pytest; extra == "dev" Requires-Dist: pytest-cov; extra == "dev" Dynamic: license-file [![GitHub Release](https://img.shields.io/github/v/release/rolschewsky/certbot-dns-hetzner-cloud)](https://github.com/rolschewsky/certbot-dns-hetzner-cloud/releases) [![PyPI Version](https://img.shields.io/pypi/v/certbot-dns-hetzner-cloud)](https://pypi.org/project/certbot-dns-hetzner-cloud/) [![Snapcraft Version](https://img.shields.io/snapcraft/v/certbot-dns-hetzner-cloud/latest/stable)](https://snapcraft.io/certbot-dns-hetzner-cloud) [![License](https://img.shields.io/github/license/rolschewsky/certbot-dns-hetzner-cloud)](https://github.com/rolschewsky/certbot-dns-hetzner-cloud/blob/main/LICENSE.txt) [![Build Release](https://github.com/rolschewsky/certbot-dns-hetzner-cloud/actions/workflows/build-release.yml/badge.svg)](https://github.com/rolschewsky/certbot-dns-hetzner-cloud/actions/workflows/build-release.yml) [![codecov](https://codecov.io/gh/rolschewsky/certbot-dns-hetzner-cloud/graph/badge.svg?token=8RDFM8FWDU)](https://codecov.io/gh/rolschewsky/certbot-dns-hetzner-cloud) # Certbot DNS Plugin for Hetzner Cloud DNS This is a Certbot DNS plugin for the new Hetzner Cloud DNS, which allows you to automate the process of obtaining and renewing SSL/TLS certificates using the DNS-01 challenge method. This Plugin is not compatible with the old Hetzner DNS Console and you might want to take a look at the [certbot-dns-hetzner][1] plugin instead. ## Setup ### Installation To install the Certbot DNS plugin for Hetzner Cloud DNS, you can either use `pip` or `snap`. #### Installation using *pip* If you installed Certbot within a virtual environment (e.g., `/opt/certbot`) as per [official Certbot instructions][2] you can install the plugin using the following command: ```bash /opt/certbot/bin/pip install certbot-dns-hetzner-cloud ``` #### Installation using *snap* If you installed Certbot using `snap`, you can install the plugin with the following commands: ```bash sudo snap install certbot-dns-hetzner-cloud sudo snap set certbot trust-plugin-with-root=ok sudo snap connect certbot:plugin certbot-dns-hetzner-cloud ``` #### Verify installation After installation, you can verify that the plugin is available by running: ```bash certbot plugins ``` you should see `dns-hetzner-cloud` listed among the available plugins. ### Storing the API Token Create a configuration file under `/etc/letsencrypt/hetzner-cloud.ini` with the following content: ```ini # Hetzner Cloud API Token dns_hetzner_cloud_api_token = your_api_token_here ``` Make sure to set the correct permissions for the configuration file to protect your API token: ```bash sudo chmod 600 /etc/letsencrypt/hetzner_cloud.ini ``` If you want to use a different path for the configuration file, you can specify it using the `--dns-hetzner-cloud-credentials` option when running Certbot. ## Usage You can use the plugin with Certbot by specifying the `dns-hetzner-cloud` authenticator. Here is an example command to obtain a certificate for a wildcard subdomain: ```bash certbot certonly --agree-tos \ --authenticator dns-hetzner-cloud \ -d '*.example.eu' ``` If you want to use a different path for the configuration file, you can specify it using the `--dns-hetzner-cloud-credentials` option. ```bash certbot certonly --agree-tos \ --authenticator dns-hetzner-cloud \ --dns-hetzner-cloud-credentials /path/to/your/hetzner_cloud.ini \ -d '*.example.eu' ``` [1]:https://github.com/ctrlaltcoop/certbot-dns-hetzner [2]:https://certbot.eff.org/instructions ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112840.0 certbot_dns_hetzner_cloud-1.0.5/src/certbot_dns_hetzner_cloud.egg-info/SOURCES.txt0000644000175100017510000000154015140343510030012 0ustar00runnerrunner.gitignore LICENSE.txt README.md pyproject.toml .github/workflows/build-release.yml .github/workflows/publish-github.yml .github/workflows/publish-pipy.yml .github/workflows/publish-snapcraft.yml snap/snapcraft.yaml src/certbot_dns_hetzner_cloud/__init__.py src/certbot_dns_hetzner_cloud/authenticator.py src/certbot_dns_hetzner_cloud/hetzner_cloud_helper.py src/certbot_dns_hetzner_cloud.egg-info/PKG-INFO src/certbot_dns_hetzner_cloud.egg-info/SOURCES.txt src/certbot_dns_hetzner_cloud.egg-info/dependency_links.txt src/certbot_dns_hetzner_cloud.egg-info/entry_points.txt src/certbot_dns_hetzner_cloud.egg-info/requires.txt src/certbot_dns_hetzner_cloud.egg-info/top_level.txt tests/test_authenticator_cleanup.py tests/test_authenticator_creates_client.py tests/test_authenticator_perform.py tests/test_hetzner_cloud_helper.py tests/test_split_validation_name.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112840.0 certbot_dns_hetzner_cloud-1.0.5/src/certbot_dns_hetzner_cloud.egg-info/dependency_links.txt0000644000175100017510000000000115140343510032174 0ustar00runnerrunner ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112840.0 certbot_dns_hetzner_cloud-1.0.5/src/certbot_dns_hetzner_cloud.egg-info/entry_points.txt0000644000175100017510000000015315140343510031423 0ustar00runnerrunner[certbot.plugins] dns-hetzner-cloud = certbot_dns_hetzner_cloud.authenticator:HetznerCloudDNSAuthenticator ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112840.0 certbot_dns_hetzner_cloud-1.0.5/src/certbot_dns_hetzner_cloud.egg-info/requires.txt0000644000175100017510000000010615140343510030523 0ustar00runnerrunnercertbot>=2.0 tldextract>=5.3.0 hcloud>=2.8.0 [dev] pytest pytest-cov ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112840.0 certbot_dns_hetzner_cloud-1.0.5/src/certbot_dns_hetzner_cloud.egg-info/top_level.txt0000644000175100017510000000003215140343510030653 0ustar00runnerrunnercertbot_dns_hetzner_cloud ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1770112840.7578251 certbot_dns_hetzner_cloud-1.0.5/tests/0000755000175100017510000000000015140343511017555 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/tests/test_authenticator_cleanup.py0000644000175100017510000000341715140343503025555 0ustar00runnerrunnerimport pytest from certbot_dns_hetzner_cloud.authenticator import HetznerCloudDNSAuthenticator class DummyHelper: """Mock helper that records delete_txt_record calls.""" def __init__(self): self.delete_calls = [] def delete_txt_record(self, *, zone: str, name: str, value: str = None): self.delete_calls.append((zone, name, value)) @pytest.fixture def authenticator(monkeypatch): """Fixture that sets up an Authenticator with a mocked Hetzner helper.""" auth = HetznerCloudDNSAuthenticator(config=None, name="dns-hetzner-cloud") # Replace the real helper with our dummy mock dummy = DummyHelper() auth.hetzner_dns_helper = dummy return auth, dummy def test_cleanup_removes_correct_txt_record(authenticator): """ _cleanup() should call delete_txt_record() with the correct zone and record name derived from the validation name. """ auth, dummy = authenticator # Example DNS-01 challenge details domain = "example.com" validation_name = "_acme-challenge.sub.example.com." validation_value = "abcdef123456" # not used by cleanup, but Certbot provides it # Act: perform the cleanup auth._cleanup(domain, validation_name, validation_value) # Assert: exactly one delete call was made assert len(dummy.delete_calls) == 1, "expected exactly one TXT record deletion call" # Extract the call arguments zone, name, value = dummy.delete_calls[0] # Zone should be the registered domain assert zone == "example.com", f"unexpected zone: {zone}" # Record name should be relative within the zone assert name == "_acme-challenge.sub", f"unexpected record name: {name}" # Value should match the validation token assert value == validation_value, f"unexpected value: {value}" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/tests/test_authenticator_creates_client.py0000644000175100017510000000177315140343503027115 0ustar00runnerrunnerimport pytest from unittest.mock import MagicMock, patch, ANY from certbot_dns_hetzner_cloud.authenticator import HetznerCloudDNSAuthenticator def test_setup_credentials_creates_client(): auth = object.__new__(HetznerCloudDNSAuthenticator) mock_credentials = MagicMock() mock_credentials.conf.return_value = "dummy_token" with patch.object(auth, "_configure_credentials", return_value=mock_credentials) as mock_configure, \ patch("certbot_dns_hetzner_cloud.authenticator.HetznerCloudHelper", autospec=True) as mock_helper: auth._setup_credentials() mock_configure.assert_called_once_with( "credentials", ANY, {"api_token": "Hetzner Cloud API Token"}, ) mock_helper.assert_called_once_with("dummy_token") # Falls du in _setup_credentials auf hetzner_dns_helper umgestellt hast: client = getattr(auth, "hetzner_dns_helper", getattr(auth, "_client", None)) assert client is mock_helper.return_value ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/tests/test_authenticator_perform.py0000644000175100017510000000414215140343503025574 0ustar00runnerrunnerimport pytest from datetime import datetime, timezone from certbot_dns_hetzner_cloud.authenticator import HetznerCloudDNSAuthenticator class DummyHelper: """Mock helper that records all put_txt_record calls.""" def __init__(self): self.put_calls = [] def put_txt_record(self, *, zone: str, name: str, value: str, comment: str): # Basic validation: value should not be quoted (Certbot handles raw TXT values) assert not value.startswith('"') and not value.endswith('"') self.put_calls.append((zone, name, value, comment)) @pytest.fixture def authenticator(monkeypatch): """Fixture that sets up an Authenticator with a mocked Hetzner helper.""" auth = HetznerCloudDNSAuthenticator(config=None, name="dns-hetzner-cloud") # Replace the real helper with our dummy mock dummy = DummyHelper() auth.hetzner_dns_helper = dummy return auth, dummy def test_perform_creates_expected_txt_record(authenticator): """_perform() should split validation name, create a TXT record and pass proper values.""" auth, dummy = authenticator # Example data from a real Certbot DNS-01 challenge domain = "example.com" validation_name = "_acme-challenge.sub.example.com." validation_value = "abcdef123456" # Act: run the method under test auth._perform(domain, validation_name, validation_value) # Assert: exactly one API call was made assert len(dummy.put_calls) == 1, "expected exactly one TXT record creation call" # Unpack recorded call arguments zone, name, value, comment = dummy.put_calls[0] # Zone should be the registered domain assert zone == "example.com", f"unexpected zone: {zone}" # Name should be the subrecord portion assert name == "_acme-challenge.sub", f"unexpected record name: {name}" # Value should match the validation token (unquoted) assert value == validation_value # Comment must contain ISO8601 UTC timestamp and plugin marker assert "created by hetzner cloud plugin" in comment.lower() datetime.fromisoformat(comment.split()[-1].replace("Z", "+00:00")) # should parse cleanly ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/tests/test_hetzner_cloud_helper.py0000644000175100017510000001766015140343503025405 0ustar00runnerrunnerimport pytest from types import SimpleNamespace from certbot_dns_hetzner_cloud.hetzner_cloud_helper import HetznerCloudHelper import certbot_dns_hetzner_cloud.hetzner_cloud_helper as mod # ---- Test-Doubles ---- class FakeRRSet: def __init__(self, name, *values): self.name = name # Support multiple values: each can be a string or tuple (value, comment) self.records = [] for v in values: if isinstance(v, tuple): value, comment = v else: value, comment = v, None self.records.append(SimpleNamespace(value=value, comment=comment)) class FakeRRSetListResp: def __init__(self, rrsets=None): self.rrsets = rrsets or [] class FakeZonesAPI: def __init__(self): # Aufgerufen-Flags + zuletzt übergebene Argumente self.calls = [] self.bound_zone = SimpleNamespace(id="Z1", name="example.com") # Zonen def get(self, zone_name): self.calls.append(("get", zone_name)) assert zone_name == "example.com" return self.bound_zone # RRSet lesen def get_rrset_list(self, *, zone, name, type): self.calls.append(("get_rrset_list", zone.name, name, type)) # Rückgabe wird pro Test per Injection gesetzt return self._rrset_list # RRSet löschen def delete_rrset(self, rrset): self.calls.append(("delete_rrset", rrset.name)) # RRSet erstellen def create_rrset(self, *, zone, name, type, records): self.calls.append(("create_rrset", zone.name, name, type, tuple(r.value for r in records))) # Minimal-Response mit rrset zurückgeben (ähnlich hcloud) return SimpleNamespace(rrset=FakeRRSet(name, records[0].value)) class FakeClient: def __init__(self): self.zones = FakeZonesAPI() class FakeBoundZone: def __init__(self, name="example.com", id_="Z1"): self.name = name self.id = id_ # ---- Fixtures ---- @pytest.fixture def helper(monkeypatch): # 1) Hetzner-Client faken def fake_init(self, api_key: str): self.client = FakeClient() monkeypatch.setattr(HetznerCloudHelper, "__init__", fake_init) # 2) BoundZone-Klasse im Modul patchen, damit isinstance(...) True ist monkeypatch.setattr(mod, "BoundZone", FakeBoundZone) # 3) Instanz + Default-BoundZone setzen h = HetznerCloudHelper("DUMMY") h.client.zones.bound_zone = FakeBoundZone(name="example.com", id_="Z1") # (optional) leere RRSet-Liste als Default h.client.zones._rrset_list = FakeRRSetListResp([]) return h # ---- Tests ---- def test_ensure_zone_with_string(helper): zones = helper.client.zones zones._rrset_list = FakeRRSetListResp([]) # default z = helper._ensure_zone("example.com") assert z.name == "example.com" assert ("get", "example.com") in zones.calls def test_ensure_zone_with_boundzone(helper, monkeypatch): zones = helper.client.zones zones._rrset_list = FakeRRSetListResp([]) # make BoundZone isinstance(...) pass monkeypatch.setattr(mod, "BoundZone", FakeBoundZone) bound = FakeBoundZone(name="example.com", id_="Z1") zones.bound_zone = bound # our fake bound zone z = helper._ensure_zone(bound) assert z is bound assert z.name == "example.com" assert not any(c[0] == "get" for c in zones.calls) def test_delete_txt_record_deletes_when_present(helper): zones = helper.client.zones # Simuliere vorhandenes RRSet zones._rrset_list = FakeRRSetListResp([FakeRRSet("_acme-challenge", '"old"')]) helper.delete_txt_record("example.com", "_acme-challenge") # Erwartung: get -> get_rrset_list -> delete_rrset assert ("get", "example.com") in zones.calls assert ("get_rrset_list", "example.com", "_acme-challenge", "TXT") in zones.calls assert ("delete_rrset", "_acme-challenge") in zones.calls def test_delete_txt_record_noop_when_absent(helper): zones = helper.client.zones zones._rrset_list = FakeRRSetListResp([]) helper.delete_txt_record("example.com", "_acme-challenge") # Kein delete_rrset-Call assert ("get_rrset_list", "example.com", "_acme-challenge", "TXT") in zones.calls assert not [c for c in zones.calls if c[0] == "delete_rrset"] def test_put_txt_record_quotes_value_and_replaces(helper): zones = helper.client.zones # Vorhandenes RRSet -> sollte erst gelöscht, dann neu erstellt werden zones._rrset_list = FakeRRSetListResp([FakeRRSet("_acme-challenge", '"old"')]) resp = helper.put_txt_record("example.com", "_acme-challenge", value="abc123", comment="test") # Reihenfolge prüfen: get → get_rrset_list → delete_rrset → create_rrset names = [c[0] for c in zones.calls] assert names[:4] == ["get", "get_rrset_list", "delete_rrset", "create_rrset"] # create_rrset wurde mit gequotetem Value aufgerufen: create_call = [c for c in zones.calls if c[0] == "create_rrset"][-1] _, zone_name, rr_name, rr_type, values = create_call assert zone_name == "example.com" assert rr_name == "_acme-challenge" assert rr_type == "TXT" # Should preserve old value and add new one assert values == ('"old"', '"abc123"') # Response rrset contains records (first one is preserved old value) assert len(resp.rrset.records) > 0 def test_put_txt_record_preserves_multiple_existing_records(helper): zones = helper.client.zones # Multiple existing records zones._rrset_list = FakeRRSetListResp([FakeRRSet("_acme-challenge", '"token1"', '"token2"')]) helper.put_txt_record("example.com", "_acme-challenge", value="token3", comment="test") # Should create with all three values create_call = [c for c in zones.calls if c[0] == "create_rrset"][-1] _, zone_name, rr_name, rr_type, values = create_call assert values == ('"token1"', '"token2"', '"token3"') def test_put_txt_record_avoids_duplicates(helper): zones = helper.client.zones # Record with same value already exists zones._rrset_list = FakeRRSetListResp([FakeRRSet("_acme-challenge", '"token1"', '"abc123"')]) helper.put_txt_record("example.com", "_acme-challenge", value="abc123", comment="test") # Should not duplicate, only have token1 and abc123 create_call = [c for c in zones.calls if c[0] == "create_rrset"][-1] _, zone_name, rr_name, rr_type, values = create_call assert values == ('"token1"', '"abc123"') def test_delete_txt_record_with_value_removes_only_that_value(helper): zones = helper.client.zones # Multiple records exist zones._rrset_list = FakeRRSetListResp([FakeRRSet("_acme-challenge", '"token1"', '"token2"')]) helper.delete_txt_record("example.com", "_acme-challenge", value="token1") # Should delete and recreate with only token2 assert ("delete_rrset", "_acme-challenge") in zones.calls create_call = [c for c in zones.calls if c[0] == "create_rrset"] assert len(create_call) == 1 _, zone_name, rr_name, rr_type, values = create_call[0] assert values == ('"token2"',) def test_delete_txt_record_with_value_deletes_all_when_last_removed(helper): zones = helper.client.zones # Only one record exists zones._rrset_list = FakeRRSetListResp([FakeRRSet("_acme-challenge", '"token1"')]) helper.delete_txt_record("example.com", "_acme-challenge", value="token1") # Should delete but not recreate (no remaining records) assert ("delete_rrset", "_acme-challenge") in zones.calls create_calls = [c for c in zones.calls if c[0] == "create_rrset"] assert len(create_calls) == 0 def test_delete_txt_record_without_value_deletes_all(helper): zones = helper.client.zones # Multiple records exist zones._rrset_list = FakeRRSetListResp([FakeRRSet("_acme-challenge", '"token1"', '"token2"')]) helper.delete_txt_record("example.com", "_acme-challenge", value=None) # Should delete entire rrset without recreation assert ("delete_rrset", "_acme-challenge") in zones.calls create_calls = [c for c in zones.calls if c[0] == "create_rrset"] assert len(create_calls) == 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1770112835.0 certbot_dns_hetzner_cloud-1.0.5/tests/test_split_validation_name.py0000644000175100017510000000127115140343503025535 0ustar00runnerrunnerimport pytest from certbot_dns_hetzner_cloud.authenticator import split_validation_name @pytest.mark.parametrize("fqdn, zone, rec", [ ("_acme-challenge.example.com.", "example.com", "_acme-challenge"), ("_acme-challenge.sub.example.com.", "example.com", "_acme-challenge.sub"), ("_acme-challenge.sub.example.co.uk.", "example.co.uk", "_acme-challenge.sub"), ]) def test_split_validation_name_ok(fqdn, zone, rec): z, r = split_validation_name(fqdn) assert z == zone assert r == rec def test_split_validation_name_robust_without_trailing_dot(): z, r = split_validation_name("_acme-challenge.example.com") assert z == "example.com" assert r == "_acme-challenge"