certbot-dns-edgeone 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- certbot_dns_edgeone-0.1.0/.github/workflows/publish.yml +33 -0
- certbot_dns_edgeone-0.1.0/.gitignore +12 -0
- certbot_dns_edgeone-0.1.0/LICENSE +176 -0
- certbot_dns_edgeone-0.1.0/PKG-INFO +173 -0
- certbot_dns_edgeone-0.1.0/README.md +143 -0
- certbot_dns_edgeone-0.1.0/README.zh-CN.md +146 -0
- certbot_dns_edgeone-0.1.0/certbot_dns_edgeone/__init__.py +3 -0
- certbot_dns_edgeone-0.1.0/certbot_dns_edgeone/client.py +233 -0
- certbot_dns_edgeone-0.1.0/certbot_dns_edgeone/dns_edgeone.py +73 -0
- certbot_dns_edgeone-0.1.0/credentials.example.ini +12 -0
- certbot_dns_edgeone-0.1.0/pyproject.toml +52 -0
- certbot_dns_edgeone-0.1.0/tests/__init__.py +1 -0
- certbot_dns_edgeone-0.1.0/tests/test_dns_edgeone.py +176 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build-and-publish:
|
|
10
|
+
name: Build and publish to PyPI
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
permissions:
|
|
13
|
+
# Required for PyPI Trusted Publishing
|
|
14
|
+
id-token: write
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- name: Install uv
|
|
21
|
+
uses: astral-sh/setup-uv@v3
|
|
22
|
+
with:
|
|
23
|
+
version: "latest"
|
|
24
|
+
|
|
25
|
+
- name: Build package
|
|
26
|
+
run: uv build
|
|
27
|
+
|
|
28
|
+
- name: Publish package distributions to PyPI
|
|
29
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
30
|
+
with:
|
|
31
|
+
# If using API token instead of trusted publishing:
|
|
32
|
+
# password: ${{ secrets.PYPI_API_TOKEN }}
|
|
33
|
+
skip-existing: true
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: certbot-dns-edgeone
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tencent Cloud EdgeOne (TEO) DNS Authenticator plugin for Certbot
|
|
5
|
+
Project-URL: Homepage, https://github.com/hurole/certbot-dns-edgeone
|
|
6
|
+
Project-URL: Repository, https://github.com/hurole/certbot-dns-edgeone
|
|
7
|
+
Project-URL: Issues, https://github.com/hurole/certbot-dns-edgeone/issues
|
|
8
|
+
Author: Developer
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Plugins
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Operating System :: MacOS
|
|
16
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
17
|
+
Classifier: Programming Language :: Python
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Internet :: Name Service (DNS)
|
|
24
|
+
Classifier: Topic :: Security
|
|
25
|
+
Classifier: Topic :: System :: Systems Administration
|
|
26
|
+
Requires-Python: >=3.9
|
|
27
|
+
Requires-Dist: certbot>=2.0.0
|
|
28
|
+
Requires-Dist: tencentcloud-sdk-python-teo>=3.0.0
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# certbot-dns-edgeone
|
|
32
|
+
|
|
33
|
+
[English](README.md) | [中文](README.zh-CN.md)
|
|
34
|
+
|
|
35
|
+
Tencent Cloud **EdgeOne (TEO)** DNS Authenticator plugin for Certbot (similar to `certbot-dns-aliyun` / `certbot-dns-cloudflare`), allowing automated issuance and renewal of Let's Encrypt SSL/TLS certificates (including wildcard certificates).
|
|
36
|
+
|
|
37
|
+
This plugin automates the process of completing `dns-01` challenges by creating and subsequently removing `_acme-challenge` TXT records via the Tencent Cloud EdgeOne API.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- **Automated DNS-01 Challenge**: Automatically matches the corresponding EdgeOne Zone, creates verification TXT records, and cleans them up after ACME validation.
|
|
44
|
+
- **Wildcard Certificate Support**: Full support for single domains, multi-domains (SAN), and wildcard domains (e.g., `*.example.com`).
|
|
45
|
+
- **IDN / Punycode Support**: Built-in support for internationalized domain names (Chinese, Japanese, etc.).
|
|
46
|
+
- **Smart Zone Discovery & Caching**: Progressively looks up parent domains to locate the EdgeOne Zone ID, with in-memory caching to minimize API requests.
|
|
47
|
+
- **Customizable**: Allows specifying a `zone-id` explicitly and configuring custom DNS propagation wait times.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
### Using uv (Recommended)
|
|
54
|
+
|
|
55
|
+
In your virtual environment:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uv pip install certbot-dns-edgeone
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or install from source:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
git clone https://github.com/hurole/certbot-dns-edgeone.git
|
|
65
|
+
cd certbot-dns-edgeone
|
|
66
|
+
|
|
67
|
+
# Create and activate virtual environment
|
|
68
|
+
uv venv
|
|
69
|
+
source .venv/bin/activate
|
|
70
|
+
|
|
71
|
+
# Install in editable mode
|
|
72
|
+
uv pip install -e .
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Using pip
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install certbot certbot-dns-edgeone
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Verify that Certbot discovers the plugin:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
certbot plugins
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
You should see `* dns-edgeone` listed in the output:
|
|
88
|
+
```text
|
|
89
|
+
* dns-edgeone
|
|
90
|
+
Description: Obtain certificates using a DNS TXT record (if you are using Tencent Cloud EdgeOne for DNS).
|
|
91
|
+
Interfaces: Authenticator, Plugin
|
|
92
|
+
Entry point: dns-edgeone = certbot_dns_edgeone.dns_edgeone:Authenticator
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Credentials
|
|
98
|
+
|
|
99
|
+
1. Go to [Tencent Cloud CAM Console - API Key Management](https://console.cloud.tencent.com/cam/capi) to generate an API key (`SecretId` and `SecretKey`).
|
|
100
|
+
2. Ensure the user or role has the required EdgeOne (TEO) permissions (`QcloudTEOFullAccess` or a custom policy granting `teo:DescribeZones`, `teo:CreateDnsRecord`, `teo:DescribeDnsRecords`, and `teo:DeleteDnsRecords`).
|
|
101
|
+
3. Create a credentials INI file (e.g., `~/.secrets/certbot/edgeone.ini`):
|
|
102
|
+
|
|
103
|
+
```ini
|
|
104
|
+
# Tencent Cloud EdgeOne API credentials
|
|
105
|
+
dns_edgeone_secret_id = YOUR_TENCENTCLOUD_SECRET_ID
|
|
106
|
+
dns_edgeone_secret_key = YOUR_TENCENTCLOUD_SECRET_KEY
|
|
107
|
+
|
|
108
|
+
# Optional: STS Security Token (if using temporary credentials)
|
|
109
|
+
# dns_edgeone_token = your_sts_token
|
|
110
|
+
|
|
111
|
+
# Optional: Manually specify EdgeOne Zone ID (skips auto-discovery)
|
|
112
|
+
# dns_edgeone_zone_id = zone-2noz78a8ev6k
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
4. **Secure the credentials file**:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
chmod 600 ~/.secrets/certbot/edgeone.ini
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Usage
|
|
124
|
+
|
|
125
|
+
### Request a Certificate
|
|
126
|
+
|
|
127
|
+
Run `certbot certonly` with the `dns-edgeone` authenticator:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
certbot certonly \
|
|
131
|
+
-a dns-edgeone \
|
|
132
|
+
--dns-edgeone-credentials ~/.secrets/certbot/edgeone.ini \
|
|
133
|
+
--dns-edgeone-propagation-seconds 30 \
|
|
134
|
+
-d example.com \
|
|
135
|
+
-d "*.example.com"
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Automatic Renewal
|
|
139
|
+
|
|
140
|
+
Certbot renews certificates automatically before they expire (usually within 30 days) via cron or a systemd timer.
|
|
141
|
+
|
|
142
|
+
Test renewal using dry-run mode:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
certbot renew --dry-run
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Arguments
|
|
151
|
+
|
|
152
|
+
| Argument | Default | Description |
|
|
153
|
+
| :--- | :--- | :--- |
|
|
154
|
+
| `--dns-edgeone-credentials` | Required | Path to INI credentials file containing EdgeOne API `secret_id` and `secret_key` |
|
|
155
|
+
| `--dns-edgeone-propagation-seconds` | `30` | Seconds to wait for DNS propagation before ACME validation |
|
|
156
|
+
| `--dns-edgeone-zone-id` | Auto | Optional EdgeOne Zone ID (e.g. `zone-xxxxxx`) to override automatic discovery |
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Development & Testing
|
|
161
|
+
|
|
162
|
+
Run the test suite using `pytest`:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
uv pip install pytest
|
|
166
|
+
pytest -v
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
[Apache License 2.0](LICENSE)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# certbot-dns-edgeone
|
|
2
|
+
|
|
3
|
+
[English](README.md) | [中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
Tencent Cloud **EdgeOne (TEO)** DNS Authenticator plugin for Certbot (similar to `certbot-dns-aliyun` / `certbot-dns-cloudflare`), allowing automated issuance and renewal of Let's Encrypt SSL/TLS certificates (including wildcard certificates).
|
|
6
|
+
|
|
7
|
+
This plugin automates the process of completing `dns-01` challenges by creating and subsequently removing `_acme-challenge` TXT records via the Tencent Cloud EdgeOne API.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Automated DNS-01 Challenge**: Automatically matches the corresponding EdgeOne Zone, creates verification TXT records, and cleans them up after ACME validation.
|
|
14
|
+
- **Wildcard Certificate Support**: Full support for single domains, multi-domains (SAN), and wildcard domains (e.g., `*.example.com`).
|
|
15
|
+
- **IDN / Punycode Support**: Built-in support for internationalized domain names (Chinese, Japanese, etc.).
|
|
16
|
+
- **Smart Zone Discovery & Caching**: Progressively looks up parent domains to locate the EdgeOne Zone ID, with in-memory caching to minimize API requests.
|
|
17
|
+
- **Customizable**: Allows specifying a `zone-id` explicitly and configuring custom DNS propagation wait times.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
### Using uv (Recommended)
|
|
24
|
+
|
|
25
|
+
In your virtual environment:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv pip install certbot-dns-edgeone
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or install from source:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
git clone https://github.com/hurole/certbot-dns-edgeone.git
|
|
35
|
+
cd certbot-dns-edgeone
|
|
36
|
+
|
|
37
|
+
# Create and activate virtual environment
|
|
38
|
+
uv venv
|
|
39
|
+
source .venv/bin/activate
|
|
40
|
+
|
|
41
|
+
# Install in editable mode
|
|
42
|
+
uv pip install -e .
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Using pip
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install certbot certbot-dns-edgeone
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Verify that Certbot discovers the plugin:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
certbot plugins
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
You should see `* dns-edgeone` listed in the output:
|
|
58
|
+
```text
|
|
59
|
+
* dns-edgeone
|
|
60
|
+
Description: Obtain certificates using a DNS TXT record (if you are using Tencent Cloud EdgeOne for DNS).
|
|
61
|
+
Interfaces: Authenticator, Plugin
|
|
62
|
+
Entry point: dns-edgeone = certbot_dns_edgeone.dns_edgeone:Authenticator
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Credentials
|
|
68
|
+
|
|
69
|
+
1. Go to [Tencent Cloud CAM Console - API Key Management](https://console.cloud.tencent.com/cam/capi) to generate an API key (`SecretId` and `SecretKey`).
|
|
70
|
+
2. Ensure the user or role has the required EdgeOne (TEO) permissions (`QcloudTEOFullAccess` or a custom policy granting `teo:DescribeZones`, `teo:CreateDnsRecord`, `teo:DescribeDnsRecords`, and `teo:DeleteDnsRecords`).
|
|
71
|
+
3. Create a credentials INI file (e.g., `~/.secrets/certbot/edgeone.ini`):
|
|
72
|
+
|
|
73
|
+
```ini
|
|
74
|
+
# Tencent Cloud EdgeOne API credentials
|
|
75
|
+
dns_edgeone_secret_id = YOUR_TENCENTCLOUD_SECRET_ID
|
|
76
|
+
dns_edgeone_secret_key = YOUR_TENCENTCLOUD_SECRET_KEY
|
|
77
|
+
|
|
78
|
+
# Optional: STS Security Token (if using temporary credentials)
|
|
79
|
+
# dns_edgeone_token = your_sts_token
|
|
80
|
+
|
|
81
|
+
# Optional: Manually specify EdgeOne Zone ID (skips auto-discovery)
|
|
82
|
+
# dns_edgeone_zone_id = zone-2noz78a8ev6k
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
4. **Secure the credentials file**:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
chmod 600 ~/.secrets/certbot/edgeone.ini
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Usage
|
|
94
|
+
|
|
95
|
+
### Request a Certificate
|
|
96
|
+
|
|
97
|
+
Run `certbot certonly` with the `dns-edgeone` authenticator:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
certbot certonly \
|
|
101
|
+
-a dns-edgeone \
|
|
102
|
+
--dns-edgeone-credentials ~/.secrets/certbot/edgeone.ini \
|
|
103
|
+
--dns-edgeone-propagation-seconds 30 \
|
|
104
|
+
-d example.com \
|
|
105
|
+
-d "*.example.com"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Automatic Renewal
|
|
109
|
+
|
|
110
|
+
Certbot renews certificates automatically before they expire (usually within 30 days) via cron or a systemd timer.
|
|
111
|
+
|
|
112
|
+
Test renewal using dry-run mode:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
certbot renew --dry-run
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Arguments
|
|
121
|
+
|
|
122
|
+
| Argument | Default | Description |
|
|
123
|
+
| :--- | :--- | :--- |
|
|
124
|
+
| `--dns-edgeone-credentials` | Required | Path to INI credentials file containing EdgeOne API `secret_id` and `secret_key` |
|
|
125
|
+
| `--dns-edgeone-propagation-seconds` | `30` | Seconds to wait for DNS propagation before ACME validation |
|
|
126
|
+
| `--dns-edgeone-zone-id` | Auto | Optional EdgeOne Zone ID (e.g. `zone-xxxxxx`) to override automatic discovery |
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Development & Testing
|
|
131
|
+
|
|
132
|
+
Run the test suite using `pytest`:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
uv pip install pytest
|
|
136
|
+
pytest -v
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
[Apache License 2.0](LICENSE)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# certbot-dns-edgeone
|
|
2
|
+
|
|
3
|
+
[English](README.md) | [中文](README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
适用于腾讯云 **EdgeOne (TEO)** 的 Certbot DNS 认证插件(类似于 `certbot-dns-aliyun` / `certbot-dns-cloudflare`),用于自动化申请与续期 Let's Encrypt 等 ACME SSL/TLS 证书(支持泛域名证书)。
|
|
6
|
+
|
|
7
|
+
通过调用腾讯云 EdgeOne API 自动添加和清理 `_acme-challenge` TXT 解析记录,完成 DNS-01 质询。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 特性
|
|
12
|
+
|
|
13
|
+
- **全自动 DNS-01 验证**:自动匹配站点 (Zone)、创建 TXT 记录、DNS 校验完成后自动清理记录。
|
|
14
|
+
- **支持泛域名**:支持申请单域名、多域名以及泛域名(通配符 `*.example.com`)证书。
|
|
15
|
+
- **国际化域名 (IDN / Punycode)**:原生支持中文、日文等 Punycode 域名转换。
|
|
16
|
+
- **智能站点匹配与缓存**:自动向上递归查找对应的 EdgeOne 站点 ID,并在证书申请过程中进行缓存,减少 API 调用。
|
|
17
|
+
- **自定义站点与传播延迟**:支持通过参数手动指定 `zone-id`,支持自定义 DNS 传播等待时间。
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 安装方法
|
|
22
|
+
|
|
23
|
+
### 通过 uv 安装(推荐)
|
|
24
|
+
|
|
25
|
+
在虚拟环境中使用 `uv`:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv pip install certbot-dns-edgeone
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
如果使用本项目本地源码安装:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
git clone https://github.com/hurole/certbot-dns-edgeone.git
|
|
35
|
+
cd certbot-dns-edgeone
|
|
36
|
+
|
|
37
|
+
# 创建并激活虚拟环境
|
|
38
|
+
uv venv
|
|
39
|
+
source .venv/bin/activate
|
|
40
|
+
|
|
41
|
+
# 安装开发与运行依赖
|
|
42
|
+
uv pip install -e .
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 通过 pip 安装
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install certbot certbot-dns-edgeone
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
验证插件是否已被 Certbot 正确识别:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
certbot plugins
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
若输出中包含 `dns-edgeone` 即表示安装成功:
|
|
58
|
+
```text
|
|
59
|
+
* dns-edgeone
|
|
60
|
+
Description: Obtain certificates using a DNS TXT record (if you are using Tencent Cloud EdgeOne for DNS).
|
|
61
|
+
Interfaces: Authenticator, Plugin
|
|
62
|
+
Entry point: dns-edgeone = certbot_dns_edgeone.dns_edgeone:Authenticator
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## 凭据准备
|
|
68
|
+
|
|
69
|
+
1. 前往 [腾讯云访问管理 (CAM) 控制台 - API 密钥管理](https://console.cloud.tencent.com/cam/capi) 创建或获取 API 密钥(`SecretId` 和 `SecretKey`)。
|
|
70
|
+
2. 请确保该子用户或密钥拥有 EdgeOne (TEO) 相关的权限策略(如 `QcloudTEOFullAccess`,或包含 `teo:DescribeZones`、`teo:CreateDnsRecord`、`teo:DescribeDnsRecords`、`teo:DeleteDnsRecords` 的自定义策略)。
|
|
71
|
+
3. 创建凭据配置文件(例如 `~/.secrets/certbot/edgeone.ini`):
|
|
72
|
+
|
|
73
|
+
```ini
|
|
74
|
+
# 腾讯云 EdgeOne API 凭据
|
|
75
|
+
dns_edgeone_secret_id = YOUR_TENCENTCLOUD_SECRET_ID
|
|
76
|
+
dns_edgeone_secret_key = YOUR_TENCENTCLOUD_SECRET_KEY
|
|
77
|
+
|
|
78
|
+
# 可选:如果使用 STS 临时密钥,填写 security token
|
|
79
|
+
# dns_edgeone_token = your_sts_token
|
|
80
|
+
|
|
81
|
+
# 可选:手动指定 EdgeOne 站点 ID(若指定则跳过站点自动搜索)
|
|
82
|
+
# dns_edgeone_zone_id = zone-2noz78a8ev6k
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
4. **重要安全保护**:为避免凭据泄露,请将该文件的权限限制为仅当前用户可读写:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
chmod 600 ~/.secrets/certbot/edgeone.ini
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 使用方法
|
|
94
|
+
|
|
95
|
+
### 申请证书
|
|
96
|
+
|
|
97
|
+
使用 `certbot certonly` 命令配合 `dns-edgeone` 认证器:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
certbot certonly \
|
|
101
|
+
-a dns-edgeone \
|
|
102
|
+
--dns-edgeone-credentials ~/.secrets/certbot/edgeone.ini \
|
|
103
|
+
--dns-edgeone-propagation-seconds 30 \
|
|
104
|
+
-d example.com \
|
|
105
|
+
-d "*.example.com"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### 自动续期
|
|
109
|
+
|
|
110
|
+
Certbot 会在证书过期前(通常剩余 30 天内)通过定时任务(如 cron 或 systemd timer)执行 `certbot renew`。
|
|
111
|
+
|
|
112
|
+
由于认证参数和凭据路径已保存在续期配置中,只需测试续期命令:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
certbot renew --dry-run
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 参数说明
|
|
121
|
+
|
|
122
|
+
| 参数 | 默认值 | 描述 |
|
|
123
|
+
| :--- | :--- | :--- |
|
|
124
|
+
| `--dns-edgeone-credentials` | 必填 | 包含 EdgeOne API `secret_id` 和 `secret_key` 的 INI 配置文件路径 |
|
|
125
|
+
| `--dns-edgeone-propagation-seconds` | `30` | 创建 DNS 记录后等待 DNS 解析生效的等待秒数 |
|
|
126
|
+
| `--dns-edgeone-zone-id` | 自动匹配 | 可选,手动指定 EdgeOne 站点 ID(形如 `zone-xxxxxx`) |
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## 开发与测试
|
|
131
|
+
|
|
132
|
+
在虚拟环境中运行测试:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
# 安装测试工具
|
|
136
|
+
uv pip install pytest
|
|
137
|
+
|
|
138
|
+
# 执行单元测试
|
|
139
|
+
pytest -v
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
[Apache License 2.0](LICENSE)
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""EdgeOne (TEO) DNS API client wrapper."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
from certbot import errors
|
|
7
|
+
from certbot.plugins import dns_common
|
|
8
|
+
from tencentcloud.common import credential
|
|
9
|
+
from tencentcloud.common.exception.tencent_cloud_sdk_exception import (
|
|
10
|
+
TencentCloudSDKException,
|
|
11
|
+
)
|
|
12
|
+
from tencentcloud.common.profile.client_profile import ClientProfile
|
|
13
|
+
from tencentcloud.common.profile.http_profile import HttpProfile
|
|
14
|
+
from tencentcloud.teo.v20220901 import models, teo_client
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
DEFAULT_TTL = 60
|
|
19
|
+
DEFAULT_ENDPOINT = "teo.tencentcloudapi.com"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EdgeOneDNSClient:
|
|
23
|
+
"""Encapsulates all communication with Tencent Cloud EdgeOne (TEO) API."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
secret_id: str,
|
|
28
|
+
secret_key: str,
|
|
29
|
+
token: Optional[str] = None,
|
|
30
|
+
zone_id: Optional[str] = None,
|
|
31
|
+
ttl: int = DEFAULT_TTL,
|
|
32
|
+
endpoint: str = DEFAULT_ENDPOINT,
|
|
33
|
+
):
|
|
34
|
+
self.secret_id = secret_id
|
|
35
|
+
self.secret_key = secret_key
|
|
36
|
+
self.token = token
|
|
37
|
+
self.zone_id = zone_id
|
|
38
|
+
self.ttl = max(60, ttl) # EdgeOne minimum TTL is 60 seconds
|
|
39
|
+
self.endpoint = endpoint
|
|
40
|
+
|
|
41
|
+
self._client: Optional[teo_client.TeoClient] = None
|
|
42
|
+
self._zone_cache: Dict[str, str] = {}
|
|
43
|
+
self._created_records: Dict[Tuple[str, str], Tuple[str, str]] = {}
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def client(self) -> teo_client.TeoClient:
|
|
47
|
+
if self._client is None:
|
|
48
|
+
cred = credential.Credential(self.secret_id, self.secret_key, self.token)
|
|
49
|
+
http_profile = HttpProfile()
|
|
50
|
+
http_profile.endpoint = self.endpoint
|
|
51
|
+
client_profile = ClientProfile(httpProfile=http_profile)
|
|
52
|
+
self._client = teo_client.TeoClient(cred, "", client_profile)
|
|
53
|
+
return self._client
|
|
54
|
+
|
|
55
|
+
def _to_punycode(self, name: str) -> str:
|
|
56
|
+
"""Convert internationalized domain names (IDN) to punycode format."""
|
|
57
|
+
try:
|
|
58
|
+
return name.encode("idna").decode("ascii")
|
|
59
|
+
except Exception:
|
|
60
|
+
return name
|
|
61
|
+
|
|
62
|
+
def _find_zone_id(self, domain: str) -> str:
|
|
63
|
+
"""Find the ZoneId corresponding to the given domain."""
|
|
64
|
+
if self.zone_id:
|
|
65
|
+
return self.zone_id
|
|
66
|
+
|
|
67
|
+
norm_domain = domain.lstrip("*.").rstrip(".")
|
|
68
|
+
ascii_domain = self._to_punycode(norm_domain)
|
|
69
|
+
|
|
70
|
+
if ascii_domain in self._zone_cache:
|
|
71
|
+
return self._zone_cache[ascii_domain]
|
|
72
|
+
|
|
73
|
+
guesses = [
|
|
74
|
+
g
|
|
75
|
+
for g in dns_common.base_domain_name_guesses(ascii_domain)
|
|
76
|
+
if g and not g.startswith("*")
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
for guess in guesses:
|
|
80
|
+
try:
|
|
81
|
+
req = models.DescribeZonesRequest()
|
|
82
|
+
f = models.AdvancedFilter()
|
|
83
|
+
f.Name = "zone-name"
|
|
84
|
+
f.Values = [guess]
|
|
85
|
+
f.Fuzzy = False
|
|
86
|
+
req.Filters = [f]
|
|
87
|
+
req.Limit = 20
|
|
88
|
+
|
|
89
|
+
resp = self.client.DescribeZones(req)
|
|
90
|
+
if resp.Zones:
|
|
91
|
+
for zone in resp.Zones:
|
|
92
|
+
if zone.ZoneName == guess:
|
|
93
|
+
self._zone_cache[ascii_domain] = zone.ZoneId
|
|
94
|
+
return zone.ZoneId
|
|
95
|
+
except TencentCloudSDKException as e:
|
|
96
|
+
raise self._handle_sdk_error(
|
|
97
|
+
e, f"Error searching zone for {guess} (domain: {domain})"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
raise errors.PluginError(
|
|
101
|
+
f"Unable to determine EdgeOne zone identifier for {domain} using candidates: {guesses}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def add_txt_record(self, domain: str, record_name: str, value: str) -> str:
|
|
105
|
+
"""Create a DNS TXT record for domain validation.
|
|
106
|
+
|
|
107
|
+
:param str domain: The target domain.
|
|
108
|
+
:param str record_name: The validation record name (FQDN).
|
|
109
|
+
:param str value: The validation value (TXT content).
|
|
110
|
+
:return: The created record ID.
|
|
111
|
+
"""
|
|
112
|
+
zone_id = self._find_zone_id(domain)
|
|
113
|
+
name = self._to_punycode(record_name.rstrip("."))
|
|
114
|
+
|
|
115
|
+
req = models.CreateDnsRecordRequest()
|
|
116
|
+
req.ZoneId = zone_id
|
|
117
|
+
req.Type = "TXT"
|
|
118
|
+
req.Name = name
|
|
119
|
+
req.Content = value
|
|
120
|
+
req.TTL = self.ttl
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
resp = self.client.CreateDnsRecord(req)
|
|
124
|
+
record_id = resp.RecordId
|
|
125
|
+
self._created_records[(name, value)] = (zone_id, record_id)
|
|
126
|
+
logger.debug(
|
|
127
|
+
"Created EdgeOne TXT record '%s' with ID %s in zone %s",
|
|
128
|
+
name,
|
|
129
|
+
record_id,
|
|
130
|
+
zone_id,
|
|
131
|
+
)
|
|
132
|
+
return record_id
|
|
133
|
+
except TencentCloudSDKException as e:
|
|
134
|
+
raise self._handle_sdk_error(
|
|
135
|
+
e, f"Error creating TXT record '{name}' for domain '{domain}'"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def del_txt_record(self, domain: str, record_name: str, value: str) -> None:
|
|
139
|
+
"""Delete a DNS TXT record.
|
|
140
|
+
|
|
141
|
+
:param str domain: The target domain.
|
|
142
|
+
:param str record_name: The validation record name (FQDN).
|
|
143
|
+
:param str value: The validation value (TXT content).
|
|
144
|
+
"""
|
|
145
|
+
name = self._to_punycode(record_name.rstrip("."))
|
|
146
|
+
|
|
147
|
+
# 1. Check if record was created in this session
|
|
148
|
+
cached = self._created_records.pop((name, value), None)
|
|
149
|
+
if cached:
|
|
150
|
+
zone_id, record_id = cached
|
|
151
|
+
self._delete_records_by_id(zone_id, [record_id], name)
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
# 2. If not cached, lookup by querying DescribeDnsRecords
|
|
155
|
+
try:
|
|
156
|
+
zone_id = self._find_zone_id(domain)
|
|
157
|
+
except errors.PluginError as e:
|
|
158
|
+
logger.warning("Could not determine zone ID during cleanup of %s: %s", name, e)
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
req = models.DescribeDnsRecordsRequest()
|
|
163
|
+
req.ZoneId = zone_id
|
|
164
|
+
f_name = models.AdvancedFilter()
|
|
165
|
+
f_name.Name = "name"
|
|
166
|
+
f_name.Values = [name]
|
|
167
|
+
f_name.Fuzzy = False
|
|
168
|
+
|
|
169
|
+
f_type = models.AdvancedFilter()
|
|
170
|
+
f_type.Name = "type"
|
|
171
|
+
f_type.Values = ["TXT"]
|
|
172
|
+
f_type.Fuzzy = False
|
|
173
|
+
|
|
174
|
+
req.Filters = [f_name, f_type]
|
|
175
|
+
req.Limit = 100
|
|
176
|
+
|
|
177
|
+
resp = self.client.DescribeDnsRecords(req)
|
|
178
|
+
record_ids_to_del: List[str] = []
|
|
179
|
+
if resp.DnsRecords:
|
|
180
|
+
for rec in resp.DnsRecords:
|
|
181
|
+
# Strip quotation marks if present
|
|
182
|
+
if rec.Content and rec.Content.strip('"') == value.strip('"'):
|
|
183
|
+
record_ids_to_del.append(rec.RecordId)
|
|
184
|
+
|
|
185
|
+
if record_ids_to_del:
|
|
186
|
+
self._delete_records_by_id(zone_id, record_ids_to_del, name)
|
|
187
|
+
else:
|
|
188
|
+
logger.debug("No matching EdgeOne TXT record found for %s to delete", name)
|
|
189
|
+
except TencentCloudSDKException as e:
|
|
190
|
+
logger.warning("Error searching for TXT record %s to delete: %s", name, e)
|
|
191
|
+
|
|
192
|
+
def _delete_records_by_id(
|
|
193
|
+
self, zone_id: str, record_ids: List[str], record_name: str
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Helper to invoke DeleteDnsRecords."""
|
|
196
|
+
req = models.DeleteDnsRecordsRequest()
|
|
197
|
+
req.ZoneId = zone_id
|
|
198
|
+
req.RecordIds = record_ids
|
|
199
|
+
try:
|
|
200
|
+
self.client.DeleteDnsRecords(req)
|
|
201
|
+
logger.debug(
|
|
202
|
+
"Deleted EdgeOne TXT record(s) %s for %s in zone %s",
|
|
203
|
+
record_ids,
|
|
204
|
+
record_name,
|
|
205
|
+
zone_id,
|
|
206
|
+
)
|
|
207
|
+
except TencentCloudSDKException as e:
|
|
208
|
+
logger.warning(
|
|
209
|
+
"Failed to delete EdgeOne TXT record(s) %s for %s: %s",
|
|
210
|
+
record_ids,
|
|
211
|
+
record_name,
|
|
212
|
+
e,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _handle_sdk_error(
|
|
216
|
+
self, e: TencentCloudSDKException, context: str
|
|
217
|
+
) -> errors.PluginError:
|
|
218
|
+
"""Format TencentCloud SDK error messages with troubleshooting hints."""
|
|
219
|
+
msg = f"{context}: [{e.code}] {e.message} (RequestId: {e.requestId})"
|
|
220
|
+
code_str = str(e.code)
|
|
221
|
+
if any(k in code_str for k in ("AuthFailure", "SecretId", "Signature")):
|
|
222
|
+
msg += (
|
|
223
|
+
"\nTroubleshooting: Please ensure your secret_id and secret_key are valid "
|
|
224
|
+
"and have the required EdgeOne permissions in Tencent Cloud CAM."
|
|
225
|
+
)
|
|
226
|
+
elif "ResourceNotFound" in code_str:
|
|
227
|
+
msg += "\nTroubleshooting: The specified EdgeOne zone or record was not found."
|
|
228
|
+
elif "UnauthorizedOperation" in code_str:
|
|
229
|
+
msg += (
|
|
230
|
+
"\nTroubleshooting: Your Tencent Cloud CAM user/role does not have permission "
|
|
231
|
+
"to perform this action on EdgeOne."
|
|
232
|
+
)
|
|
233
|
+
return errors.PluginError(msg)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""DNS Authenticator for Tencent Cloud EdgeOne (TEO)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from certbot.plugins import dns_common
|
|
7
|
+
from certbot_dns_edgeone.client import DEFAULT_TTL, EdgeOneDNSClient
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Authenticator(dns_common.DNSAuthenticator):
|
|
13
|
+
"""DNS Authenticator for Tencent Cloud EdgeOne (TEO)
|
|
14
|
+
|
|
15
|
+
This Authenticator uses the Tencent Cloud EdgeOne API to fulfill a dns-01 challenge.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
description = (
|
|
19
|
+
"Obtain certificates using a DNS TXT record (if you are using Tencent Cloud EdgeOne for DNS)."
|
|
20
|
+
)
|
|
21
|
+
ttl = DEFAULT_TTL
|
|
22
|
+
_client: Optional[EdgeOneDNSClient] = None
|
|
23
|
+
|
|
24
|
+
def __init__(self, *args, **kwargs):
|
|
25
|
+
super().__init__(*args, **kwargs)
|
|
26
|
+
self.credentials = None
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def add_parser_arguments(cls, add, default_propagation_seconds=30):
|
|
30
|
+
super().add_parser_arguments(
|
|
31
|
+
add, default_propagation_seconds=default_propagation_seconds
|
|
32
|
+
)
|
|
33
|
+
add("credentials", help="Tencent Cloud EdgeOne credentials INI file.")
|
|
34
|
+
add("zone-id", help="Optional EdgeOne Zone ID (e.g. zone-xxxxxxxx) to override auto-discovery.")
|
|
35
|
+
|
|
36
|
+
def more_info(self) -> str:
|
|
37
|
+
return (
|
|
38
|
+
"This plugin configures a DNS TXT record to respond to a dns-01 challenge using "
|
|
39
|
+
"the Tencent Cloud EdgeOne (TEO) API."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def _setup_credentials(self) -> None:
|
|
43
|
+
self.credentials = self._configure_credentials(
|
|
44
|
+
"credentials",
|
|
45
|
+
"Tencent Cloud EdgeOne credentials INI file",
|
|
46
|
+
{
|
|
47
|
+
"secret-id": "SecretId for Tencent Cloud API, obtained from CAM console",
|
|
48
|
+
"secret-key": "SecretKey for Tencent Cloud API, obtained from CAM console",
|
|
49
|
+
},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def _get_client(self) -> EdgeOneDNSClient:
|
|
53
|
+
if not self._client:
|
|
54
|
+
secret_id = self.credentials.conf("secret-id")
|
|
55
|
+
secret_key = self.credentials.conf("secret-key")
|
|
56
|
+
token = self.credentials.conf("token")
|
|
57
|
+
|
|
58
|
+
zone_id = self.conf("zone-id") or self.credentials.conf("zone-id")
|
|
59
|
+
|
|
60
|
+
self._client = EdgeOneDNSClient(
|
|
61
|
+
secret_id=secret_id,
|
|
62
|
+
secret_key=secret_key,
|
|
63
|
+
token=token,
|
|
64
|
+
zone_id=zone_id,
|
|
65
|
+
ttl=self.ttl,
|
|
66
|
+
)
|
|
67
|
+
return self._client
|
|
68
|
+
|
|
69
|
+
def _perform(self, domain: str, validation_name: str, validation: str) -> None:
|
|
70
|
+
self._get_client().add_txt_record(domain, validation_name, validation)
|
|
71
|
+
|
|
72
|
+
def _cleanup(self, domain: str, validation_name: str, validation: str) -> None:
|
|
73
|
+
self._get_client().del_txt_record(domain, validation_name, validation)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# 腾讯云 EdgeOne (TEO) API 凭据文件
|
|
2
|
+
# 获取方式:登录腾讯云控制台 -> 访问管理 (CAM) -> API 密钥管理
|
|
3
|
+
# 注意:该文件包含敏感凭据,请确保权限为 600 (chmod 600 /path/to/credentials.ini)
|
|
4
|
+
|
|
5
|
+
dns_edgeone_secret_id = YOUR_TENCENTCLOUD_SECRET_ID
|
|
6
|
+
dns_edgeone_secret_key = YOUR_TENCENTCLOUD_SECRET_KEY
|
|
7
|
+
|
|
8
|
+
# 可选配置(若使用 STS 临时密钥,需配置 token):
|
|
9
|
+
# dns_edgeone_token = your_security_token
|
|
10
|
+
|
|
11
|
+
# 可选配置(指定特定的 EdgeOne 站点 ID,跳过自动查找站点):
|
|
12
|
+
# dns_edgeone_zone_id = zone-2noz78a8ev6k
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "certbot-dns-edgeone"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Tencent Cloud EdgeOne (TEO) DNS Authenticator plugin for Certbot"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Developer" }
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Plugins",
|
|
18
|
+
"Intended Audience :: System Administrators",
|
|
19
|
+
"License :: OSI Approved :: Apache Software License",
|
|
20
|
+
"Operating System :: POSIX :: Linux",
|
|
21
|
+
"Operating System :: MacOS",
|
|
22
|
+
"Programming Language :: Python",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.9",
|
|
25
|
+
"Programming Language :: Python :: 3.10",
|
|
26
|
+
"Programming Language :: Python :: 3.11",
|
|
27
|
+
"Programming Language :: Python :: 3.12",
|
|
28
|
+
"Topic :: Internet :: Name Service (DNS)",
|
|
29
|
+
"Topic :: Security",
|
|
30
|
+
"Topic :: System :: Systems Administration",
|
|
31
|
+
]
|
|
32
|
+
dependencies = [
|
|
33
|
+
"certbot>=2.0.0",
|
|
34
|
+
"tencentcloud-sdk-python-teo>=3.0.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/hurole/certbot-dns-edgeone"
|
|
39
|
+
Repository = "https://github.com/hurole/certbot-dns-edgeone"
|
|
40
|
+
Issues = "https://github.com/hurole/certbot-dns-edgeone/issues"
|
|
41
|
+
|
|
42
|
+
[project.entry-points."certbot.plugins"]
|
|
43
|
+
dns-edgeone = "certbot_dns_edgeone.dns_edgeone:Authenticator"
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=7.0.0",
|
|
48
|
+
"pytest-mock>=3.10.0",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[tool.hatch.build.targets.wheel]
|
|
52
|
+
packages = ["certbot_dns_edgeone"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tests for certbot-dns-edgeone."""
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Unit tests for certbot_dns_edgeone."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
from certbot import errors
|
|
9
|
+
from certbot.plugins import dns_test_common
|
|
10
|
+
from tencentcloud.common.exception.tencent_cloud_sdk_exception import (
|
|
11
|
+
TencentCloudSDKException,
|
|
12
|
+
)
|
|
13
|
+
from tencentcloud.teo.v20220901 import models
|
|
14
|
+
|
|
15
|
+
from certbot_dns_edgeone.client import EdgeOneDNSClient
|
|
16
|
+
from certbot_dns_edgeone.dns_edgeone import Authenticator
|
|
17
|
+
|
|
18
|
+
API_SECRET_ID = "test-secret-id"
|
|
19
|
+
API_SECRET_KEY = "test-secret-key"
|
|
20
|
+
ZONE_ID = "zone-12345678"
|
|
21
|
+
DOMAIN = "example.com"
|
|
22
|
+
VALIDATION_NAME = "_acme-challenge.example.com"
|
|
23
|
+
VALIDATION_VALUE = "validationsampletoken123"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EdgeOneDNSClientTest(unittest.TestCase):
|
|
27
|
+
def setUp(self):
|
|
28
|
+
self.client = EdgeOneDNSClient(
|
|
29
|
+
secret_id=API_SECRET_ID,
|
|
30
|
+
secret_key=API_SECRET_KEY,
|
|
31
|
+
ttl=60,
|
|
32
|
+
)
|
|
33
|
+
self.mock_teo_client = MagicMock()
|
|
34
|
+
self.client._client = self.mock_teo_client
|
|
35
|
+
|
|
36
|
+
def test_find_zone_id_explicit(self):
|
|
37
|
+
self.client.zone_id = "zone-explicit"
|
|
38
|
+
self.assertEqual(self.client._find_zone_id("test.example.com"), "zone-explicit")
|
|
39
|
+
self.mock_teo_client.DescribeZones.assert_not_called()
|
|
40
|
+
|
|
41
|
+
def test_find_zone_id_auto(self):
|
|
42
|
+
resp = models.DescribeZonesResponse()
|
|
43
|
+
zone = models.Zone()
|
|
44
|
+
zone.ZoneId = ZONE_ID
|
|
45
|
+
zone.ZoneName = "example.com"
|
|
46
|
+
resp.Zones = [zone]
|
|
47
|
+
resp.TotalCount = 1
|
|
48
|
+
self.mock_teo_client.DescribeZones.return_value = resp
|
|
49
|
+
|
|
50
|
+
zone_id = self.client._find_zone_id("sub.example.com")
|
|
51
|
+
self.assertEqual(zone_id, ZONE_ID)
|
|
52
|
+
# Verify caching
|
|
53
|
+
self.assertEqual(self.client._zone_cache.get("sub.example.com"), ZONE_ID)
|
|
54
|
+
# Calling again should use cache without invoking API
|
|
55
|
+
self.mock_teo_client.DescribeZones.reset_mock()
|
|
56
|
+
self.assertEqual(self.client._find_zone_id("sub.example.com"), ZONE_ID)
|
|
57
|
+
self.mock_teo_client.DescribeZones.assert_not_called()
|
|
58
|
+
|
|
59
|
+
def test_find_zone_id_not_found(self):
|
|
60
|
+
resp = models.DescribeZonesResponse()
|
|
61
|
+
resp.Zones = []
|
|
62
|
+
resp.TotalCount = 0
|
|
63
|
+
self.mock_teo_client.DescribeZones.return_value = resp
|
|
64
|
+
|
|
65
|
+
with self.assertRaises(errors.PluginError):
|
|
66
|
+
self.client._find_zone_id("notfound.org")
|
|
67
|
+
|
|
68
|
+
def test_find_zone_id_sdk_error(self):
|
|
69
|
+
self.mock_teo_client.DescribeZones.side_effect = TencentCloudSDKException(
|
|
70
|
+
code="AuthFailure", message="SecretId is invalid", requestId="req-1"
|
|
71
|
+
)
|
|
72
|
+
with self.assertRaises(errors.PluginError) as ctx:
|
|
73
|
+
self.client._find_zone_id("example.com")
|
|
74
|
+
self.assertIn("AuthFailure", str(ctx.exception))
|
|
75
|
+
|
|
76
|
+
def test_add_txt_record(self):
|
|
77
|
+
# mock zone discovery
|
|
78
|
+
self.client._zone_cache["example.com"] = ZONE_ID
|
|
79
|
+
|
|
80
|
+
resp = models.CreateDnsRecordResponse()
|
|
81
|
+
resp.RecordId = "record-987"
|
|
82
|
+
self.mock_teo_client.CreateDnsRecord.return_value = resp
|
|
83
|
+
|
|
84
|
+
rec_id = self.client.add_txt_record(DOMAIN, VALIDATION_NAME, VALIDATION_VALUE)
|
|
85
|
+
self.assertEqual(rec_id, "record-987")
|
|
86
|
+
|
|
87
|
+
self.mock_teo_client.CreateDnsRecord.assert_called_once()
|
|
88
|
+
req = self.mock_teo_client.CreateDnsRecord.call_args[0][0]
|
|
89
|
+
self.assertEqual(req.ZoneId, ZONE_ID)
|
|
90
|
+
self.assertEqual(req.Name, VALIDATION_NAME)
|
|
91
|
+
self.assertEqual(req.Type, "TXT")
|
|
92
|
+
self.assertEqual(req.Content, VALIDATION_VALUE)
|
|
93
|
+
self.assertEqual(req.TTL, 60)
|
|
94
|
+
|
|
95
|
+
def test_del_txt_record_cached(self):
|
|
96
|
+
self.client._created_records[(VALIDATION_NAME, VALIDATION_VALUE)] = (
|
|
97
|
+
ZONE_ID,
|
|
98
|
+
"record-987",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
resp = models.DeleteDnsRecordsResponse()
|
|
102
|
+
resp.RequestId = "req-del-1"
|
|
103
|
+
self.mock_teo_client.DeleteDnsRecords.return_value = resp
|
|
104
|
+
|
|
105
|
+
self.client.del_txt_record(DOMAIN, VALIDATION_NAME, VALIDATION_VALUE)
|
|
106
|
+
|
|
107
|
+
self.mock_teo_client.DeleteDnsRecords.assert_called_once()
|
|
108
|
+
req = self.mock_teo_client.DeleteDnsRecords.call_args[0][0]
|
|
109
|
+
self.assertEqual(req.ZoneId, ZONE_ID)
|
|
110
|
+
self.assertEqual(req.RecordIds, ["record-987"])
|
|
111
|
+
|
|
112
|
+
def test_del_txt_record_uncached_query(self):
|
|
113
|
+
self.client._zone_cache["example.com"] = ZONE_ID
|
|
114
|
+
|
|
115
|
+
# Mock DescribeDnsRecords
|
|
116
|
+
resp_desc = models.DescribeDnsRecordsResponse()
|
|
117
|
+
r1 = models.DnsRecord()
|
|
118
|
+
r1.RecordId = "record-query-1"
|
|
119
|
+
r1.Content = f'"{VALIDATION_VALUE}"' # Quotes in content
|
|
120
|
+
resp_desc.DnsRecords = [r1]
|
|
121
|
+
self.mock_teo_client.DescribeDnsRecords.return_value = resp_desc
|
|
122
|
+
|
|
123
|
+
resp_del = models.DeleteDnsRecordsResponse()
|
|
124
|
+
self.mock_teo_client.DeleteDnsRecords.return_value = resp_del
|
|
125
|
+
|
|
126
|
+
self.client.del_txt_record(DOMAIN, VALIDATION_NAME, VALIDATION_VALUE)
|
|
127
|
+
|
|
128
|
+
self.mock_teo_client.DescribeDnsRecords.assert_called_once()
|
|
129
|
+
self.mock_teo_client.DeleteDnsRecords.assert_called_once()
|
|
130
|
+
req = self.mock_teo_client.DeleteDnsRecords.call_args[0][0]
|
|
131
|
+
self.assertEqual(req.ZoneId, ZONE_ID)
|
|
132
|
+
self.assertEqual(req.RecordIds, ["record-query-1"])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class AuthenticatorPluginTest(unittest.TestCase):
|
|
136
|
+
def setUp(self):
|
|
137
|
+
self.temp_dir = tempfile.TemporaryDirectory()
|
|
138
|
+
self.config_path = os.path.join(self.temp_dir.name, "credentials.ini")
|
|
139
|
+
dns_test_common.write(
|
|
140
|
+
{
|
|
141
|
+
"edgeone_secret_id": API_SECRET_ID,
|
|
142
|
+
"edgeone_secret_key": API_SECRET_KEY,
|
|
143
|
+
},
|
|
144
|
+
self.config_path,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
self.config = MagicMock()
|
|
148
|
+
self.config.edgeone_credentials = self.config_path
|
|
149
|
+
self.config.edgeone_zone_id = None
|
|
150
|
+
self.config.edgeone_propagation_seconds = 0
|
|
151
|
+
|
|
152
|
+
self.auth = Authenticator(self.config, "edgeone")
|
|
153
|
+
self.mock_client = MagicMock()
|
|
154
|
+
self.auth._get_client = MagicMock(return_value=self.mock_client)
|
|
155
|
+
|
|
156
|
+
def tearDown(self):
|
|
157
|
+
self.temp_dir.cleanup()
|
|
158
|
+
|
|
159
|
+
def test_more_info(self):
|
|
160
|
+
self.assertIsInstance(self.auth.more_info(), str)
|
|
161
|
+
|
|
162
|
+
def test_perform(self):
|
|
163
|
+
self.auth._perform(DOMAIN, VALIDATION_NAME, VALIDATION_VALUE)
|
|
164
|
+
self.mock_client.add_txt_record.assert_called_once_with(
|
|
165
|
+
DOMAIN, VALIDATION_NAME, VALIDATION_VALUE
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def test_cleanup(self):
|
|
169
|
+
self.auth._cleanup(DOMAIN, VALIDATION_NAME, VALIDATION_VALUE)
|
|
170
|
+
self.mock_client.del_txt_record.assert_called_once_with(
|
|
171
|
+
DOMAIN, VALIDATION_NAME, VALIDATION_VALUE
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
unittest.main()
|