Skip to content

Commit 33432c6

Browse files
author
Patrick Koss
committed
first implementation
1 parent 084a824 commit 33432c6

9 files changed

Lines changed: 721 additions & 1 deletion

File tree

.coveragerc

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[run]
2+
branch = True
3+
source = .
4+
omit =
5+
/opt/homebrew/*
6+
7+
[report]
8+
show_missing = True
9+
skip_covered = True
10+
omit =
11+
/opt/homebrew/*

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,4 +157,7 @@ cython_debug/
157157
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158158
# and can be added to the global gitignore or merged into this file. For a more nuclear
159159
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
160-
#.idea/
160+
.idea/
161+
162+
logs/
163+
credentials/

Makefile

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
VENV_PATH := ./venv
2+
3+
test: venv
4+
$(VENV_PATH)/bin/coverage run -m unittest discover
5+
$(VENV_PATH)/bin/coverage report
6+
7+
lint: venv
8+
$(VENV_PATH)/bin/mypy .
9+
$(VENV_PATH)/bin/flake8 .
10+
$(VENV_PATH)/bin/pydocstyle .
11+
12+
.PHONY: setup-venv
13+
setup-venv:
14+
if [ ! -d "$(VENV_PATH)" ]; then \
15+
python3 -m venv $(VENV_PATH); \
16+
fi
17+
18+
.PHONY: venv
19+
venv: setup-venv
20+
$(VENV_PATH)/bin/pip install -e .

certbot_dns_stackit/__init__.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""
2+
certbot_dns_stackit.
3+
4+
-------------------
5+
6+
This module provides functionality to integrate STACKIT DNS with Certbot
7+
for DNS-01 challenge type. The module contains two main classes:
8+
9+
- Authenticator: This class is responsible for solving the DNS-01 challenge by uploading
10+
the required validation token to a STACKIT DNS record.
11+
12+
- _StackitClient: This is an internal helper class that facilitates interactions
13+
with the STACKIT DNS API.
14+
15+
Note:
16+
The `_StackitClient` class is intended for internal use within this module and
17+
may not provide a stable public API for external consumers.
18+
19+
Examples:
20+
To use the `Authenticator` class for DNS-01 challenge:
21+
22+
```python
23+
authenticator = Authenticator(config, name)
24+
authenticator.perform(domain, validation_name, validation)
25+
```
26+
27+
For further details on each class and their methods, refer to their respective docstrings.
28+
29+
"""
30+
31+
from .stackit import Authenticator
32+
33+
__all__ = ["Authenticator"]

certbot_dns_stackit/stackit.py

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import logging
2+
from dataclasses import dataclass
3+
from typing import Optional, List, Callable
4+
5+
import requests
6+
from certbot import errors
7+
from certbot.plugins import dns_common
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
@dataclass
13+
class Record:
14+
"""Represents a Record."""
15+
16+
content: str
17+
id: str
18+
19+
20+
@dataclass
21+
class RRSet:
22+
"""Represents a RRSet."""
23+
24+
id: str
25+
records: List[Record]
26+
27+
28+
class _StackitClient(object):
29+
"""
30+
A client to interact with the STACKIT DNS API.
31+
32+
Attributes:
33+
auth_token (str): The authentication token for the API.
34+
project_id (str): The project ID associated with the domain (zone).
35+
base_url (str): The base URL endpoint for the STACKIT API.
36+
headers (dict): The headers to be used in API requests.
37+
"""
38+
39+
def __init__(self, auth_token: str, project_id: str, base_url: str):
40+
"""
41+
Initialize the StackitClient.
42+
43+
:param auth_token: The authentication token for the API.
44+
:param project_id: The project ID associated with the domain (zone).
45+
:param base_url: The base URL endpoint for the STACKIT API.
46+
"""
47+
self.auth_token = auth_token
48+
self.project_id = project_id
49+
self.base_url = base_url
50+
self.headers = {"Authorization": f"Bearer {self.auth_token}"}
51+
52+
def add_txt_record(self, domain: str, validation_name: str, validation: str):
53+
"""
54+
Add a TXT record using the supplied information.
55+
56+
:param domain: The zone dnsName.
57+
:param validation_name: The record name.
58+
:param validation: The record content.
59+
"""
60+
zone_id = self._get_zone_id(domain)
61+
rrset = self._get_rrset(zone_id, validation_name)
62+
# rrset does not exist therefore add it
63+
if rrset is None:
64+
self._create_rrset(zone_id, validation_name, validation)
65+
else:
66+
# rrset exists. If it does not contain the validation record, add it
67+
records = [record.content for record in rrset.records]
68+
if validation not in records:
69+
self._add_record_to_rrset(zone_id, rrset.id, validation)
70+
71+
def _create_rrset(self, zone_id: str, validation_name: str, validation: str):
72+
"""
73+
Create a new rrset for the supplied zone id.
74+
75+
:param zone_id: The zone ID where the rrset will be created.
76+
:param validation_name: The record name.
77+
:param validation: The record content.
78+
"""
79+
# append a dot if the validation name does not end with a dot
80+
if not validation_name.endswith("."):
81+
validation_name = f"{validation_name}."
82+
83+
body = {
84+
"name": validation_name,
85+
"type": "TXT",
86+
"ttl": 60,
87+
"records": [
88+
{
89+
"content": validation,
90+
}
91+
],
92+
}
93+
94+
res = requests.post(
95+
f"{self.base_url}/v1/projects/{self.project_id}/zones/{zone_id}/rrsets",
96+
headers=self.headers,
97+
json=body,
98+
)
99+
100+
if res.status_code != 202:
101+
raise errors.PluginError(
102+
f"Could not create rrset for zone id {zone_id}. Response: {res.text}"
103+
)
104+
105+
def _add_record_to_rrset(self, zone_id: str, rrset_id: str, validation: str):
106+
"""
107+
Add a record to an existing rrset.
108+
109+
:param zone_id: The zone ID where the rrset is located.
110+
:param rrset_id: The rrset ID where the record will be added.
111+
:param validation: The record content.
112+
"""
113+
body = {
114+
"action": "add",
115+
"records": [
116+
{
117+
"content": validation,
118+
}
119+
],
120+
}
121+
122+
res = requests.patch(
123+
f"{self.base_url}/v1/projects/{self.project_id}/zones/{zone_id}/rrsets/{rrset_id}/records",
124+
headers=self.headers,
125+
json=body,
126+
)
127+
128+
if res.status_code != 202:
129+
raise errors.PluginError(
130+
f"Could not add record to rrset {rrset_id}. Response: {res.text}"
131+
)
132+
133+
def _get_zone_id(self, domain: str) -> str:
134+
"""
135+
Retrieve the zone ID for the given domain.
136+
137+
:param domain: The domain (zone dnsName) for which the zone ID is needed.
138+
:return: The ID of the zone.
139+
"""
140+
res = requests.get(
141+
f"{self.base_url}/v1/projects/{self.project_id}/zones?dnsName[eq]={domain}&active[eq]=true",
142+
headers=self.headers,
143+
)
144+
if res.status_code != 200 or len(res.json()["zones"]) == 0:
145+
raise errors.PluginError(
146+
f"Could not find zone id for domain {domain}, Response: {res.text}"
147+
)
148+
149+
return res.json()["zones"][0]["id"]
150+
151+
def _get_rrset(self, zone_id: str, validation_name: str) -> Optional[RRSet]:
152+
"""
153+
Retrieve the rrset ID for the given zone ID and validation name.
154+
155+
:param zone_id: The zone ID where the rrset is located.
156+
:param validation_name: The name of the rrset to retrieve.
157+
:return: The rrset object if found; otherwise, None.
158+
"""
159+
if not validation_name.endswith("."):
160+
validation_name = f"{validation_name}."
161+
162+
res = requests.get(
163+
f"{self.base_url}/v1/projects/{self.project_id}/zones/{zone_id}/rrsets?name[eq]={validation_name}&type[eq]=TXT&active[eq]=true",
164+
headers=self.headers,
165+
)
166+
if res.status_code != 200:
167+
raise errors.PluginError(
168+
f"Could not find rrset id for zone id {zone_id} and validation name {validation_name}, Response: {res.text}"
169+
)
170+
171+
if len(res.json()["rrSets"]) == 0:
172+
return None
173+
174+
records = []
175+
for record in res.json()["rrSets"][0]["records"]:
176+
records.append(Record(content=record["content"], id=record["id"]))
177+
178+
rrset = RRSet(id=res.json()["rrSets"][0]["id"], records=records)
179+
180+
return rrset
181+
182+
def del_txt_record(self, domain: str, validation_name: str, validation: str):
183+
"""
184+
Delete a TXT record using the supplied information.
185+
186+
:param domain: The zone dnsName.
187+
:param validation_name: The record name.
188+
:param validation: The record content.
189+
"""
190+
zone_id = self._get_zone_id(domain)
191+
rrset = self._get_rrset(zone_id, validation_name)
192+
# delete rrset only if it exists. If it does not exist, we do not need to delete it
193+
if rrset is not None:
194+
self._delete_record_set(zone_id, rrset.id)
195+
196+
def _delete_record_set(self, zone_id: str, rrset_id: str):
197+
"""
198+
Delete the rrset using the supplied zone ID and rrset ID.
199+
200+
:param zone_id: The zone ID where the rrset is located.
201+
:param rrset_id: The ID of the rrset to be deleted.
202+
"""
203+
res = requests.delete(
204+
f"{self.base_url}/v1/projects/{self.project_id}/zones/{zone_id}/rrsets/{rrset_id}",
205+
headers=self.headers,
206+
)
207+
208+
if res.status_code != 202:
209+
raise errors.PluginError(
210+
f"Could not delete rrset id {rrset_id}. Response: {res.text}"
211+
)
212+
213+
214+
class Authenticator(dns_common.DNSAuthenticator):
215+
"""
216+
STACKIT DNS Authenticator.
217+
218+
This authenticator resolves a DNS01 challenge by publishing the required
219+
validation token (record within a record set within a zone) to a STACKIT DNS record.
220+
221+
Attributes:
222+
credentials: A configuration object that holds STACKIT API credentials.
223+
"""
224+
225+
def __init__(self, *args, **kwargs):
226+
"""Initialize the Authenticator by calling the parent's init method."""
227+
super(Authenticator, self).__init__(*args, **kwargs)
228+
229+
@classmethod
230+
def add_parser_arguments(cls, add: Callable, **kwargs):
231+
"""
232+
Add custom arguments for the STACKIT DNS Authenticator.
233+
234+
:param add: Callable to add an argument.
235+
:param kwargs: Additional keyword arguments.
236+
"""
237+
super(Authenticator, cls).add_parser_arguments(
238+
add, default_propagation_seconds=120
239+
)
240+
add("credentials", help="STACKIT credentials INI file.")
241+
242+
def _setup_credentials(self):
243+
"""Set up and configure the STACKIT credentials."""
244+
self.credentials = self._configure_credentials(
245+
"credentials",
246+
"STACKIT credentials for the STACKIT DNS API",
247+
{
248+
"project_id": "Specifies the project id of the STACKIT project.",
249+
"auth_token": "Defines the authentication token for the STACKIT DNS API. Keep in mind that the "
250+
"service account to this token need to have project edit permissions as we create txt "
251+
"records in the zone",
252+
},
253+
)
254+
255+
def _perform(self, domain: str, validation_name: str, validation: str):
256+
"""
257+
Carry out a DNS update.
258+
259+
:param domain: The domain where the DNS record will be added.
260+
:param validation_name: The name of the DNS record.
261+
:param validation: The validation content to be added to the DNS record.
262+
"""
263+
self._get_stackit_client().add_txt_record(domain, validation_name, validation)
264+
265+
def _cleanup(self, domain: str, validation_name: str, validation: str):
266+
"""
267+
Remove the previously added DNS record.
268+
269+
:param domain: The domain from which the DNS record will be deleted.
270+
:param validation_name: The name of the DNS record to be deleted.
271+
:param validation: The validation content of the DNS record to be deleted.
272+
"""
273+
self._get_stackit_client().del_txt_record(domain, validation_name, validation)
274+
275+
def _get_stackit_client(self) -> _StackitClient:
276+
"""
277+
Instantiate and return a StackitClient object.
278+
279+
:return: A _StackitClient instance to interact with the STACKIT DNS API.
280+
"""
281+
base_url = "https://dns.api.stackit.cloud"
282+
if self.credentials.conf("base_url") is not None:
283+
base_url = self.credentials.conf("base_url")
284+
285+
return _StackitClient(
286+
self.credentials.conf("auth_token"),
287+
self.credentials.conf("project_id"),
288+
base_url,
289+
)

0 commit comments

Comments
 (0)