certapi 0.4.0__tar.gz → 0.4.1__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.4.0 → certapi-0.4.1}/PKG-INFO +2 -2
- {certapi-0.4.0 → certapi-0.4.1}/README.md +1 -1
- {certapi-0.4.0 → certapi-0.4.1}/setup.py +1 -1
- certapi-0.4.1/src/certapi/challenge_store/dns/cloudflare/cloudflare_challenge_store.py +59 -0
- certapi-0.4.1/src/certapi/challenge_store/dns/cloudflare/cloudflare_client.py +138 -0
- certapi-0.4.1/src/certapi/challenge_store/dns/digitalocean/digitalocean_challenge_store.py +57 -0
- certapi-0.4.1/src/certapi/challenge_store/dns/digitalocean/digitalocean_client.py +85 -0
- certapi-0.4.1/src/certapi/client/__init__.py +0 -0
- certapi-0.4.1/src/certapi/http/__init__.py +0 -0
- certapi-0.4.1/src/certapi/http/client.py +0 -0
- certapi-0.4.1/src/certapi/http/types.py +68 -0
- certapi-0.4.1/src/certapi/manager/__init__.py +0 -0
- certapi-0.4.1/src/certapi/manager/acme_cert_manager.py +155 -0
- certapi-0.4.1/src/certapi/server/__init__.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi.egg-info/PKG-INFO +2 -2
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi.egg-info/SOURCES.txt +11 -0
- {certapi-0.4.0 → certapi-0.4.1}/MANIFEST.in +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/pyproject.toml +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/setup.cfg +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/__init__.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/acme/Acme.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/acme/AcmeError.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/acme/Challenge.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/acme/Order.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/acme/__init__.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/acme/http.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/challenge_store/ChallengeStore.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/challenge_store/FileSystemChallengeStore.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/challenge_store/FilesystemChallengeStore.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/challenge_store/InmemoryChallengeStore.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/challenge_store/__init__.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/challenge_store/dns/__init__.py +0 -0
- {certapi-0.4.0/src/certapi/client → certapi-0.4.1/src/certapi/challenge_store/dns/cloudflare}/__init__.py +0 -0
- {certapi-0.4.0/src/certapi/server → certapi-0.4.1/src/certapi/challenge_store/dns/digitalocean}/__init__.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/client/cert_manager_client.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/crypto/__init__.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/crypto/crypto.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/crypto/crypto_classes.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/issuers/SelfCertIssuer.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/issuers/__init__.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/issuers/abstract_certissuer.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/keystore/FileSystemKeystore.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/keystore/KeyStore.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/keystore/RemoteKeyStore.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/keystore/SqliteKeyStore.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/keystore/__init__.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/server/api.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/server/cert_api.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/server/key_api.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/util.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi/utils.py +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi.egg-info/requires.txt +0 -0
- {certapi-0.4.0 → certapi-0.4.1}/src/certapi.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: certapi
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Python Package for managing keys, request SSL certificates from ACME.
|
|
5
5
|
Home-page: https://github.com/mesudip/certapi
|
|
6
6
|
Author: Sudip Bhattarai
|
|
@@ -33,7 +33,7 @@ import json
|
|
|
33
33
|
from certapi import FileSystemChallengeStore, FilesystemKeyStore, CertAuthority
|
|
34
34
|
|
|
35
35
|
key_store = FilesystemKeyStore("data")
|
|
36
|
-
challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/
|
|
36
|
+
challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/
|
|
37
37
|
|
|
38
38
|
certAuthority = CertAuthority(challenge_store, key_store)
|
|
39
39
|
certAuthority.setup()
|
|
@@ -20,7 +20,7 @@ import json
|
|
|
20
20
|
from certapi import FileSystemChallengeStore, FilesystemKeyStore, CertAuthority
|
|
21
21
|
|
|
22
22
|
key_store = FilesystemKeyStore("data")
|
|
23
|
-
challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/
|
|
23
|
+
challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/
|
|
24
24
|
|
|
25
25
|
certAuthority = CertAuthority(challenge_store, key_store)
|
|
26
26
|
certAuthority.setup()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
from collections.abc import MutableMapping
|
|
4
|
+
from ...ChallengeStore import ChallengeStore
|
|
5
|
+
from .cloudflare_client import Cloudflare
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CloudflareChallengeStore(ChallengeStore):
|
|
9
|
+
def __init__(self,api_key:str=None):
|
|
10
|
+
self.cloudflare = Cloudflare(api_key)
|
|
11
|
+
self.challenges_map = {}
|
|
12
|
+
|
|
13
|
+
def supports_domain(self, domain: str) -> bool:
|
|
14
|
+
"""
|
|
15
|
+
Checks if the Cloudflare account has access to the given domain (or its base domain)
|
|
16
|
+
as a registered zone.
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
self.cloudflare.determine_registered_domain(domain)
|
|
20
|
+
return True
|
|
21
|
+
except Exception:
|
|
22
|
+
return False
|
|
23
|
+
|
|
24
|
+
def save_challenge(self, key: str, value: str, domain=None):
|
|
25
|
+
# key example: _acme-challenge.sub.example.com
|
|
26
|
+
# value example: ACME_CHALLENGE_TOKEN
|
|
27
|
+
base_domain = self.cloudflare.determine_registered_domain(domain)
|
|
28
|
+
|
|
29
|
+
record_id = self.cloudflare.create_record(name=key, data=value, domain=base_domain)
|
|
30
|
+
self.challenges_map[key] = record_id
|
|
31
|
+
print(f"CloudflareChallengeStore: Saved challenge for {key} with record ID {record_id}")
|
|
32
|
+
time.sleep(10) # wait for dns propagation. it may not be immediate
|
|
33
|
+
|
|
34
|
+
def get_challenge(self, key: str, domain: str) -> str:
|
|
35
|
+
base_domain = self.cloudflare.determine_registered_domain(domain)
|
|
36
|
+
records = self.cloudflare.list_txt_records(base_domain, name_filter=key)
|
|
37
|
+
for record in records:
|
|
38
|
+
if record["name"] == key:
|
|
39
|
+
return record["content"]
|
|
40
|
+
return None # Return None if not found, as per ChallengeStore's __getitem__ behavior
|
|
41
|
+
|
|
42
|
+
def delete_challenge(self, key: str, domain: str):
|
|
43
|
+
if key not in self.challenges_map:
|
|
44
|
+
raise KeyError(f"Challenge {key} not found in store (no record_id stored).")
|
|
45
|
+
|
|
46
|
+
record_id = self.challenges_map[key]
|
|
47
|
+
base_domain = self.cloudflare.determine_registered_domain(domain)
|
|
48
|
+
self.cloudflare.delete_record(record=record_id, domain=base_domain)
|
|
49
|
+
del self.challenges_map[key]
|
|
50
|
+
print(f"CloudflareChallengeStore: Deleted challenge for {key} with record ID {record_id}")
|
|
51
|
+
|
|
52
|
+
def __iter__(self):
|
|
53
|
+
# This is tricky as we can't easily iterate all challenges across all domains
|
|
54
|
+
# If the user wants a full API-driven iteration, they need to clarify how to get all domains.
|
|
55
|
+
return iter(self.challenges_map)
|
|
56
|
+
|
|
57
|
+
def __len__(self):
|
|
58
|
+
# Similar to __iter__, this will count challenges managed by this store instance.
|
|
59
|
+
return len(self.challenges_map)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import time
|
|
3
|
+
from os import getenv
|
|
4
|
+
from urllib.request import urlopen, Request
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Cloudflare(object):
|
|
8
|
+
name = "cloudflare"
|
|
9
|
+
|
|
10
|
+
def __init__(self,api_key:str):
|
|
11
|
+
self.token = api_key
|
|
12
|
+
self.api = "https://api.cloudflare.com/client/v4"
|
|
13
|
+
if not self.token:
|
|
14
|
+
self.token = getenv("CLOUDFLARE_API_KEY")
|
|
15
|
+
if not self.token:
|
|
16
|
+
raise Exception("CLOUDFLARE_API_KEY not found in environment")
|
|
17
|
+
|
|
18
|
+
self._zones_cache = None
|
|
19
|
+
self._zones_cache_time = 0 # Unix timestamp of last cache update
|
|
20
|
+
|
|
21
|
+
def _cloudflare_headers(self):
|
|
22
|
+
return {"Content-Type": "application/json", "Authorization": "Bearer " + self.token}
|
|
23
|
+
|
|
24
|
+
def _get_zones(self):
|
|
25
|
+
"""Fetch and cache Cloudflare zones"""
|
|
26
|
+
# Cache for 1 day (86400 seconds)
|
|
27
|
+
if self._zones_cache and (time.time() - self._zones_cache_time) < 86400:
|
|
28
|
+
return self._zones_cache
|
|
29
|
+
|
|
30
|
+
request_headers = self._cloudflare_headers()
|
|
31
|
+
api_url = "{0}/zones?per_page=50".format(self.api)
|
|
32
|
+
response = urlopen(Request(api_url, headers=request_headers))
|
|
33
|
+
if response.getcode() != 200:
|
|
34
|
+
raise Exception(json.loads(response.read().decode("utf8")))
|
|
35
|
+
|
|
36
|
+
zones = json.loads(response.read().decode("utf8"))["result"]
|
|
37
|
+
self._zones_cache = zones
|
|
38
|
+
self._zones_cache_time = time.time()
|
|
39
|
+
return zones
|
|
40
|
+
|
|
41
|
+
def _get_zone_id(self, domain):
|
|
42
|
+
"""Determine Cloudflare Zone ID for a given domain"""
|
|
43
|
+
zones = self._get_zones()
|
|
44
|
+
for zone in zones:
|
|
45
|
+
if zone["name"] == domain:
|
|
46
|
+
return zone["id"]
|
|
47
|
+
raise Exception("No Cloudflare zone found for domain: {0}".format(domain))
|
|
48
|
+
|
|
49
|
+
def determine_registered_domain(self, domain: str) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Determine the registered domain in Cloudflare for a given (sub)domain.
|
|
52
|
+
This method iterates through parts of the domain to find a matching Cloudflare zone.
|
|
53
|
+
"""
|
|
54
|
+
parts = domain.split(".")
|
|
55
|
+
err = None
|
|
56
|
+
for i in range(len(parts)):
|
|
57
|
+
potential_domain = ".".join(parts[i:])
|
|
58
|
+
try:
|
|
59
|
+
self._get_zone_id(potential_domain)
|
|
60
|
+
return potential_domain
|
|
61
|
+
except Exception as e:
|
|
62
|
+
err = e
|
|
63
|
+
continue
|
|
64
|
+
if err:
|
|
65
|
+
raise err
|
|
66
|
+
else:
|
|
67
|
+
raise Exception("Could not determine Cloudflare registered domain for: {0}".format(domain))
|
|
68
|
+
|
|
69
|
+
def list_txt_records(self, domain: str, name_filter: str = None) -> list:
|
|
70
|
+
"""
|
|
71
|
+
Lists TXT records for a given domain, optionally filtered by name.
|
|
72
|
+
Returns a list of dictionaries, each representing a TXT record.
|
|
73
|
+
"""
|
|
74
|
+
registered_domain = self.determine_registered_domain(domain)
|
|
75
|
+
zone_id = self._get_zone_id(registered_domain)
|
|
76
|
+
api_url = f"{self.api}/zones/{zone_id}/dns_records?type=TXT"
|
|
77
|
+
if name_filter:
|
|
78
|
+
api_url += f"&name={name_filter}"
|
|
79
|
+
|
|
80
|
+
request_headers = self._cloudflare_headers()
|
|
81
|
+
response = urlopen(Request(api_url, headers=request_headers))
|
|
82
|
+
|
|
83
|
+
if response.getcode() != 200:
|
|
84
|
+
raise Exception(json.loads(response.read().decode("utf8")))
|
|
85
|
+
|
|
86
|
+
result = json.loads(response.read().decode("utf8"))
|
|
87
|
+
if not result.get("success"):
|
|
88
|
+
raise Exception(result.get("errors", "Unknown error listing TXT records"))
|
|
89
|
+
|
|
90
|
+
return result["result"]
|
|
91
|
+
|
|
92
|
+
def create_record(self, name, data, domain):
|
|
93
|
+
"""
|
|
94
|
+
Create DNS record
|
|
95
|
+
Params:
|
|
96
|
+
name, string, record name (e.g., _acme-challenge.example.com)
|
|
97
|
+
data, string, record data (e.g., ACME challenge token)
|
|
98
|
+
domain, string, dns domain (e.g., example.com) - This will be used to determine the registered zone.
|
|
99
|
+
Return:
|
|
100
|
+
record_id, string, created record id
|
|
101
|
+
"""
|
|
102
|
+
registered_domain = self.determine_registered_domain(domain)
|
|
103
|
+
zone_id = self._get_zone_id(registered_domain)
|
|
104
|
+
api_url = "{0}/zones/{1}/dns_records".format(self.api, zone_id)
|
|
105
|
+
request_headers = self._cloudflare_headers()
|
|
106
|
+
request_data = {
|
|
107
|
+
"type": "TXT",
|
|
108
|
+
"name": name,
|
|
109
|
+
"content": data,
|
|
110
|
+
"ttl": 120, # Cloudflare minimum TTL for TXT is 120 seconds
|
|
111
|
+
"proxied": False,
|
|
112
|
+
}
|
|
113
|
+
response = urlopen(Request(api_url, data=json.dumps(request_data).encode("utf8"), headers=request_headers))
|
|
114
|
+
|
|
115
|
+
if response.getcode() != 200:
|
|
116
|
+
raise Exception(json.loads(response.read().decode("utf8")))
|
|
117
|
+
result = response.read().decode("utf8")
|
|
118
|
+
print("Cloudflare create record", name, result)
|
|
119
|
+
return json.loads(result)["result"]["id"]
|
|
120
|
+
|
|
121
|
+
def delete_record(self, record, domain):
|
|
122
|
+
"""
|
|
123
|
+
Delete DNS record
|
|
124
|
+
Params:
|
|
125
|
+
record, string, record id number
|
|
126
|
+
domain, string, dns domain - This will be used to determine the registered zone.
|
|
127
|
+
"""
|
|
128
|
+
registered_domain = self.determine_registered_domain(domain)
|
|
129
|
+
zone_id = self._get_zone_id(registered_domain)
|
|
130
|
+
api_url = "{0}/zones/{1}/dns_records/{2}".format(self.api, zone_id, record)
|
|
131
|
+
request_headers = self._cloudflare_headers()
|
|
132
|
+
request = Request(api_url, headers=request_headers)
|
|
133
|
+
request.get_method = lambda: "DELETE"
|
|
134
|
+
response = urlopen(request)
|
|
135
|
+
result = response.read().decode("utf8")
|
|
136
|
+
print(f"Delete dns record [{response.getcode()}]", result)
|
|
137
|
+
if response.getcode() != 200:
|
|
138
|
+
raise Exception(json.loads(response.read().decode("utf8")))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import MutableMapping
|
|
3
|
+
from ...ChallengeStore import ChallengeStore
|
|
4
|
+
from .digitalocean_client import DigitalOcean
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DigitalOceanChallengeStore(ChallengeStore):
|
|
8
|
+
def __init__(self,api_key:str=None):
|
|
9
|
+
self.digitalocean = DigitalOcean(api_key)
|
|
10
|
+
self.challenges_map = {} # Stores key: record_id (needed for deletion)
|
|
11
|
+
|
|
12
|
+
def supports_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,85 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from os import getenv
|
|
3
|
+
|
|
4
|
+
from urllib.request import urlopen, Request # Python 3
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DigitalOcean(object):
|
|
8
|
+
def __init__(self, api_key:str=None):
|
|
9
|
+
self.token = api_key
|
|
10
|
+
self.api = "https://api.digitalocean.com/v2/domains"
|
|
11
|
+
if not self.token:
|
|
12
|
+
self.token = getenv("DIGITALOCEAN_API_KEY")
|
|
13
|
+
if not self.token:
|
|
14
|
+
raise Exception("DIGITALOCEAN_API_KEY not found in environment")
|
|
15
|
+
|
|
16
|
+
def determine_domain(self, domain):
|
|
17
|
+
"""Determine registered domain in API"""
|
|
18
|
+
request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
|
|
19
|
+
response = urlopen(Request(self.api, headers=request_headers))
|
|
20
|
+
if response.getcode() != 200:
|
|
21
|
+
raise Exception(json.loads(response.read().decode("utf8")))
|
|
22
|
+
domains = json.loads(response.read().decode("utf8"))["domains"]
|
|
23
|
+
for d in domains:
|
|
24
|
+
if d["name"] in domain:
|
|
25
|
+
return d["name"]
|
|
26
|
+
|
|
27
|
+
def create_record(self, name, data, domain):
|
|
28
|
+
"""
|
|
29
|
+
Create DNS record
|
|
30
|
+
Params:
|
|
31
|
+
name, string, record name
|
|
32
|
+
data, string, record data
|
|
33
|
+
domain, string, dns domain
|
|
34
|
+
Return:
|
|
35
|
+
record_id, int, created record id
|
|
36
|
+
"""
|
|
37
|
+
registered_domain = self.determine_domain(domain)
|
|
38
|
+
api = self.api + "/" + registered_domain + "/records"
|
|
39
|
+
request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
|
|
40
|
+
request_data = {"type": "TXT", "ttl": 300, "name": name, "data": data}
|
|
41
|
+
response = urlopen(Request(api, data=json.dumps(request_data).encode("utf8"), headers=request_headers))
|
|
42
|
+
if response.getcode() != 201:
|
|
43
|
+
raise Exception(json.loads(response.read().decode("utf8")))
|
|
44
|
+
return json.loads(response.read().decode("utf8"))["domain_record"]["id"]
|
|
45
|
+
|
|
46
|
+
def delete_record(self, record, domain):
|
|
47
|
+
"""
|
|
48
|
+
Delete DNS record
|
|
49
|
+
Params:
|
|
50
|
+
record, int, record id number
|
|
51
|
+
domain, string, dns domain
|
|
52
|
+
"""
|
|
53
|
+
registered_domain = self.determine_domain(domain)
|
|
54
|
+
api = self.api + "/" + registered_domain + "/records/" + str(record)
|
|
55
|
+
request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
|
|
56
|
+
request = Request(api, data=json.dumps({}).encode("utf8"), headers=request_headers)
|
|
57
|
+
request.get_method = lambda: "DELETE"
|
|
58
|
+
response = urlopen(request)
|
|
59
|
+
if response.getcode() != 204:
|
|
60
|
+
raise Exception(json.loads(response.read().decode("utf8")))
|
|
61
|
+
|
|
62
|
+
def list_records(self, domain, name_filter=None):
|
|
63
|
+
"""
|
|
64
|
+
List DNS records for a domain, optionally filtered by name.
|
|
65
|
+
Params:
|
|
66
|
+
domain, string, dns domain
|
|
67
|
+
name_filter, string, optional filter for record name
|
|
68
|
+
Return:
|
|
69
|
+
records, list of dicts, matching DNS records
|
|
70
|
+
"""
|
|
71
|
+
registered_domain = self.determine_domain(domain)
|
|
72
|
+
api = self.api + "/" + registered_domain + "/records"
|
|
73
|
+
request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
|
|
74
|
+
response = urlopen(Request(api, headers=request_headers))
|
|
75
|
+
if response.getcode() != 200:
|
|
76
|
+
raise Exception(json.loads(response.read().decode("utf8")))
|
|
77
|
+
|
|
78
|
+
all_records = json.loads(response.read().decode("utf8"))["domain_records"]
|
|
79
|
+
|
|
80
|
+
if name_filter:
|
|
81
|
+
filtered_records = [r for r in all_records if r["name"] == name_filter and r["type"] == "TXT"]
|
|
82
|
+
else:
|
|
83
|
+
filtered_records = [r for r in all_records if r["type"] == "TXT"]
|
|
84
|
+
|
|
85
|
+
return filtered_records
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import List, Dict, Any, Union, Optional
|
|
3
|
+
from cryptography.x509 import Certificate
|
|
4
|
+
from ..crypto.crypto import cert_to_pem, certs_to_pem
|
|
5
|
+
from ..crypto.crypto_classes import Key
|
|
6
|
+
|
|
7
|
+
class IssuedCert:
|
|
8
|
+
def __init__(self, *, key: Union[str, Key] = None, cert: Union[str, Certificate, List[Certificate]] = None, domains: List[str] = None):
|
|
9
|
+
if isinstance(key, Key):
|
|
10
|
+
key = key.to_pem().decode("utf-8")
|
|
11
|
+
elif isinstance(key, bytes):
|
|
12
|
+
key = key.decode("utf-8")
|
|
13
|
+
|
|
14
|
+
if isinstance(cert, list):
|
|
15
|
+
cert = certs_to_pem(cert).decode("utf-8")
|
|
16
|
+
elif isinstance(cert, Certificate):
|
|
17
|
+
cert = cert_to_pem(cert).decode("utf-8")
|
|
18
|
+
elif isinstance(cert, bytes):
|
|
19
|
+
cert = cert.decode("utf-8")
|
|
20
|
+
|
|
21
|
+
self.privateKey = key
|
|
22
|
+
self.certificate = cert
|
|
23
|
+
self.domains = domains
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def from_json(data: Dict[str, Any]) -> "IssuedCert":
|
|
27
|
+
return IssuedCert(
|
|
28
|
+
key=data.get('privateKey'),
|
|
29
|
+
cert=data.get('certificate'),
|
|
30
|
+
domains=data.get('domains', []),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def __repr__(self):
|
|
34
|
+
return f"IssuedCert(domains={self.domains})"
|
|
35
|
+
|
|
36
|
+
def __str__(self):
|
|
37
|
+
return f"(hosts: {self.domains}, certificate:{self.certificate})"
|
|
38
|
+
|
|
39
|
+
def to_json(self):
|
|
40
|
+
return {"privateKey": self.privateKey, "certificate": self.certificate, "domains": self.domains}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CertificateResponse:
|
|
44
|
+
def __init__(self, *, existing: List[IssuedCert] = None, issued: List[IssuedCert] = None):
|
|
45
|
+
self.existing: List[IssuedCert] = existing or []
|
|
46
|
+
self.issued: List[IssuedCert] = issued or []
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def from_json(data: Dict[str, Any]) -> "CertificateResponse":
|
|
50
|
+
return CertificateResponse(
|
|
51
|
+
existing=[IssuedCert.from_json(cert_data) for cert_data in data.get('existing', [])],
|
|
52
|
+
issued=[IssuedCert.from_json(cert_data) for cert_data in data.get('issued', [])]
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def __repr__(self):
|
|
56
|
+
return f"CertificateResponse(existing={self.existing}, issued={self.issued})"
|
|
57
|
+
|
|
58
|
+
def __str__(self):
|
|
59
|
+
if self.issued:
|
|
60
|
+
return f"(existing: {self.existing}, new: {self.issued})"
|
|
61
|
+
else:
|
|
62
|
+
return f"(existing: {self.existing})"
|
|
63
|
+
|
|
64
|
+
def to_json(self):
|
|
65
|
+
return {
|
|
66
|
+
"existing": [x.to_json() for x in self.existing],
|
|
67
|
+
"issued": [x.to_json() for x in self.issued],
|
|
68
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import List, Literal, Optional, Tuple, Union, Dict
|
|
3
|
+
|
|
4
|
+
from certapi import crypto
|
|
5
|
+
from ..acme import Challenge
|
|
6
|
+
from ..challenge_store import ChallengeStore
|
|
7
|
+
|
|
8
|
+
from ..issuers import AcmeCertIssuer,CertIssuer
|
|
9
|
+
from ..http.types import CertificateResponse, IssuedCert
|
|
10
|
+
from ..keystore.KeyStore import KeyStore
|
|
11
|
+
from cryptography.x509 import Certificate,CertificateSigningRequest
|
|
12
|
+
from ..crypto import Key,certs_to_pem, cert_to_pem,get_csr_hostnames
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AcmeCertManager():
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
key_store: KeyStore,
|
|
19
|
+
cert_issuer: AcmeCertIssuer,
|
|
20
|
+
challenge_stores: List[ChallengeStore] = [],
|
|
21
|
+
):
|
|
22
|
+
self.key_store: KeyStore = key_store
|
|
23
|
+
self.cert_issuer : AcmeCertIssuer = cert_issuer
|
|
24
|
+
self.challenge_stores : List[ChallengeStore]= challenge_stores
|
|
25
|
+
print(f"AcmeCertManager initialized with challenge_stores: {self.challenge_stores}")
|
|
26
|
+
|
|
27
|
+
def setup(self):
|
|
28
|
+
self.cert_issuer.setup()
|
|
29
|
+
|
|
30
|
+
def issue_certificate_for_csr(self, csr: CertificateSigningRequest)->str:
|
|
31
|
+
"""
|
|
32
|
+
Returns Certificate
|
|
33
|
+
"""
|
|
34
|
+
hostnames = get_csr_hostnames(csr)
|
|
35
|
+
if not hostnames:
|
|
36
|
+
raise ValueError("CSR does not contain any hostnames.")
|
|
37
|
+
|
|
38
|
+
# Find a challenge store that supports all hostnames in the CSR
|
|
39
|
+
selected_challenge_store = None
|
|
40
|
+
for store in self.challenge_stores:
|
|
41
|
+
if all(store.supports_domain(h) for h in hostnames):
|
|
42
|
+
selected_challenge_store = store
|
|
43
|
+
break
|
|
44
|
+
|
|
45
|
+
if selected_challenge_store is None:
|
|
46
|
+
raise ValueError(f"No challenge store found that supports all domains: {hostnames}")
|
|
47
|
+
|
|
48
|
+
fullchain_cert = self.cert_issuer.sign_csr(csr, challenge_store=selected_challenge_store)
|
|
49
|
+
if fullchain_cert:
|
|
50
|
+
# Assuming the private key associated with the CSR is not managed by CertManager directly
|
|
51
|
+
# and is handled by the caller or the cert_issuer's internal process.
|
|
52
|
+
# For now, we'll just return the certificate.
|
|
53
|
+
# If key saving is required here, the private key would need to be passed along with the CSR.
|
|
54
|
+
return fullchain_cert
|
|
55
|
+
else:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def issue_certificate(self, hosts: Union[str, List[str]],
|
|
59
|
+
key_type: Literal["rsa", "ecdsa", "ed25519"] = "rsa",
|
|
60
|
+
expiry_days: int = 90,
|
|
61
|
+
country: Optional[str] = None,
|
|
62
|
+
state: Optional[str] = None,
|
|
63
|
+
locality: Optional[str] = None,
|
|
64
|
+
organization: Optional[str] = None,
|
|
65
|
+
user_id: Optional[str] = None) -> CertificateResponse:
|
|
66
|
+
|
|
67
|
+
if type(hosts) == str:
|
|
68
|
+
hosts = [hosts]
|
|
69
|
+
|
|
70
|
+
existing : Dict[str, Tuple[int | str, Key, List[Certificate] | str]]= {}
|
|
71
|
+
for h in hosts:
|
|
72
|
+
result = self.key_store.find_key_and_cert_by_domain(h)
|
|
73
|
+
if result is not None:
|
|
74
|
+
# result is (domain_id, key, cert_list)
|
|
75
|
+
existing[h] = result # Store the certificate list
|
|
76
|
+
|
|
77
|
+
missing = [h for h in hosts if h not in existing]
|
|
78
|
+
if len(missing) > 0:
|
|
79
|
+
issued_certs_list = []
|
|
80
|
+
# Group missing hosts by the challenge store that supports them
|
|
81
|
+
domains_by_store: Dict[ChallengeStore, List[str]] = {}
|
|
82
|
+
for host in missing:
|
|
83
|
+
found_store = None
|
|
84
|
+
for store in self.challenge_stores:
|
|
85
|
+
print(f"Debug: Checking store {store} for domain {host}. supports_domain: {store.supports_domain(host)}")
|
|
86
|
+
if store.supports_domain(host):
|
|
87
|
+
print(f"Supports domain: '{host}'",)
|
|
88
|
+
found_store = store
|
|
89
|
+
break
|
|
90
|
+
if found_store is not None:
|
|
91
|
+
if found_store not in domains_by_store:
|
|
92
|
+
domains_by_store[found_store] = []
|
|
93
|
+
domains_by_store[found_store].append(host)
|
|
94
|
+
else:
|
|
95
|
+
print(f"Debug: Current challenge_stores: {self.challenge_stores}")
|
|
96
|
+
print(f"Warning: No challenge store found that supports domain: {host}. Skipping.")
|
|
97
|
+
|
|
98
|
+
# original_challenge_store = self.cert_issuer.challenge_store # Store original
|
|
99
|
+
|
|
100
|
+
for store, domains_to_issue in domains_by_store.items():
|
|
101
|
+
|
|
102
|
+
private_key, fullchain_cert = self.cert_issuer.generate_key_and_cert_for_domains(
|
|
103
|
+
domains_to_issue,
|
|
104
|
+
key_type=key_type,
|
|
105
|
+
expiry_days=expiry_days,
|
|
106
|
+
country=country,
|
|
107
|
+
state=state,
|
|
108
|
+
locality=locality,
|
|
109
|
+
organization=organization,
|
|
110
|
+
user_id=user_id,
|
|
111
|
+
challenge_store=store
|
|
112
|
+
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if fullchain_cert:
|
|
116
|
+
key_id = self.key_store.save_key(private_key, domains_to_issue[0])
|
|
117
|
+
self.key_store.save_cert(key_id, fullchain_cert, domains_to_issue)
|
|
118
|
+
issued_certs_list.append(IssuedCert(key=private_key, cert=fullchain_cert, domains=domains_to_issue))
|
|
119
|
+
else:
|
|
120
|
+
print(f"Failed to issue certificate for domains: {domains_to_issue}")
|
|
121
|
+
|
|
122
|
+
# self.cert_issuer.challenge_store = original_challenge_store # Restore original
|
|
123
|
+
return createExistingResponse(existing, issued_certs_list)
|
|
124
|
+
|
|
125
|
+
else:
|
|
126
|
+
return createExistingResponse(existing, [])
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def createExistingResponse(
|
|
130
|
+
existing: Dict[str, Tuple[int | str, Key, List[Certificate] | str]], issued_certs: List[IssuedCert]
|
|
131
|
+
):
|
|
132
|
+
certs = []
|
|
133
|
+
certMap = {}
|
|
134
|
+
|
|
135
|
+
for h, (id, key, cert) in existing.items():
|
|
136
|
+
if id in certMap:
|
|
137
|
+
certMap[id][0].append(h)
|
|
138
|
+
else:
|
|
139
|
+
if isinstance(cert, str):
|
|
140
|
+
cert_pem = cert
|
|
141
|
+
elif isinstance(cert, list):
|
|
142
|
+
cert_pem = certs_to_pem(cert).decode("utf-8")
|
|
143
|
+
else:
|
|
144
|
+
cert_pem = cert_to_pem(cert).decode("utf-8")
|
|
145
|
+
|
|
146
|
+
certMap[id] = (
|
|
147
|
+
[h],
|
|
148
|
+
key,
|
|
149
|
+
cert_pem,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
for hosts, key, cert in certMap.values():
|
|
153
|
+
certs.append(IssuedCert(key=key, cert=cert, domains=hosts))
|
|
154
|
+
|
|
155
|
+
return CertificateResponse(existing=certs, issued=issued_certs)
|
|
File without changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: certapi
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: Python Package for managing keys, request SSL certificates from ACME.
|
|
5
5
|
Home-page: https://github.com/mesudip/certapi
|
|
6
6
|
Author: Sudip Bhattarai
|
|
@@ -33,7 +33,7 @@ import json
|
|
|
33
33
|
from certapi import FileSystemChallengeStore, FilesystemKeyStore, CertAuthority
|
|
34
34
|
|
|
35
35
|
key_store = FilesystemKeyStore("data")
|
|
36
|
-
challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/
|
|
36
|
+
challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/
|
|
37
37
|
|
|
38
38
|
certAuthority = CertAuthority(challenge_store, key_store)
|
|
39
39
|
certAuthority.setup()
|
|
@@ -22,11 +22,20 @@ src/certapi/challenge_store/FilesystemChallengeStore.py
|
|
|
22
22
|
src/certapi/challenge_store/InmemoryChallengeStore.py
|
|
23
23
|
src/certapi/challenge_store/__init__.py
|
|
24
24
|
src/certapi/challenge_store/dns/__init__.py
|
|
25
|
+
src/certapi/challenge_store/dns/cloudflare/__init__.py
|
|
26
|
+
src/certapi/challenge_store/dns/cloudflare/cloudflare_challenge_store.py
|
|
27
|
+
src/certapi/challenge_store/dns/cloudflare/cloudflare_client.py
|
|
28
|
+
src/certapi/challenge_store/dns/digitalocean/__init__.py
|
|
29
|
+
src/certapi/challenge_store/dns/digitalocean/digitalocean_challenge_store.py
|
|
30
|
+
src/certapi/challenge_store/dns/digitalocean/digitalocean_client.py
|
|
25
31
|
src/certapi/client/__init__.py
|
|
26
32
|
src/certapi/client/cert_manager_client.py
|
|
27
33
|
src/certapi/crypto/__init__.py
|
|
28
34
|
src/certapi/crypto/crypto.py
|
|
29
35
|
src/certapi/crypto/crypto_classes.py
|
|
36
|
+
src/certapi/http/__init__.py
|
|
37
|
+
src/certapi/http/client.py
|
|
38
|
+
src/certapi/http/types.py
|
|
30
39
|
src/certapi/issuers/AcmeCertIssuer.py
|
|
31
40
|
src/certapi/issuers/SelfCertIssuer.py
|
|
32
41
|
src/certapi/issuers/__init__.py
|
|
@@ -37,6 +46,8 @@ src/certapi/keystore/PostgresqlKeyStore.py
|
|
|
37
46
|
src/certapi/keystore/RemoteKeyStore.py
|
|
38
47
|
src/certapi/keystore/SqliteKeyStore.py
|
|
39
48
|
src/certapi/keystore/__init__.py
|
|
49
|
+
src/certapi/manager/__init__.py
|
|
50
|
+
src/certapi/manager/acme_cert_manager.py
|
|
40
51
|
src/certapi/server/__init__.py
|
|
41
52
|
src/certapi/server/api.py
|
|
42
53
|
src/certapi/server/cert_api.py
|
|
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
|
|
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
|
|
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
|