certapi 0.2.1__tar.gz → 0.2.3__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.
- {certapi-0.2.1 → certapi-0.2.3}/PKG-INFO +1 -1
- {certapi-0.2.1 → certapi-0.2.3}/setup.py +1 -1
- {certapi-0.2.1 → certapi-0.2.3}/src/certapi/Acme.py +2 -1
- {certapi-0.2.1 → certapi-0.2.3}/src/certapi/cloudflare_challenge_store.py +1 -1
- {certapi-0.2.1 → certapi-0.2.3}/src/certapi/db.py +2 -1
- certapi-0.2.3/src/certapi/digitalocean_challenge_store.py +57 -0
- certapi-0.2.3/src/certapi/digitalocean_client.py +86 -0
- {certapi-0.2.1 → certapi-0.2.3}/src/certapi.egg-info/PKG-INFO +1 -1
- {certapi-0.2.1 → certapi-0.2.3}/src/certapi.egg-info/SOURCES.txt +2 -0
- {certapi-0.2.1 → certapi-0.2.3}/MANIFEST.in +0 -0
- {certapi-0.2.1 → certapi-0.2.3}/README.md +0 -0
- {certapi-0.2.1 → certapi-0.2.3}/pyproject.toml +0 -0
- {certapi-0.2.1 → certapi-0.2.3}/setup.cfg +0 -0
- {certapi-0.2.1 → certapi-0.2.3}/src/certapi/__init__.py +0 -0
- {certapi-0.2.1 → certapi-0.2.3}/src/certapi/certauthority.py +0 -0
- {certapi-0.2.1 → certapi-0.2.3}/src/certapi/challenge.py +0 -0
- {certapi-0.2.1 → certapi-0.2.3}/src/certapi/cloudflare_client.py +0 -0
- {certapi-0.2.1 → certapi-0.2.3}/src/certapi/crypto.py +0 -0
- {certapi-0.2.1 → certapi-0.2.3}/src/certapi/crypto_classes.py +0 -0
- {certapi-0.2.1 → certapi-0.2.3}/src/certapi/custom_certauthority.py +0 -0
- {certapi-0.2.1 → certapi-0.2.3}/src/certapi/util.py +0 -0
- {certapi-0.2.1 → certapi-0.2.3}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-0.2.1 → certapi-0.2.3}/src/certapi.egg-info/requires.txt +0 -0
- {certapi-0.2.1 → certapi-0.2.3}/src/certapi.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import re
|
|
3
3
|
import threading
|
|
4
|
+
import time
|
|
4
5
|
from typing import Union, List, Tuple
|
|
5
6
|
import json
|
|
6
7
|
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
|
@@ -81,7 +82,6 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
|
|
|
81
82
|
"resolved": validation_record["addressesResolved"],
|
|
82
83
|
"used": validation_record["addressUsed"],
|
|
83
84
|
}
|
|
84
|
-
err_detail: str = error["detail"]
|
|
85
85
|
if error["type"] == "urn:ietf:params:acme:error:connection":
|
|
86
86
|
if "Timeout during connect" in err_detail:
|
|
87
87
|
error["connect"] = {"error": "Timeout"}
|
|
@@ -276,6 +276,7 @@ class Acme:
|
|
|
276
276
|
response = post(step, url, json=payload, headers={"Content-Type": "application/jose+json"}, throw=throw)
|
|
277
277
|
except AcmeError as e:
|
|
278
278
|
if e.can_retry and depth <= 0:
|
|
279
|
+
time.sleep(2)
|
|
279
280
|
return self._signed_req(url, payload, depth + 1, step, throw)
|
|
280
281
|
else:
|
|
281
282
|
raise e
|
|
@@ -7,7 +7,7 @@ from certapi.cloudflare_client import Cloudflare
|
|
|
7
7
|
class CloudflareChallengeStore(ChallengeStore):
|
|
8
8
|
def __init__(self):
|
|
9
9
|
self.cloudflare = Cloudflare()
|
|
10
|
-
self.challenges_map = {} # Stores key: record_id (
|
|
10
|
+
self.challenges_map = {} # Stores key: record_id (needed for deletion)
|
|
11
11
|
|
|
12
12
|
def has_domain(self, domain: str) -> bool:
|
|
13
13
|
"""
|
|
@@ -191,8 +191,9 @@ class FilesystemKeyStore(KeyStore):
|
|
|
191
191
|
key_path = os.path.join(self.keys_dir, f"{private_key_id}.key")
|
|
192
192
|
with open(key_path, "rb") as f:
|
|
193
193
|
key_content = f.read()
|
|
194
|
+
|
|
194
195
|
for domain in domains:
|
|
195
|
-
if name.endswith(".selfsigned"):
|
|
196
|
+
if name is not None and name.endswith(".selfsigned"):
|
|
196
197
|
domain = domain + ".selfsigned"
|
|
197
198
|
|
|
198
199
|
if domain != private_key_id:
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import MutableMapping
|
|
3
|
+
from certapi.challenge import ChallengeStore
|
|
4
|
+
from certapi.digitalocean_client import DigitalOcean
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DigitalOceanChallengeStore(ChallengeStore):
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.digitalocean = DigitalOcean()
|
|
10
|
+
self.challenges_map = {} # Stores key: record_id (needed for deletion)
|
|
11
|
+
|
|
12
|
+
def has_domain(self, domain: str) -> bool:
|
|
13
|
+
"""
|
|
14
|
+
Checks if the DigitalOcean account has access to the given domain (or its base domain)
|
|
15
|
+
as a registered zone.
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
self.digitalocean.determine_domain(domain)
|
|
19
|
+
return True
|
|
20
|
+
except Exception:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
def save_challenge(self, key: str, value: str, domain=None):
|
|
24
|
+
# key example: _acme-challenge.sub.example.com
|
|
25
|
+
# value example: ACME_CHALLENGE_TOKEN
|
|
26
|
+
base_domain = self.digitalocean.determine_domain(domain)
|
|
27
|
+
|
|
28
|
+
record_id = self.digitalocean.create_record(name=key, data=value, domain=base_domain)
|
|
29
|
+
self.challenges_map[key] = record_id
|
|
30
|
+
print(f"DigitalOceanChallengeStore: Saved challenge for {key} with record ID {record_id}")
|
|
31
|
+
|
|
32
|
+
def get_challenge(self, key: str, domain: str) -> str:
|
|
33
|
+
base_domain = self.digitalocean.determine_domain(domain)
|
|
34
|
+
records = self.digitalocean.list_records(base_domain, name_filter=key)
|
|
35
|
+
for record in records:
|
|
36
|
+
if record["name"] == key:
|
|
37
|
+
return record["data"] # DigitalOcean API returns 'data' for TXT record content
|
|
38
|
+
return None # Return None if not found, as per ChallengeStore's __getitem__ behavior
|
|
39
|
+
|
|
40
|
+
def delete_challenge(self, key: str, domain: str):
|
|
41
|
+
if key not in self.challenges_map:
|
|
42
|
+
raise KeyError(f"Challenge {key} not found in store (no record_id stored).")
|
|
43
|
+
|
|
44
|
+
record_id = self.challenges_map[key]
|
|
45
|
+
base_domain = self.digitalocean.determine_domain(domain)
|
|
46
|
+
self.digitalocean.delete_record(record=record_id, domain=base_domain)
|
|
47
|
+
del self.challenges_map[key]
|
|
48
|
+
print(f"DigitalOceanChallengeStore: Deleted challenge for {key} with record ID {record_id}")
|
|
49
|
+
|
|
50
|
+
def __iter__(self):
|
|
51
|
+
# This is tricky as we can't easily iterate all challenges across all domains
|
|
52
|
+
# If the user wants a full API-driven iteration, they need to clarify how to get all domains.
|
|
53
|
+
return iter(self.challenges_map)
|
|
54
|
+
|
|
55
|
+
def __len__(self):
|
|
56
|
+
# Similar to __iter__, this will count challenges managed by this store instance.
|
|
57
|
+
return len(self.challenges_map)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from os import getenv
|
|
3
|
+
|
|
4
|
+
from urllib.request import urlopen, Request # Python 3
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DigitalOcean(object):
|
|
9
|
+
def __init__(self,api_key=None):
|
|
10
|
+
self.token=api_key
|
|
11
|
+
self.api = "https://api.digitalocean.com/v2/domains"
|
|
12
|
+
if not self.token:
|
|
13
|
+
self.token = getenv("DIGITALOCEAN_API_TOKEN")
|
|
14
|
+
if not self.token:
|
|
15
|
+
raise Exception("API_TOKEN not found in environment")
|
|
16
|
+
|
|
17
|
+
def determine_domain(self, domain):
|
|
18
|
+
"""Determine registered domain in API"""
|
|
19
|
+
request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
|
|
20
|
+
response = urlopen(Request(self.api, headers=request_headers))
|
|
21
|
+
if response.getcode() != 200:
|
|
22
|
+
raise Exception(json.loads(response.read().decode("utf8")))
|
|
23
|
+
domains = json.loads(response.read().decode("utf8"))["domains"]
|
|
24
|
+
for d in domains:
|
|
25
|
+
if d["name"] in domain:
|
|
26
|
+
return d["name"]
|
|
27
|
+
|
|
28
|
+
def create_record(self, name, data, domain):
|
|
29
|
+
"""
|
|
30
|
+
Create DNS record
|
|
31
|
+
Params:
|
|
32
|
+
name, string, record name
|
|
33
|
+
data, string, record data
|
|
34
|
+
domain, string, dns domain
|
|
35
|
+
Return:
|
|
36
|
+
record_id, int, created record id
|
|
37
|
+
"""
|
|
38
|
+
registered_domain = self.determine_domain(domain)
|
|
39
|
+
api = self.api + "/" + registered_domain + "/records"
|
|
40
|
+
request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
|
|
41
|
+
request_data = {"type": "TXT", "ttl": 300, "name": name, "data": data}
|
|
42
|
+
response = urlopen(Request(api, data=json.dumps(request_data).encode("utf8"), headers=request_headers))
|
|
43
|
+
if response.getcode() != 201:
|
|
44
|
+
raise Exception(json.loads(response.read().decode("utf8")))
|
|
45
|
+
return json.loads(response.read().decode("utf8"))["domain_record"]["id"]
|
|
46
|
+
|
|
47
|
+
def delete_record(self, record, domain):
|
|
48
|
+
"""
|
|
49
|
+
Delete DNS record
|
|
50
|
+
Params:
|
|
51
|
+
record, int, record id number
|
|
52
|
+
domain, string, dns domain
|
|
53
|
+
"""
|
|
54
|
+
registered_domain = self.determine_domain(domain)
|
|
55
|
+
api = self.api + "/" + registered_domain + "/records/" + str(record)
|
|
56
|
+
request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
|
|
57
|
+
request = Request(api, data=json.dumps({}).encode("utf8"), headers=request_headers)
|
|
58
|
+
request.get_method = lambda: "DELETE"
|
|
59
|
+
response = urlopen(request)
|
|
60
|
+
if response.getcode() != 204:
|
|
61
|
+
raise Exception(json.loads(response.read().decode("utf8")))
|
|
62
|
+
|
|
63
|
+
def list_records(self, domain, name_filter=None):
|
|
64
|
+
"""
|
|
65
|
+
List DNS records for a domain, optionally filtered by name.
|
|
66
|
+
Params:
|
|
67
|
+
domain, string, dns domain
|
|
68
|
+
name_filter, string, optional filter for record name
|
|
69
|
+
Return:
|
|
70
|
+
records, list of dicts, matching DNS records
|
|
71
|
+
"""
|
|
72
|
+
registered_domain = self.determine_domain(domain)
|
|
73
|
+
api = self.api + "/" + registered_domain + "/records"
|
|
74
|
+
request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
|
|
75
|
+
response = urlopen(Request(api, headers=request_headers))
|
|
76
|
+
if response.getcode() != 200:
|
|
77
|
+
raise Exception(json.loads(response.read().decode("utf8")))
|
|
78
|
+
|
|
79
|
+
all_records = json.loads(response.read().decode("utf8"))["domain_records"]
|
|
80
|
+
|
|
81
|
+
if name_filter:
|
|
82
|
+
filtered_records = [r for r in all_records if r["name"] == name_filter and r["type"] == "TXT"]
|
|
83
|
+
else:
|
|
84
|
+
filtered_records = [r for r in all_records if r["type"] == "TXT"]
|
|
85
|
+
|
|
86
|
+
return filtered_records
|
|
@@ -12,6 +12,8 @@ src/certapi/crypto.py
|
|
|
12
12
|
src/certapi/crypto_classes.py
|
|
13
13
|
src/certapi/custom_certauthority.py
|
|
14
14
|
src/certapi/db.py
|
|
15
|
+
src/certapi/digitalocean_challenge_store.py
|
|
16
|
+
src/certapi/digitalocean_client.py
|
|
15
17
|
src/certapi/util.py
|
|
16
18
|
src/certapi.egg-info/PKG-INFO
|
|
17
19
|
src/certapi.egg-info/SOURCES.txt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|