certapi 0.2.2__tar.gz → 0.2.4__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.2 → certapi-0.2.4}/PKG-INFO +3 -1
- {certapi-0.2.2 → certapi-0.2.4}/README.md +2 -0
- {certapi-0.2.2 → certapi-0.2.4}/setup.py +1 -1
- {certapi-0.2.2 → certapi-0.2.4}/src/certapi/Acme.py +5 -4
- {certapi-0.2.2 → certapi-0.2.4}/src/certapi/certauthority.py +8 -4
- {certapi-0.2.2 → certapi-0.2.4}/src/certapi/cloudflare_challenge_store.py +1 -1
- {certapi-0.2.2 → certapi-0.2.4}/src/certapi/crypto.py +5 -0
- {certapi-0.2.2 → certapi-0.2.4}/src/certapi/db.py +25 -15
- certapi-0.2.4/src/certapi/digitalocean_challenge_store.py +57 -0
- certapi-0.2.4/src/certapi/digitalocean_client.py +86 -0
- {certapi-0.2.2 → certapi-0.2.4}/src/certapi.egg-info/PKG-INFO +3 -1
- {certapi-0.2.2 → certapi-0.2.4}/src/certapi.egg-info/SOURCES.txt +2 -0
- {certapi-0.2.2 → certapi-0.2.4}/MANIFEST.in +0 -0
- {certapi-0.2.2 → certapi-0.2.4}/pyproject.toml +0 -0
- {certapi-0.2.2 → certapi-0.2.4}/setup.cfg +0 -0
- {certapi-0.2.2 → certapi-0.2.4}/src/certapi/__init__.py +0 -0
- {certapi-0.2.2 → certapi-0.2.4}/src/certapi/challenge.py +0 -0
- {certapi-0.2.2 → certapi-0.2.4}/src/certapi/cloudflare_client.py +0 -0
- {certapi-0.2.2 → certapi-0.2.4}/src/certapi/crypto_classes.py +0 -0
- {certapi-0.2.2 → certapi-0.2.4}/src/certapi/custom_certauthority.py +0 -0
- {certapi-0.2.2 → certapi-0.2.4}/src/certapi/util.py +0 -0
- {certapi-0.2.2 → certapi-0.2.4}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-0.2.2 → certapi-0.2.4}/src/certapi.egg-info/requires.txt +0 -0
- {certapi-0.2.2 → certapi-0.2.4}/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.2.
|
|
3
|
+
Version: 0.2.4
|
|
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
|
|
@@ -16,6 +16,8 @@ Description-Content-Type: text/markdown
|
|
|
16
16
|
CertApi is a Python package for requesting SSL certificates from ACME.
|
|
17
17
|
This is supposed to be used as a base library for building other tools, or to integrate Certificate creation feature in you app.
|
|
18
18
|
|
|
19
|
+
> ⚠️ Warning: This project is not polished for production use. Please stay tuned for the LTS `v1.0.0` release.
|
|
20
|
+
|
|
19
21
|
## Installation
|
|
20
22
|
|
|
21
23
|
You can install CertApi using pip:
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
CertApi is a Python package for requesting SSL certificates from ACME.
|
|
4
4
|
This is supposed to be used as a base library for building other tools, or to integrate Certificate creation feature in you app.
|
|
5
5
|
|
|
6
|
+
> ⚠️ Warning: This project is not polished for production use. Please stay tuned for the LTS `v1.0.0` release.
|
|
7
|
+
|
|
6
8
|
## Installation
|
|
7
9
|
|
|
8
10
|
You can install CertApi using pip:
|
|
@@ -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
|
|
@@ -325,7 +326,7 @@ class Order:
|
|
|
325
326
|
self.status = self._data["status"]
|
|
326
327
|
return response
|
|
327
328
|
|
|
328
|
-
def get_certificate(self) -> Certificate:
|
|
329
|
+
def get_certificate(self) -> Tuple[str, List[Certificate]]:
|
|
329
330
|
if self.status == "processing":
|
|
330
331
|
raise ValueError(
|
|
331
332
|
"Order is still in 'processing' state! Wait until the order is finalized, and call `Order.refresh()` to update the state"
|
|
@@ -336,8 +337,8 @@ class Order:
|
|
|
336
337
|
certificate_res = self._acme._signed_req(
|
|
337
338
|
self._data["certificate"], step="Get Certificate from Successful Order"
|
|
338
339
|
)
|
|
339
|
-
certificate = crypto.x509.
|
|
340
|
-
return certificate
|
|
340
|
+
certificate = crypto.x509.load_pem_x509_certificates(certificate_res.content)
|
|
341
|
+
return ( certificate_res.content, certificate)
|
|
341
342
|
|
|
342
343
|
def finalize(self, csr: CertificateSigningRequest):
|
|
343
344
|
"""
|
|
@@ -121,10 +121,10 @@ class CertAuthority:
|
|
|
121
121
|
order.refresh() # is this refresh necessary?
|
|
122
122
|
|
|
123
123
|
if order.status == "valid":
|
|
124
|
-
certificate =
|
|
124
|
+
fullchain_cert,certificate = order.get_certificate()
|
|
125
125
|
key_id = self.key_store.save_key(private_key, missing[0])
|
|
126
126
|
cert_id = self.key_store.save_cert(key_id, certificate, missing)
|
|
127
|
-
issued_cert = IssuedCert(key_to_pem(private_key),
|
|
127
|
+
issued_cert = IssuedCert(key_to_pem(private_key), fullchain_cert, missing)
|
|
128
128
|
# Clean up challenges after successful certificate issuance
|
|
129
129
|
for c in challenges:
|
|
130
130
|
challenge_name = f"_acme-challenge.{c.domain}" if has_wildcard else c.token
|
|
@@ -145,24 +145,28 @@ class CertAuthority:
|
|
|
145
145
|
return createExistingResponse(existing, [])
|
|
146
146
|
|
|
147
147
|
|
|
148
|
-
def createExistingResponse(existing: Dict[str, Tuple[int | str, Key, Certificate]], issued_certs: List["IssuedCert"]):
|
|
148
|
+
def createExistingResponse(existing: Dict[str, Tuple[int | str, Key, Certificate | str]], issued_certs: List["IssuedCert"]):
|
|
149
149
|
certs = []
|
|
150
150
|
certMap = {}
|
|
151
|
+
|
|
151
152
|
for h, (id, key, cert) in existing.items():
|
|
152
153
|
if id in certMap:
|
|
153
154
|
certMap[id][0].append(h)
|
|
154
155
|
else:
|
|
156
|
+
cert_pem = cert if isinstance(cert, str) else cert_to_pem(cert).decode("utf-8")
|
|
155
157
|
certMap[id] = (
|
|
156
158
|
[h],
|
|
157
159
|
key.to_pem().decode("utf-8"),
|
|
158
|
-
|
|
160
|
+
cert_pem,
|
|
159
161
|
)
|
|
162
|
+
|
|
160
163
|
for hosts, key, cert in certMap.values():
|
|
161
164
|
certs.append(IssuedCert(key, cert, hosts))
|
|
162
165
|
|
|
163
166
|
return CertificateResponse(certs, issued_certs)
|
|
164
167
|
|
|
165
168
|
|
|
169
|
+
|
|
166
170
|
class CertificateResponse:
|
|
167
171
|
def __init__(self, existing, issued):
|
|
168
172
|
self.existing: List[IssuedCert] = existing
|
|
@@ -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
|
"""
|
|
@@ -106,6 +106,11 @@ def key_from_pem(data: bytes):
|
|
|
106
106
|
def cert_from_pem(data: bytes) -> Certificate:
|
|
107
107
|
return x509.load_pem_x509_certificate(data)
|
|
108
108
|
|
|
109
|
+
def certs_from_pem(data: bytes) -> List[Certificate]:
|
|
110
|
+
return x509.load_pem_x509_certificates(data)
|
|
111
|
+
|
|
112
|
+
def cert_to_der(cert):
|
|
113
|
+
return cert.public_bytes(serialization.Encoding.DER)
|
|
109
114
|
|
|
110
115
|
def cert_to_pem(cert):
|
|
111
116
|
return cert.public_bytes(serialization.Encoding.PEM)
|
|
@@ -21,7 +21,7 @@ class KeyStore(ABC):
|
|
|
21
21
|
pass
|
|
22
22
|
|
|
23
23
|
@abstractmethod
|
|
24
|
-
def save_cert(self, private_key_id: int, cert: Certificate, domains: List[str], name: str = None) -> int:
|
|
24
|
+
def save_cert(self, private_key_id: int, cert: Certificate|str, domains: List[str], name: str = None) -> int:
|
|
25
25
|
pass
|
|
26
26
|
|
|
27
27
|
@abstractmethod
|
|
@@ -91,12 +91,12 @@ class SqliteKeyStore(KeyStore):
|
|
|
91
91
|
self.save_key(key, name)
|
|
92
92
|
return key
|
|
93
93
|
|
|
94
|
-
def save_cert(self, private_key_id: int, cert: Certificate, domains: List[str], name: str = None) -> int:
|
|
94
|
+
def save_cert(self, private_key_id: int, cert: Certificate|str, domains: List[str], name: str = None) -> int:
|
|
95
95
|
conn = self._get_db_connection()
|
|
96
96
|
cur = conn.cursor()
|
|
97
97
|
cur.execute(
|
|
98
98
|
"INSERT INTO certificates (name, priv_id, content) VALUES (?, ?, ?)",
|
|
99
|
-
(name, private_key_id, cert
|
|
99
|
+
(name, private_key_id, cert_to_der(cert)),
|
|
100
100
|
)
|
|
101
101
|
cert_id = cur.lastrowid
|
|
102
102
|
|
|
@@ -182,32 +182,42 @@ class FilesystemKeyStore(KeyStore):
|
|
|
182
182
|
return cert_from_pem(cert_data)
|
|
183
183
|
return None
|
|
184
184
|
|
|
185
|
-
|
|
185
|
+
|
|
186
|
+
def save_cert(self, private_key_id: str, cert: Certificate | str, domains: list, name: str = None) -> int:
|
|
187
|
+
def get_cert_pem(cert) -> bytes:
|
|
188
|
+
return cert.encode() if isinstance(cert, str) else cert_to_pem(cert)
|
|
189
|
+
|
|
190
|
+
cert_pem = get_cert_pem(cert)
|
|
191
|
+
|
|
186
192
|
if name:
|
|
187
193
|
cert_path = os.path.join(self.certs_dir, f"{name}.crt")
|
|
188
194
|
with open(cert_path, "wb") as f:
|
|
189
|
-
f.write(
|
|
195
|
+
f.write(cert_pem)
|
|
196
|
+
|
|
190
197
|
key_content = None
|
|
191
198
|
key_path = os.path.join(self.keys_dir, f"{private_key_id}.key")
|
|
192
199
|
with open(key_path, "rb") as f:
|
|
193
200
|
key_content = f.read()
|
|
194
|
-
|
|
201
|
+
|
|
195
202
|
for domain in domains:
|
|
203
|
+
domain_name = domain
|
|
196
204
|
if name is not None and name.endswith(".selfsigned"):
|
|
197
|
-
|
|
205
|
+
domain_name += ".selfsigned"
|
|
198
206
|
|
|
199
|
-
if
|
|
200
|
-
with open(os.path.join(self.keys_dir, f"{
|
|
207
|
+
if domain_name != private_key_id:
|
|
208
|
+
with open(os.path.join(self.keys_dir, f"{domain_name}.key"), "wb") as f:
|
|
201
209
|
f.write(key_content)
|
|
202
|
-
|
|
210
|
+
|
|
211
|
+
domain_cert_path = os.path.join(self.certs_dir, f"{domain_name}.crt")
|
|
203
212
|
with open(domain_cert_path, "wb") as f:
|
|
204
|
-
f.write(
|
|
213
|
+
f.write(cert_pem)
|
|
214
|
+
|
|
205
215
|
|
|
206
216
|
return name if name else domains[0] # Dummy ID since filesystem does not use numeric IDs
|
|
207
217
|
|
|
208
|
-
def get_cert(self,
|
|
209
|
-
cert_path = os.path.join(self.certs_dir, f"{
|
|
210
|
-
key_path = os.path.join(self.keys_dir, f"{
|
|
218
|
+
def get_cert(self, name: str) -> None | Tuple[str, Key, Certificate]:
|
|
219
|
+
cert_path = os.path.join(self.certs_dir, f"{name}.crt")
|
|
220
|
+
key_path = os.path.join(self.keys_dir, f"{name}.key")
|
|
211
221
|
key = None
|
|
212
222
|
cert = None
|
|
213
223
|
if os.path.exists(key_path):
|
|
@@ -226,7 +236,7 @@ class FilesystemKeyStore(KeyStore):
|
|
|
226
236
|
|
|
227
237
|
if cert is None or key is None:
|
|
228
238
|
return None
|
|
229
|
-
return (
|
|
239
|
+
return (name, key, cert)
|
|
230
240
|
|
|
231
241
|
|
|
232
242
|
class PostgresKeyStore(KeyStore):
|
|
@@ -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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: certapi
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
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
|
|
@@ -16,6 +16,8 @@ Description-Content-Type: text/markdown
|
|
|
16
16
|
CertApi is a Python package for requesting SSL certificates from ACME.
|
|
17
17
|
This is supposed to be used as a base library for building other tools, or to integrate Certificate creation feature in you app.
|
|
18
18
|
|
|
19
|
+
> ⚠️ Warning: This project is not polished for production use. Please stay tuned for the LTS `v1.0.0` release.
|
|
20
|
+
|
|
19
21
|
## Installation
|
|
20
22
|
|
|
21
23
|
You can install CertApi using pip:
|
|
@@ -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
|