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.
@@ -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
+ [![CI](https://github.com/l1n-x/certbot-dns-beget-plugin/actions/workflows/ci.yml/badge.svg?branch=master)](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
+ [![CI](https://github.com/l1n-x/certbot-dns-beget-plugin/actions/workflows/ci.yml/badge.svg?branch=master)](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)
@@ -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
+