certbot-dns-beget-plugin 1.0.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_beget_plugin-1.0.0/LICENSE +22 -0
- certbot_dns_beget_plugin-1.0.0/PKG-INFO +166 -0
- certbot_dns_beget_plugin-1.0.0/README.md +134 -0
- certbot_dns_beget_plugin-1.0.0/certbot_dns_beget_plugin/__init__.py +0 -0
- certbot_dns_beget_plugin-1.0.0/certbot_dns_beget_plugin/authenticator.py +76 -0
- certbot_dns_beget_plugin-1.0.0/certbot_dns_beget_plugin/client.py +324 -0
- certbot_dns_beget_plugin-1.0.0/pyproject.toml +90 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Medan-rfz
|
|
4
|
+
Copyright (c) 2026 Roman Sarkisyan
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: certbot-dns-beget-plugin
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Certbot DNS plugin for Beget to automate Let's Encrypt certificate issuance with the DNS challenge
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: certbot,letsencrypt,acme,dns,beget
|
|
8
|
+
Author: Roman Sarkisyan
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Environment :: Plugins
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Internet :: Name Service (DNS)
|
|
22
|
+
Classifier: Topic :: Security
|
|
23
|
+
Classifier: Topic :: System :: Systems Administration
|
|
24
|
+
Requires-Dist: certbot (>=2.0)
|
|
25
|
+
Requires-Dist: requests (>=2.20)
|
|
26
|
+
Project-URL: Changelog, https://github.com/l1n-x/certbot-dns-beget-plugin/blob/master/CHANGELOG.md
|
|
27
|
+
Project-URL: Homepage, https://github.com/l1n-x/certbot-dns-beget-plugin
|
|
28
|
+
Project-URL: Issues, https://github.com/l1n-x/certbot-dns-beget-plugin/issues
|
|
29
|
+
Project-URL: Repository, https://github.com/l1n-x/certbot-dns-beget-plugin.git
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# certbot-dns-beget-plugin
|
|
33
|
+
|
|
34
|
+
[](https://github.com/l1n-x/certbot-dns-beget-plugin/actions/workflows/ci.yml)
|
|
35
|
+
|
|
36
|
+
**EN** | [RU](https://github.com/l1n-x/certbot-dns-beget-plugin/blob/master/docs/README_RU.md)
|
|
37
|
+
|
|
38
|
+
A [Certbot](https://certbot.eff.org/) plugin that obtains Let's Encrypt certificates using the DNS challenge (dns-01) for domains served by [Beget](https://beget.com/) DNS. The `_acme-challenge` TXT record is created and removed automatically through the [Beget API](https://beget.com/ru/kb/api/obshhij-princzip-raboty-s-api).
|
|
39
|
+
|
|
40
|
+
The DNS challenge lets you obtain wildcard certificates (`*.example.com`) and does not require port 80 to be open.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
The plugin must be installed into the same Python environment as Certbot.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install certbot-dns-beget-plugin
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
If Certbot was installed into a virtual environment, as in the
|
|
51
|
+
[EFF pip instructions](https://certbot.eff.org/instructions?ws=other&os=pip), use that
|
|
52
|
+
environment's pip:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
sudo /opt/certbot/bin/pip install certbot-dns-beget-plugin
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
> **Note:** Certbot installed with **snap** does not see plugins installed with pip: snap plugins have to
|
|
59
|
+
> be packaged as separate snaps. The same goes for Certbot from distribution packages (`apt`,
|
|
60
|
+
> `dnf`): modern distributions do not let pip install into the system Python. In both cases,
|
|
61
|
+
> install Certbot with pip into a virtual environment using the instructions above.
|
|
62
|
+
|
|
63
|
+
The latest development version can be installed straight from GitHub:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install git+https://github.com/l1n-x/certbot-dns-beget-plugin
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Check that Certbot sees the plugin:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
certbot plugins
|
|
73
|
+
# * dns-beget-plugin
|
|
74
|
+
# Description: Obtain certificates using a DNS TXT record (if you are using Beget for DNS).
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Configuration
|
|
78
|
+
|
|
79
|
+
1. In the Beget control panel, open the **API** section, enable API access and set an API password.
|
|
80
|
+
2. Create a credentials file, for example `/etc/letsencrypt/beget.ini`:
|
|
81
|
+
|
|
82
|
+
```ini
|
|
83
|
+
dns_beget_plugin_username = your_beget_login
|
|
84
|
+
dns_beget_plugin_password = your_api_password
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
3. Restrict access to the file:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
chmod 600 /etc/letsencrypt/beget.ini
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Usage
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
certbot certonly \
|
|
97
|
+
--authenticator dns-beget-plugin \
|
|
98
|
+
--dns-beget-plugin-credentials /etc/letsencrypt/beget.ini \
|
|
99
|
+
-d example.com \
|
|
100
|
+
-d '*.example.com'
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
| Option | Description |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `--dns-beget-plugin-credentials` | Path to the INI file with credentials (required). |
|
|
106
|
+
| `--dns-beget-plugin-propagation-seconds` | Seconds to wait for the DNS record to propagate before validation. Default: `120`. |
|
|
107
|
+
|
|
108
|
+
## Renewal
|
|
109
|
+
|
|
110
|
+
Certbot saves the plugin options in `/etc/letsencrypt/renewal/<domain>.conf`, so `certbot renew`
|
|
111
|
+
uses the same plugin and credentials file without extra arguments:
|
|
112
|
+
|
|
113
|
+
```ini
|
|
114
|
+
authenticator = dns-beget-plugin
|
|
115
|
+
dns_beget_plugin_credentials = /etc/letsencrypt/beget.ini
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Keep the credentials file at the path given when the certificate was obtained. Check that renewal
|
|
119
|
+
works:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
sudo certbot renew --dry-run
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
If Certbot is installed in `/opt/certbot` as in the
|
|
126
|
+
[EFF pip instructions](https://certbot.eff.org/instructions?ws=other&os=pip), renewal is not
|
|
127
|
+
scheduled automatically. Add a cron job that tries twice a day at a random minute:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
echo "0 0,12 * * * root /opt/certbot/bin/python -c 'import random; import time; time.sleep(random.random() * 3600)' && /opt/certbot/bin/certbot renew -q" | sudo tee -a /etc/crontab > /dev/null
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
To reload the web server after a certificate is renewed, put a script into
|
|
134
|
+
`/etc/letsencrypt/renewal-hooks/deploy/`:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'SH'
|
|
138
|
+
#!/bin/sh
|
|
139
|
+
systemctl reload nginx
|
|
140
|
+
SH
|
|
141
|
+
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## How it works
|
|
145
|
+
|
|
146
|
+
- If the `_acme-challenge.<domain>` subdomain does not exist on the account, the plugin creates it and deletes it, together with its records, after validation.
|
|
147
|
+
- If the subdomain already exists, the plugin adds a TXT record to it and removes only that record after validation. Existing A, AAAA, MX and TXT records are preserved, although the Beget API resets their TTL to 600 seconds.
|
|
148
|
+
- The plugin refuses to change records if the name is delegated via NS or CNAME, or if it has records of other types that the Beget API cannot preserve.
|
|
149
|
+
|
|
150
|
+
## Limitations
|
|
151
|
+
|
|
152
|
+
- New records usually appear on the Beget name servers within a minute, so the default 120-second wait leaves some margin. If validation fails because the record is not found, increase `--dns-beget-plugin-propagation-seconds`.
|
|
153
|
+
- The Beget API can only replace all records of a name at once. Do not run several Certbot processes for the same domain at the same time: they can overwrite each other's TXT records, and validation will fail.
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
See [CONTRIBUTING.md](https://github.com/l1n-x/certbot-dns-beget-plugin/blob/master/CONTRIBUTING.md) for setting up the project, running the tests and releasing.
|
|
158
|
+
|
|
159
|
+
## Acknowledgements
|
|
160
|
+
|
|
161
|
+
Based on [certbot-dns-beget-api](https://github.com/Medan-rfz/certbot-dns-beget-api) by Medan-rfz (MIT License).
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
[MIT](https://github.com/l1n-x/certbot-dns-beget-plugin/blob/master/LICENSE)
|
|
166
|
+
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# certbot-dns-beget-plugin
|
|
2
|
+
|
|
3
|
+
[](https://github.com/l1n-x/certbot-dns-beget-plugin/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
**EN** | [RU](https://github.com/l1n-x/certbot-dns-beget-plugin/blob/master/docs/README_RU.md)
|
|
6
|
+
|
|
7
|
+
A [Certbot](https://certbot.eff.org/) plugin that obtains Let's Encrypt certificates using the DNS challenge (dns-01) for domains served by [Beget](https://beget.com/) DNS. The `_acme-challenge` TXT record is created and removed automatically through the [Beget API](https://beget.com/ru/kb/api/obshhij-princzip-raboty-s-api).
|
|
8
|
+
|
|
9
|
+
The DNS challenge lets you obtain wildcard certificates (`*.example.com`) and does not require port 80 to be open.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
The plugin must be installed into the same Python environment as Certbot.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install certbot-dns-beget-plugin
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
If Certbot was installed into a virtual environment, as in the
|
|
20
|
+
[EFF pip instructions](https://certbot.eff.org/instructions?ws=other&os=pip), use that
|
|
21
|
+
environment's pip:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
sudo /opt/certbot/bin/pip install certbot-dns-beget-plugin
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
> **Note:** Certbot installed with **snap** does not see plugins installed with pip: snap plugins have to
|
|
28
|
+
> be packaged as separate snaps. The same goes for Certbot from distribution packages (`apt`,
|
|
29
|
+
> `dnf`): modern distributions do not let pip install into the system Python. In both cases,
|
|
30
|
+
> install Certbot with pip into a virtual environment using the instructions above.
|
|
31
|
+
|
|
32
|
+
The latest development version can be installed straight from GitHub:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install git+https://github.com/l1n-x/certbot-dns-beget-plugin
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Check that Certbot sees the plugin:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
certbot plugins
|
|
42
|
+
# * dns-beget-plugin
|
|
43
|
+
# Description: Obtain certificates using a DNS TXT record (if you are using Beget for DNS).
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
1. In the Beget control panel, open the **API** section, enable API access and set an API password.
|
|
49
|
+
2. Create a credentials file, for example `/etc/letsencrypt/beget.ini`:
|
|
50
|
+
|
|
51
|
+
```ini
|
|
52
|
+
dns_beget_plugin_username = your_beget_login
|
|
53
|
+
dns_beget_plugin_password = your_api_password
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
3. Restrict access to the file:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
chmod 600 /etc/letsencrypt/beget.ini
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
certbot certonly \
|
|
66
|
+
--authenticator dns-beget-plugin \
|
|
67
|
+
--dns-beget-plugin-credentials /etc/letsencrypt/beget.ini \
|
|
68
|
+
-d example.com \
|
|
69
|
+
-d '*.example.com'
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
| Option | Description |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `--dns-beget-plugin-credentials` | Path to the INI file with credentials (required). |
|
|
75
|
+
| `--dns-beget-plugin-propagation-seconds` | Seconds to wait for the DNS record to propagate before validation. Default: `120`. |
|
|
76
|
+
|
|
77
|
+
## Renewal
|
|
78
|
+
|
|
79
|
+
Certbot saves the plugin options in `/etc/letsencrypt/renewal/<domain>.conf`, so `certbot renew`
|
|
80
|
+
uses the same plugin and credentials file without extra arguments:
|
|
81
|
+
|
|
82
|
+
```ini
|
|
83
|
+
authenticator = dns-beget-plugin
|
|
84
|
+
dns_beget_plugin_credentials = /etc/letsencrypt/beget.ini
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Keep the credentials file at the path given when the certificate was obtained. Check that renewal
|
|
88
|
+
works:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
sudo certbot renew --dry-run
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
If Certbot is installed in `/opt/certbot` as in the
|
|
95
|
+
[EFF pip instructions](https://certbot.eff.org/instructions?ws=other&os=pip), renewal is not
|
|
96
|
+
scheduled automatically. Add a cron job that tries twice a day at a random minute:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
echo "0 0,12 * * * root /opt/certbot/bin/python -c 'import random; import time; time.sleep(random.random() * 3600)' && /opt/certbot/bin/certbot renew -q" | sudo tee -a /etc/crontab > /dev/null
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
To reload the web server after a certificate is renewed, put a script into
|
|
103
|
+
`/etc/letsencrypt/renewal-hooks/deploy/`:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'SH'
|
|
107
|
+
#!/bin/sh
|
|
108
|
+
systemctl reload nginx
|
|
109
|
+
SH
|
|
110
|
+
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## How it works
|
|
114
|
+
|
|
115
|
+
- If the `_acme-challenge.<domain>` subdomain does not exist on the account, the plugin creates it and deletes it, together with its records, after validation.
|
|
116
|
+
- If the subdomain already exists, the plugin adds a TXT record to it and removes only that record after validation. Existing A, AAAA, MX and TXT records are preserved, although the Beget API resets their TTL to 600 seconds.
|
|
117
|
+
- The plugin refuses to change records if the name is delegated via NS or CNAME, or if it has records of other types that the Beget API cannot preserve.
|
|
118
|
+
|
|
119
|
+
## Limitations
|
|
120
|
+
|
|
121
|
+
- New records usually appear on the Beget name servers within a minute, so the default 120-second wait leaves some margin. If validation fails because the record is not found, increase `--dns-beget-plugin-propagation-seconds`.
|
|
122
|
+
- The Beget API can only replace all records of a name at once. Do not run several Certbot processes for the same domain at the same time: they can overwrite each other's TXT records, and validation will fail.
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
See [CONTRIBUTING.md](https://github.com/l1n-x/certbot-dns-beget-plugin/blob/master/CONTRIBUTING.md) for setting up the project, running the tests and releasing.
|
|
127
|
+
|
|
128
|
+
## Acknowledgements
|
|
129
|
+
|
|
130
|
+
Based on [certbot-dns-beget-api](https://github.com/Medan-rfz/certbot-dns-beget-api) by Medan-rfz (MIT License).
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
[MIT](https://github.com/l1n-x/certbot-dns-beget-plugin/blob/master/LICENSE)
|
|
File without changes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""DNS Authenticator for Beget."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from certbot import errors
|
|
8
|
+
from certbot.plugins import dns_common
|
|
9
|
+
from certbot.plugins.dns_common import CredentialsConfiguration
|
|
10
|
+
|
|
11
|
+
from certbot_dns_beget_plugin.client import BegetClient
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Authenticator(dns_common.DNSAuthenticator):
|
|
17
|
+
"""DNS Authenticator for Beget
|
|
18
|
+
|
|
19
|
+
This Authenticator uses the Beget API to fulfill a dns-01 challenge.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
description = 'Obtain certificates using a DNS TXT record (if you are using Beget for DNS).'
|
|
23
|
+
|
|
24
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
25
|
+
super().__init__(*args, **kwargs)
|
|
26
|
+
self.credentials: CredentialsConfiguration | None = None
|
|
27
|
+
self._client: BegetClient | None = None
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def add_parser_arguments(
|
|
31
|
+
cls, add: Callable[..., None], default_propagation_seconds: int = 120
|
|
32
|
+
) -> None:
|
|
33
|
+
super().add_parser_arguments(add, default_propagation_seconds)
|
|
34
|
+
add('credentials', help='Beget credentials INI file.')
|
|
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 Beget API.'
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def _validate_credentials(self, credentials: CredentialsConfiguration) -> None:
|
|
43
|
+
if not credentials.conf('username') or not credentials.conf('password'):
|
|
44
|
+
raise errors.PluginError(
|
|
45
|
+
f'{credentials.confobj.filename}: dns_beget_plugin_username and '
|
|
46
|
+
'dns_beget_plugin_password are required. Enable API access and set an API '
|
|
47
|
+
'password in the Beget control panel.'
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def _setup_credentials(self) -> None:
|
|
51
|
+
self.credentials = self._configure_credentials(
|
|
52
|
+
'credentials',
|
|
53
|
+
'Beget credentials INI file',
|
|
54
|
+
{
|
|
55
|
+
'username': 'Beget account login',
|
|
56
|
+
'password': 'Beget API password',
|
|
57
|
+
},
|
|
58
|
+
self._validate_credentials,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def _perform(self, domain: str, validation_name: str, validation: str) -> None:
|
|
62
|
+
self._get_beget_client().add_txt_record(validation_name, validation)
|
|
63
|
+
|
|
64
|
+
def _cleanup(self, domain: str, validation_name: str, validation: str) -> None:
|
|
65
|
+
self._get_beget_client().del_txt_record(validation_name, validation)
|
|
66
|
+
|
|
67
|
+
def _get_beget_client(self) -> BegetClient:
|
|
68
|
+
if not self.credentials:
|
|
69
|
+
raise errors.Error('Plugin has not been prepared.')
|
|
70
|
+
|
|
71
|
+
if self._client is None:
|
|
72
|
+
self._client = BegetClient(
|
|
73
|
+
self.credentials.conf('username') or '',
|
|
74
|
+
self.credentials.conf('password') or '',
|
|
75
|
+
)
|
|
76
|
+
return self._client
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Thin client for the parts of the Beget hosting API used by the plugin.
|
|
2
|
+
|
|
3
|
+
API reference:
|
|
4
|
+
* https://beget.com/ru/kb/api/obshhij-princzip-raboty-s-api
|
|
5
|
+
* https://beget.com/ru/kb/api/funkczii-dlya-raboty-s-domenami
|
|
6
|
+
* https://beget.com/ru/kb/api/funkczii-upravleniya-dns
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from dataclasses import field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
from certbot import errors
|
|
17
|
+
from certbot.plugins import dns_common
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
BASE_API_URL = 'https://api.beget.com/api/'
|
|
22
|
+
DEFAULT_TIMEOUT = 30
|
|
23
|
+
|
|
24
|
+
# dns/getData "set_type": 1 means the name is served by A/AAAA/MX/TXT records.
|
|
25
|
+
# 2 (NS) and 3 (CNAME) delegate the name elsewhere, so a TXT record cannot be added.
|
|
26
|
+
SET_TYPE_BASIC = 1
|
|
27
|
+
|
|
28
|
+
# Record group accepted by dns/changeRecords together with TXT. The whole group is
|
|
29
|
+
# replaced on every call, so every record of these types must be sent back.
|
|
30
|
+
BASIC_RECORD_TYPES = ('A', 'AAAA', 'MX', 'TXT')
|
|
31
|
+
|
|
32
|
+
# Record types reported by dns/getData that do not belong to the basic group and
|
|
33
|
+
# must not be sent back in it.
|
|
34
|
+
IGNORED_RECORD_TYPES = ('DNS', 'DNS_IP')
|
|
35
|
+
|
|
36
|
+
# dns/getData is documented to return {"value", "priority"}, but in practice it returns
|
|
37
|
+
# the raw zone fields. Map those to the {"value", "priority"} form changeRecords accepts.
|
|
38
|
+
_VALUE_KEYS = ('value', 'address', 'txtdata', 'exchange')
|
|
39
|
+
_PRIORITY_KEYS = ('priority', 'preference')
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BegetApiError(errors.PluginError):
|
|
43
|
+
"""Error reported by the Beget API or raised while talking to it."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class _Challenge:
|
|
48
|
+
"""Plugin state for a single validation name."""
|
|
49
|
+
|
|
50
|
+
fqdn: str
|
|
51
|
+
# Subdomain ids created by the plugin and therefore removed on cleanup.
|
|
52
|
+
created_subdomain_ids: list[int] = field(default_factory=list)
|
|
53
|
+
# TXT values added by the plugin that are not cleaned up yet.
|
|
54
|
+
pending_values: list[str] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BegetClient:
|
|
58
|
+
"""Encapsulates all communication with the Beget API."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, login: str, password: str, timeout: int = DEFAULT_TIMEOUT) -> None:
|
|
61
|
+
self._login = login
|
|
62
|
+
self._password = password
|
|
63
|
+
self._timeout = timeout
|
|
64
|
+
self._session = requests.Session()
|
|
65
|
+
self._domains: dict[str, int] | None = None
|
|
66
|
+
self._challenges: dict[str, _Challenge] = {}
|
|
67
|
+
|
|
68
|
+
def add_txt_record(self, record_name: str, record_content: str) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Add a TXT record, creating the subdomain that holds it if necessary.
|
|
71
|
+
|
|
72
|
+
:param str record_name: The record name (typically beginning with '_acme-challenge.').
|
|
73
|
+
:param str record_content: The record content (typically the challenge validation).
|
|
74
|
+
:raises certbot.errors.PluginError: if an error occurs communicating with the Beget API
|
|
75
|
+
"""
|
|
76
|
+
fqdn = _normalize(record_name)
|
|
77
|
+
challenge = self._challenges.setdefault(fqdn, _Challenge(fqdn))
|
|
78
|
+
|
|
79
|
+
if not challenge.created_subdomain_ids:
|
|
80
|
+
subdomains = self._list_subdomains()
|
|
81
|
+
if not any(_normalize(s['fqdn']) == fqdn for s in subdomains):
|
|
82
|
+
self._create_subdomain(challenge, subdomains)
|
|
83
|
+
|
|
84
|
+
records = self._get_basic_records(fqdn)
|
|
85
|
+
txt_records = records.setdefault('TXT', [])
|
|
86
|
+
if any(r['value'] == record_content for r in txt_records):
|
|
87
|
+
logger.debug('TXT record %s with this value already exists', fqdn)
|
|
88
|
+
else:
|
|
89
|
+
txt_records.append({'priority': _next_priority(txt_records), 'value': record_content})
|
|
90
|
+
self._change_records(fqdn, records)
|
|
91
|
+
logger.debug('Added TXT record to %s', fqdn)
|
|
92
|
+
|
|
93
|
+
challenge.pending_values.append(record_content)
|
|
94
|
+
|
|
95
|
+
def del_txt_record(self, record_name: str, record_content: str) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Delete a TXT record added by :meth:`add_txt_record`.
|
|
98
|
+
|
|
99
|
+
Removes only the record with the given content. If the plugin created the
|
|
100
|
+
subdomain and no other challenge still uses it, the subdomain is deleted.
|
|
101
|
+
|
|
102
|
+
Failures are logged, but not raised.
|
|
103
|
+
|
|
104
|
+
:param str record_name: The record name (typically beginning with '_acme-challenge.').
|
|
105
|
+
:param str record_content: The record content (typically the challenge validation).
|
|
106
|
+
"""
|
|
107
|
+
fqdn = _normalize(record_name)
|
|
108
|
+
challenge = self._challenges.get(fqdn)
|
|
109
|
+
if challenge is None:
|
|
110
|
+
logger.debug('No TXT record for %s was added by this run, nothing to clean up', fqdn)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
# The value is not pending if add_txt_record failed before adding it.
|
|
114
|
+
added = record_content in challenge.pending_values
|
|
115
|
+
if added:
|
|
116
|
+
challenge.pending_values.remove(record_content)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
if challenge.created_subdomain_ids and not challenge.pending_values:
|
|
120
|
+
# Deleting the subdomain also deletes its records. Aliases go first in
|
|
121
|
+
# case Beget removes them together with the subdomain itself.
|
|
122
|
+
while challenge.created_subdomain_ids:
|
|
123
|
+
subdomain_id = challenge.created_subdomain_ids[-1]
|
|
124
|
+
self._call('domain/deleteSubdomain', {'id': subdomain_id})
|
|
125
|
+
challenge.created_subdomain_ids.pop()
|
|
126
|
+
del self._challenges[fqdn]
|
|
127
|
+
logger.debug('Deleted subdomain %s', fqdn)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if not added:
|
|
131
|
+
logger.debug('TXT record %s was not added, nothing to clean up', fqdn)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
if record_content in challenge.pending_values:
|
|
135
|
+
logger.debug('TXT record %s with this value is still in use', fqdn)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
records = self._get_basic_records(fqdn)
|
|
139
|
+
txt_records = records.get('TXT', [])
|
|
140
|
+
remaining = [r for r in txt_records if r['value'] != record_content]
|
|
141
|
+
if len(remaining) != len(txt_records):
|
|
142
|
+
records['TXT'] = remaining
|
|
143
|
+
self._change_records(fqdn, records)
|
|
144
|
+
logger.debug('Deleted TXT record from %s', fqdn)
|
|
145
|
+
except errors.PluginError as e:
|
|
146
|
+
logger.warning('Failed to clean up TXT record %s: %s', fqdn, e)
|
|
147
|
+
|
|
148
|
+
def _find_domain(self, fqdn: str) -> tuple[str, int]:
|
|
149
|
+
"""Find the account domain the name belongs to, preferring the longest match."""
|
|
150
|
+
if self._domains is None:
|
|
151
|
+
self._domains = {
|
|
152
|
+
_normalize(d['fqdn']): int(d['id']) for d in self._call('domain/getList') or []
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for guess in dns_common.base_domain_name_guesses(fqdn):
|
|
156
|
+
if guess != fqdn and guess in self._domains:
|
|
157
|
+
return guess, self._domains[guess]
|
|
158
|
+
|
|
159
|
+
raise errors.PluginError(
|
|
160
|
+
f'Unable to find a domain for {fqdn} on the Beget account '
|
|
161
|
+
f'(domains on the account: {", ".join(sorted(self._domains)) or "none"})'
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def _list_subdomains(self) -> list[dict[str, Any]]:
|
|
165
|
+
return self._call('domain/getSubdomainList') or []
|
|
166
|
+
|
|
167
|
+
def _create_subdomain(self, challenge: _Challenge, existing: list[dict[str, Any]]) -> None:
|
|
168
|
+
"""Create the subdomain and record ids of every subdomain it brought along."""
|
|
169
|
+
fqdn = challenge.fqdn
|
|
170
|
+
domain, domain_id = self._find_domain(fqdn)
|
|
171
|
+
before = {int(s['id']) for s in existing}
|
|
172
|
+
|
|
173
|
+
subdomain_id = int(
|
|
174
|
+
self._call(
|
|
175
|
+
'domain/addSubdomainVirtual',
|
|
176
|
+
{
|
|
177
|
+
'subdomain': fqdn[: -len(domain) - 1],
|
|
178
|
+
'domain_id': domain_id,
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
# Record the id right away so that cleanup removes the subdomain even if
|
|
183
|
+
# the calls below fail.
|
|
184
|
+
challenge.created_subdomain_ids.append(subdomain_id)
|
|
185
|
+
logger.debug('Created subdomain %s (id %d)', fqdn, subdomain_id)
|
|
186
|
+
|
|
187
|
+
# Beget may create a "www." alias along with the subdomain; it has to go too.
|
|
188
|
+
for s in self._list_subdomains():
|
|
189
|
+
sid = int(s['id'])
|
|
190
|
+
if (
|
|
191
|
+
sid not in before
|
|
192
|
+
and sid != subdomain_id
|
|
193
|
+
and _normalize(s['fqdn']) in (fqdn, 'www.' + fqdn)
|
|
194
|
+
):
|
|
195
|
+
challenge.created_subdomain_ids.append(sid)
|
|
196
|
+
|
|
197
|
+
def _get_basic_records(self, fqdn: str) -> dict[str, list[dict[str, Any]]]:
|
|
198
|
+
"""Return the current A/AAAA/MX/TXT records of a name in the changeRecords format."""
|
|
199
|
+
data = self._call('dns/getData', {'fqdn': fqdn})
|
|
200
|
+
|
|
201
|
+
set_type = int(data.get('set_type') or SET_TYPE_BASIC)
|
|
202
|
+
if set_type != SET_TYPE_BASIC:
|
|
203
|
+
raise errors.PluginError(
|
|
204
|
+
f'{fqdn} is configured with NS or CNAME records on Beget, '
|
|
205
|
+
'a TXT record cannot be added to it'
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
records: dict[str, list[dict[str, Any]]] = {}
|
|
209
|
+
for record_type, entries in (data.get('records') or {}).items():
|
|
210
|
+
if record_type in IGNORED_RECORD_TYPES or not entries:
|
|
211
|
+
continue
|
|
212
|
+
if record_type not in BASIC_RECORD_TYPES:
|
|
213
|
+
# changeRecords replaces the whole record set. Sending it without this
|
|
214
|
+
# type could delete it, so refuse rather than risk losing records.
|
|
215
|
+
raise errors.PluginError(
|
|
216
|
+
f'{fqdn} has {record_type} records that this plugin cannot preserve; '
|
|
217
|
+
'refusing to modify the DNS records'
|
|
218
|
+
)
|
|
219
|
+
converted = [_to_change_format(record_type, e) for e in entries]
|
|
220
|
+
# getData reports a name without records of a type as one record with an
|
|
221
|
+
# empty value. Sending it back would create an empty record.
|
|
222
|
+
converted = [r for r in converted if r['value'] != '']
|
|
223
|
+
if not converted:
|
|
224
|
+
continue
|
|
225
|
+
if record_type != 'MX':
|
|
226
|
+
# Only MX priorities carry meaning. getData usually omits priorities for
|
|
227
|
+
# other types, so number them to keep them unique as the API requires.
|
|
228
|
+
for i, record in enumerate(converted, 1):
|
|
229
|
+
record['priority'] = i * 10
|
|
230
|
+
records[record_type] = converted
|
|
231
|
+
return records
|
|
232
|
+
|
|
233
|
+
def _change_records(self, fqdn: str, records: dict[str, list[dict[str, Any]]]) -> None:
|
|
234
|
+
records = {k: v for k, v in records.items() if v}
|
|
235
|
+
if not self._call('dns/changeRecords', {'fqdn': fqdn, 'records': records}):
|
|
236
|
+
raise BegetApiError(f'Beget API refused to change DNS records of {fqdn}')
|
|
237
|
+
|
|
238
|
+
def _call(self, method: str, data: dict[str, Any] | None = None) -> Any:
|
|
239
|
+
"""
|
|
240
|
+
Call an API method and return its "result".
|
|
241
|
+
|
|
242
|
+
Credentials are sent in the POST body, never in the URL, so they do not end up
|
|
243
|
+
in certbot's debug log.
|
|
244
|
+
"""
|
|
245
|
+
payload = {
|
|
246
|
+
'login': self._login,
|
|
247
|
+
'passwd': self._password,
|
|
248
|
+
'input_format': 'json',
|
|
249
|
+
'output_format': 'json',
|
|
250
|
+
# With input_format=json the API answers "Cannot parse the JSON input params"
|
|
251
|
+
# in plain text if input_data is missing, even for methods without arguments.
|
|
252
|
+
'input_data': json.dumps(data or {}),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
response = self._session.post(
|
|
257
|
+
BASE_API_URL + method, data=payload, timeout=self._timeout
|
|
258
|
+
)
|
|
259
|
+
response.raise_for_status()
|
|
260
|
+
except requests.RequestException as e:
|
|
261
|
+
raise BegetApiError(f'Error communicating with the Beget API ({method}): {e}') from e
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
body = response.json()
|
|
265
|
+
except ValueError as e:
|
|
266
|
+
raise BegetApiError(
|
|
267
|
+
f'Beget API returned a non-JSON response ({method}): '
|
|
268
|
+
f'{response.text.strip()[:200]!r}'
|
|
269
|
+
) from e
|
|
270
|
+
|
|
271
|
+
if not isinstance(body, dict):
|
|
272
|
+
raise BegetApiError(f'Beget API returned an unexpected response ({method}): {body!r}')
|
|
273
|
+
|
|
274
|
+
if body.get('status') != 'success':
|
|
275
|
+
raise BegetApiError(
|
|
276
|
+
f'Beget API error ({method}): {body.get("error_text") or "unknown error"} '
|
|
277
|
+
f'[{body.get("error_code") or "?"}]'
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
answer = body.get('answer')
|
|
281
|
+
if not isinstance(answer, dict):
|
|
282
|
+
answer = {}
|
|
283
|
+
if answer.get('status') != 'success':
|
|
284
|
+
details = (
|
|
285
|
+
'; '.join(
|
|
286
|
+
f'{_error_text(e)} [{e.get("error_code")}]' for e in answer.get('errors') or []
|
|
287
|
+
)
|
|
288
|
+
or 'unknown error'
|
|
289
|
+
)
|
|
290
|
+
raise BegetApiError(f'Beget API method {method} failed: {details}')
|
|
291
|
+
|
|
292
|
+
return answer.get('result')
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _normalize(name: str) -> str:
|
|
296
|
+
return name.rstrip('.').lower()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _error_text(error: dict[str, Any]) -> str:
|
|
300
|
+
"""
|
|
301
|
+
Return the text of an API error.
|
|
302
|
+
|
|
303
|
+
dns/changeRecords reports validation errors as an object, e.g.
|
|
304
|
+
{"type": "INVALID_DATA_SINGLE", "message": [{"errorText": "Empty txt record", ...}]}.
|
|
305
|
+
"""
|
|
306
|
+
text = error.get('error_text')
|
|
307
|
+
if isinstance(text, dict):
|
|
308
|
+
messages = [m.get('errorText') for m in text.get('message') or [] if isinstance(m, dict)]
|
|
309
|
+
return '; '.join(str(m) for m in messages if m) or str(text.get('type') or text)
|
|
310
|
+
return str(text)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _to_change_format(record_type: str, entry: dict[str, Any]) -> dict[str, Any]:
|
|
314
|
+
value = next((entry[k] for k in _VALUE_KEYS if entry.get(k) is not None), None)
|
|
315
|
+
priority = next((entry[k] for k in _PRIORITY_KEYS if entry.get(k) is not None), 0)
|
|
316
|
+
if value is None:
|
|
317
|
+
raise errors.PluginError(f'Unrecognized {record_type} record format: {entry!r}')
|
|
318
|
+
if record_type == 'MX':
|
|
319
|
+
value = str(value).rstrip('.')
|
|
320
|
+
return {'priority': int(priority), 'value': value}
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _next_priority(records: list[dict[str, Any]]) -> int:
|
|
324
|
+
return max((r['priority'] for r in records), default=0) + 10
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["poetry-core>=2.0"]
|
|
3
|
+
build-backend = "poetry.core.masonry.api"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "certbot-dns-beget-plugin"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Certbot DNS plugin for Beget to automate Let's Encrypt certificate issuance with the DNS challenge"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Roman Sarkisyan" }]
|
|
13
|
+
requires-python = ">=3.10"
|
|
14
|
+
keywords = ["certbot", "letsencrypt", "acme", "dns", "beget"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Environment :: Plugins",
|
|
18
|
+
"Intended Audience :: System Administrators",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Topic :: Internet :: Name Service (DNS)",
|
|
28
|
+
"Topic :: Security",
|
|
29
|
+
"Topic :: System :: Systems Administration",
|
|
30
|
+
]
|
|
31
|
+
dependencies = [
|
|
32
|
+
"certbot>=2.0",
|
|
33
|
+
"requests>=2.20",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/l1n-x/certbot-dns-beget-plugin"
|
|
38
|
+
Repository = "https://github.com/l1n-x/certbot-dns-beget-plugin.git"
|
|
39
|
+
Issues = "https://github.com/l1n-x/certbot-dns-beget-plugin/issues"
|
|
40
|
+
Changelog = "https://github.com/l1n-x/certbot-dns-beget-plugin/blob/master/CHANGELOG.md"
|
|
41
|
+
|
|
42
|
+
[project.entry-points."certbot.plugins"]
|
|
43
|
+
dns-beget-plugin = "certbot_dns_beget_plugin.authenticator:Authenticator"
|
|
44
|
+
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=9.1",
|
|
48
|
+
"pytest-cov>=7.1",
|
|
49
|
+
"ruff>=0.17",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.poetry]
|
|
53
|
+
packages = [{ include = "certbot_dns_beget_plugin" }]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
addopts = ["--cov", "--cov-report=term-missing"]
|
|
58
|
+
|
|
59
|
+
[tool.coverage.run]
|
|
60
|
+
source = ["certbot_dns_beget_plugin"]
|
|
61
|
+
branch = true
|
|
62
|
+
|
|
63
|
+
[tool.coverage.report]
|
|
64
|
+
fail_under = 90
|
|
65
|
+
show_missing = true
|
|
66
|
+
skip_covered = true
|
|
67
|
+
|
|
68
|
+
[tool.ruff]
|
|
69
|
+
line-length = 100
|
|
70
|
+
target-version = "py310"
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint]
|
|
73
|
+
select = [
|
|
74
|
+
"E", # pycodestyle errors
|
|
75
|
+
"W", # pycodestyle warnings
|
|
76
|
+
"F", # pyflakes
|
|
77
|
+
"I", # isort
|
|
78
|
+
"B", # flake8-bugbear
|
|
79
|
+
"UP", # pyupgrade
|
|
80
|
+
"SIM", # flake8-simplify
|
|
81
|
+
"RUF", # ruff-specific rules
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
[tool.ruff.lint.isort]
|
|
85
|
+
force-single-line = true
|
|
86
|
+
known-first-party = ["certbot_dns_beget_plugin", "fake_beget"]
|
|
87
|
+
|
|
88
|
+
[tool.ruff.format]
|
|
89
|
+
quote-style = "single"
|
|
90
|
+
|