franztls 0.1.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.
franztls-0.1.4/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Alpamayo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: franztls
3
+ Version: 0.1.4
4
+ Summary: Lightweight, open-source ACME client for automating TLS certificate issuance and renewal.
5
+ Author-email: Alpamayo <info@alpamayo-solutions.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 Alpamayo
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/alpamayo-solutions/franztls
29
+ Project-URL: Issues, https://github.com/alpamayo-solutions/franztls/issues
30
+ Classifier: Programming Language :: Python :: 3
31
+ Classifier: Programming Language :: Python :: 3.10
32
+ Classifier: Programming Language :: Python :: 3.11
33
+ Classifier: Programming Language :: Python :: 3.12
34
+ Requires-Python: >=3.10
35
+ Description-Content-Type: text/markdown
36
+ License-File: LICENSE
37
+ Requires-Dist: cryptography>=42.0
38
+ Requires-Dist: acme>=2.0
39
+ Requires-Dist: josepy>=1.13.0
40
+ Provides-Extra: dev
41
+ Requires-Dist: build>=1.2.1; extra == "dev"
42
+ Requires-Dist: twine>=5.0.0; extra == "dev"
43
+ Dynamic: license-file
44
+
45
+ # ๐Ÿ›ก๏ธ Franztls โ€” Simple ACME Certificate Manager for Python
46
+
47
+
48
+ `franztls` is a lightweight, open-source ACME client for automating TLS certificate issuance and renewal.
49
+ It works similarly to Let's Encrypt clients, but is designed for embedding directly in Python services or scripts.
50
+
51
+ ---
52
+
53
+ ## ๐Ÿš€ Features
54
+
55
+ - Fully self-contained ACME (RFC 8555) client
56
+ - Automatic certificate **issuance** and **renewal**
57
+ - Built-in **HTTP-01** challenge server
58
+ - Generates and stores **account**, **domain keys**, and **CSRs**
59
+ - Works with **local or public ACME servers**
60
+ - Minimal dependencies (`cryptography`, `acme`, `josepy`)
61
+ - Compatible with **Python โ‰ฅ3.10**
62
+
63
+ ---
64
+
65
+ ## ๐Ÿ“ฆ Installation
66
+
67
+ ```bash
68
+ pip install franztls
@@ -0,0 +1,24 @@
1
+ # ๐Ÿ›ก๏ธ Franztls โ€” Simple ACME Certificate Manager for Python
2
+
3
+
4
+ `franztls` is a lightweight, open-source ACME client for automating TLS certificate issuance and renewal.
5
+ It works similarly to Let's Encrypt clients, but is designed for embedding directly in Python services or scripts.
6
+
7
+ ---
8
+
9
+ ## ๐Ÿš€ Features
10
+
11
+ - Fully self-contained ACME (RFC 8555) client
12
+ - Automatic certificate **issuance** and **renewal**
13
+ - Built-in **HTTP-01** challenge server
14
+ - Generates and stores **account**, **domain keys**, and **CSRs**
15
+ - Works with **local or public ACME servers**
16
+ - Minimal dependencies (`cryptography`, `acme`, `josepy`)
17
+ - Compatible with **Python โ‰ฅ3.10**
18
+
19
+ ---
20
+
21
+ ## ๐Ÿ“ฆ Installation
22
+
23
+ ```bash
24
+ pip install franztls
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "franztls"
7
+ version = "0.1.4"
8
+ description = "Lightweight, open-source ACME client for automating TLS certificate issuance and renewal."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Alpamayo", email = "info@alpamayo-solutions.com" }]
13
+ dependencies = [
14
+ "cryptography>=42.0",
15
+ "acme>=2.0",
16
+ "josepy>=1.13.0",
17
+ ]
18
+
19
+ # These show up on PyPI
20
+ classifiers = [
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/alpamayo-solutions/franztls"
29
+ Issues = "https://github.com/alpamayo-solutions/franztls/issues"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "build>=1.2.1",
37
+ "twine>=5.0.0",
38
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ __all__ = [
2
+ "CertManager",
3
+ "CertificateExpiredException",
4
+ ]
5
+
6
+ from .cert_manager import CertManager
7
+ from .exceptions import CertificateExpiredException
@@ -0,0 +1,244 @@
1
+ import os
2
+ import base64
3
+ import threading
4
+ import time
5
+ import logging
6
+ from http.server import HTTPServer, BaseHTTPRequestHandler
7
+ from datetime import datetime, timedelta, timezone
8
+ from typing import Optional
9
+
10
+ from cryptography import x509
11
+ from cryptography.x509.oid import NameOID
12
+ from cryptography.hazmat.primitives import hashes, serialization
13
+ from cryptography.hazmat.primitives.asymmetric import rsa
14
+ from cryptography.hazmat.backends import default_backend
15
+ from josepy import JWKRSA
16
+ from acme.client import ClientNetwork
17
+ from acme import client, messages, errors, challenges
18
+
19
+
20
+ logger = logging.getLogger("CertManager")
21
+ if not logger.handlers:
22
+ logging.basicConfig(level=logging.INFO)
23
+
24
+
25
+ class ReusableHTTPServer(HTTPServer):
26
+ allow_reuse_address = True
27
+
28
+
29
+ class CertManager:
30
+ def __init__(
31
+ self,
32
+ service_id: str,
33
+ domain: str,
34
+ acme_directory: Optional[str] = "https://ca.localhost:9000/acme/acme/directory",
35
+ ca_file: Optional[str] = "/etc/certs/ca.crt",
36
+ account_key_path: Optional[str] = "/etc/certs/account.key",
37
+ domain_key_path: Optional[str] = "/etc/certs/domain.key",
38
+ csr_path: Optional[str] = "/etc/certs/domain.csr",
39
+ cert_path: Optional[str] = "/etc/certs/domain.pem",
40
+ renewal_buffer_hours: int = 24
41
+ ) -> None:
42
+ self.service_id = service_id
43
+ self.domain = domain
44
+ self.acme_directory = acme_directory
45
+ self.ca_file = ca_file
46
+
47
+ self.account_key_path = account_key_path
48
+ self.domain_key_path = domain_key_path
49
+ self.csr_path = csr_path
50
+ self.cert_path = cert_path
51
+
52
+ self.challenge_responses = {}
53
+ self.httpd: Optional[HTTPServer] = None
54
+ self.thread: Optional[threading.Thread] = None
55
+ self._expiration_date: Optional[datetime] = None
56
+
57
+ self.acct_key = self._load_or_create_key(self.account_key_path)
58
+ self.jwkey = JWKRSA(key=self.acct_key)
59
+ self.client_net = ClientNetwork(
60
+ self.jwkey,
61
+ user_agent="franzTLS/1.0",
62
+ verify_ssl=self.ca_file
63
+ )
64
+ self.acme = self._connect_acme()
65
+ self.domain_key = self._load_or_create_key(self.domain_key_path)
66
+ self.renewal_buffer_hours = renewal_buffer_hours
67
+
68
+ @property
69
+ def cert_file(self) -> str:
70
+ return self.cert_path
71
+
72
+ @property
73
+ def key_file(self) -> str:
74
+ return self.domain_key_path
75
+
76
+ @property
77
+ def cert(self) -> Optional[bytes]:
78
+ if os.path.exists(self.cert_file):
79
+ with open(self.cert_file, "rb") as f:
80
+ return f.read()
81
+ logger.error(f"๐Ÿ” No certificate found at {self.cert_file}. Do you still need to create it?")
82
+ return None
83
+
84
+ @property
85
+ def key(self) -> Optional[bytes]:
86
+ if os.path.exists(self.key_file):
87
+ with open(self.key_file, "rb") as f:
88
+ return f.read()
89
+ logger.error(f"๐Ÿ” No key found at {self.key_file}. Do you still need to create it?")
90
+ return None
91
+
92
+ @property
93
+ def ca(self) -> str:
94
+ return self.ca_file
95
+
96
+ @property
97
+ def expiration_date(self) -> Optional[datetime]:
98
+ if self._expiration_date is None and self.cert:
99
+ try:
100
+ cert = x509.load_pem_x509_certificate(self.cert, default_backend())
101
+ self._expiration_date = cert.not_valid_after_utc
102
+ except Exception as e:
103
+ logger.warning(f"โš ๏ธ Failed to parse expiration date: {e}")
104
+ return self._expiration_date
105
+
106
+ def _load_or_create_key(self, path: str):
107
+ if os.path.exists(path):
108
+ logger.debug(f"๐Ÿ”‘ Loading existing key from {path}")
109
+ with open(path, "rb") as f:
110
+ return serialization.load_pem_private_key(f.read(), password=None)
111
+ logger.info(f"๐Ÿ” Generating new key at {path}")
112
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
113
+ with open(path, "wb") as f:
114
+ f.write(key.private_bytes(
115
+ encoding=serialization.Encoding.PEM,
116
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
117
+ encryption_algorithm=serialization.NoEncryption()))
118
+ return key
119
+
120
+ def _generate_csr(self) -> x509.CertificateSigningRequest:
121
+ logger.info("๐Ÿ“„ Generating CSR...")
122
+
123
+ san_list = [
124
+ x509.DNSName(self.domain),
125
+ ]
126
+
127
+ csr_builder = x509.CertificateSigningRequestBuilder().subject_name(x509.Name([
128
+ x509.NameAttribute(NameOID.COMMON_NAME, self.domain),
129
+ ])).add_extension(
130
+ x509.SubjectAlternativeName(san_list),
131
+ critical=False
132
+ )
133
+
134
+ csr = csr_builder.sign(self.domain_key, hashes.SHA256())
135
+
136
+ with open(self.csr_path, "wb") as f:
137
+ f.write(csr.public_bytes(serialization.Encoding.PEM))
138
+ return csr
139
+
140
+ def _connect_acme(self) -> client.ClientV2:
141
+ logger.info("๐Ÿ”— Connecting to ACME server...")
142
+ directory = messages.Directory.from_json(self.client_net.get(self.acme_directory).json())
143
+ acme = client.ClientV2(directory, self.client_net)
144
+ try:
145
+ acme.new_account(messages.NewRegistration.from_data(
146
+ terms_of_service_agreed=True,
147
+ email="admin@localhost"
148
+ ))
149
+ logger.info("โœ… Account registered.")
150
+ except errors.ConflictError as e:
151
+ logger.info("๐Ÿงพ Already registered, using existing account.")
152
+ acme.net.account = {"uri": e.location}
153
+ return acme
154
+
155
+ def _start_http_challenge_server(self) -> None:
156
+ class ChallengeHandler(BaseHTTPRequestHandler):
157
+ def do_GET(inner_self):
158
+ token = inner_self.path.split("/")[-1]
159
+ if token in self.challenge_responses:
160
+ inner_self.send_response(200)
161
+ inner_self.send_header("Content-Type", "text/plain")
162
+ inner_self.end_headers()
163
+ inner_self.wfile.write(self.challenge_responses[token].encode())
164
+ else:
165
+ inner_self.send_response(404)
166
+ inner_self.end_headers()
167
+
168
+ logger.debug("๐ŸŒ Starting HTTP-01 challenge server on port 80...")
169
+ self.httpd = ReusableHTTPServer(("0.0.0.0", 80), ChallengeHandler)
170
+ self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
171
+ self.thread.start()
172
+
173
+ def _stop_http_challenge_server(self) -> None:
174
+ if self.httpd:
175
+ logger.info("๐Ÿ›‘ Stopping HTTP-01 challenge server...")
176
+ self.httpd.shutdown()
177
+ self.thread.join()
178
+ self.httpd.server_close() # Ensure socket is released
179
+ self.httpd = None
180
+ self.thread = None
181
+
182
+ @property
183
+ def needs_renewal(self) -> bool:
184
+ return self.expiration_date is None or datetime.now(timezone.utc) + timedelta(hours=self.renewal_buffer_hours) >= self.expiration_date
185
+
186
+ def renew_if_necessary(self) -> None:
187
+ exp = self.expiration_date
188
+ if exp is None:
189
+ logger.info("โณ No certificate found or unable to parse โ€” forcing renewal...")
190
+ self.force_renew()
191
+ return
192
+ if datetime.now(timezone.utc) + timedelta(hours=self.renewal_buffer_hours) >= exp:
193
+ logger.info(f"โณ Certificate expiring soon ({exp}) โ€” renewing...")
194
+ self.force_renew()
195
+ else:
196
+ logger.info(f"โœ… Certificate is still valid until {exp}. No renewal needed.")
197
+
198
+ def force_renew(self) -> None:
199
+ logger.info("๐Ÿ”„ Starting forced renewal...")
200
+ self.challenge_responses.clear()
201
+ csr = self._generate_csr()
202
+ self._start_http_challenge_server()
203
+
204
+ try:
205
+ order = self.acme.new_order(csr.public_bytes(serialization.Encoding.PEM))
206
+ for authz in order.authorizations:
207
+ chall = next(c for c in authz.body.challenges if isinstance(c.chall, challenges.HTTP01))
208
+ token = (base64.urlsafe_b64encode(chall.token).decode("utf-8").rstrip("=")
209
+ if isinstance(chall.token, bytes) else chall.token)
210
+ response, validation = chall.response_and_validation(self.jwkey)
211
+ self.challenge_responses[token] = validation
212
+
213
+ logger.info(f"๐Ÿงช Starting challenge validation for {authz.body.identifier} and token {token}")
214
+ self.acme.answer_challenge(chall, response)
215
+
216
+ for _ in range(30):
217
+ status = self.acme.net.post(authz.uri, obj=None).json()["status"]
218
+ logger.debug(f"๐Ÿ” Challenge status: {status}")
219
+ if status == "valid":
220
+ logger.info("โœ… Challenge validated!")
221
+ break
222
+ elif status == "invalid":
223
+ logger.error("โŒ Challenge validation failed.")
224
+ raise Exception("Challenge validation failed.")
225
+ time.sleep(2)
226
+ else:
227
+ raise TimeoutError("โŒ Challenge validation timed out.")
228
+
229
+ logger.info("๐Ÿ” Finalizing order...")
230
+ order = self.acme.poll_and_finalize(order)
231
+ with open(self.cert_file, "wb") as f:
232
+ f.write(order.fullchain_pem.encode())
233
+
234
+ try:
235
+ self._expiration_date = x509.load_pem_x509_certificate(
236
+ order.fullchain_pem.encode(), default_backend()
237
+ ).not_valid_after_utc
238
+ except Exception as e:
239
+ logger.warning(f"โš ๏ธ Could not parse newly issued cert expiration: {e}")
240
+
241
+ logger.info(f"โœ… Certificate saved to {self.cert_file}")
242
+
243
+ finally:
244
+ self._stop_http_challenge_server()
@@ -0,0 +1,2 @@
1
+ class CertificateExpiredException(Exception):
2
+ pass
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: franztls
3
+ Version: 0.1.4
4
+ Summary: Lightweight, open-source ACME client for automating TLS certificate issuance and renewal.
5
+ Author-email: Alpamayo <info@alpamayo-solutions.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 Alpamayo
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/alpamayo-solutions/franztls
29
+ Project-URL: Issues, https://github.com/alpamayo-solutions/franztls/issues
30
+ Classifier: Programming Language :: Python :: 3
31
+ Classifier: Programming Language :: Python :: 3.10
32
+ Classifier: Programming Language :: Python :: 3.11
33
+ Classifier: Programming Language :: Python :: 3.12
34
+ Requires-Python: >=3.10
35
+ Description-Content-Type: text/markdown
36
+ License-File: LICENSE
37
+ Requires-Dist: cryptography>=42.0
38
+ Requires-Dist: acme>=2.0
39
+ Requires-Dist: josepy>=1.13.0
40
+ Provides-Extra: dev
41
+ Requires-Dist: build>=1.2.1; extra == "dev"
42
+ Requires-Dist: twine>=5.0.0; extra == "dev"
43
+ Dynamic: license-file
44
+
45
+ # ๐Ÿ›ก๏ธ Franztls โ€” Simple ACME Certificate Manager for Python
46
+
47
+
48
+ `franztls` is a lightweight, open-source ACME client for automating TLS certificate issuance and renewal.
49
+ It works similarly to Let's Encrypt clients, but is designed for embedding directly in Python services or scripts.
50
+
51
+ ---
52
+
53
+ ## ๐Ÿš€ Features
54
+
55
+ - Fully self-contained ACME (RFC 8555) client
56
+ - Automatic certificate **issuance** and **renewal**
57
+ - Built-in **HTTP-01** challenge server
58
+ - Generates and stores **account**, **domain keys**, and **CSRs**
59
+ - Works with **local or public ACME servers**
60
+ - Minimal dependencies (`cryptography`, `acme`, `josepy`)
61
+ - Compatible with **Python โ‰ฅ3.10**
62
+
63
+ ---
64
+
65
+ ## ๐Ÿ“ฆ Installation
66
+
67
+ ```bash
68
+ pip install franztls
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/franztls/__init__.py
5
+ src/franztls/cert_manager.py
6
+ src/franztls/exceptions.py
7
+ src/franztls.egg-info/PKG-INFO
8
+ src/franztls.egg-info/SOURCES.txt
9
+ src/franztls.egg-info/dependency_links.txt
10
+ src/franztls.egg-info/requires.txt
11
+ src/franztls.egg-info/top_level.txt
@@ -0,0 +1,7 @@
1
+ cryptography>=42.0
2
+ acme>=2.0
3
+ josepy>=1.13.0
4
+
5
+ [dev]
6
+ build>=1.2.1
7
+ twine>=5.0.0
@@ -0,0 +1 @@
1
+ franztls