certbot-nginx-unit 1.0.0__py3-none-any.whl
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.
- certbot_nginx_unit/__init__.py +1 -0
- certbot_nginx_unit/configurator.py +362 -0
- certbot_nginx_unit/tests/__init__.py +1 -0
- certbot_nginx_unit/tests/configurator_test.py +195 -0
- certbot_nginx_unit/unitc.py +48 -0
- certbot_nginx_unit-1.0.0.dist-info/LICENSE +21 -0
- certbot_nginx_unit-1.0.0.dist-info/METADATA +180 -0
- certbot_nginx_unit-1.0.0.dist-info/RECORD +11 -0
- certbot_nginx_unit-1.0.0.dist-info/WHEEL +5 -0
- certbot_nginx_unit-1.0.0.dist-info/entry_points.txt +2 -0
- certbot_nginx_unit-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Certbot nginx unit plugin."""
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Nginx Unit Certbot plugins.
|
|
2
|
+
|
|
3
|
+
Authenticate and Install new o renewed certificates on Nginx Unit
|
|
4
|
+
Authenticator is built on Certbot Webroot giant shoulders
|
|
5
|
+
|
|
6
|
+
"""
|
|
7
|
+
import collections
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
|
|
12
|
+
from typing import Any, Callable, Optional, List, Union, Iterable, DefaultDict, Set, Type
|
|
13
|
+
|
|
14
|
+
from acme import challenges
|
|
15
|
+
from certbot import errors
|
|
16
|
+
from certbot import interfaces
|
|
17
|
+
from certbot.achallenges import AnnotatedChallenge
|
|
18
|
+
from certbot.compat import filesystem
|
|
19
|
+
from certbot.compat import os
|
|
20
|
+
from certbot.display import util as display_util
|
|
21
|
+
from certbot.plugins import common
|
|
22
|
+
from certbot.plugins.util import get_prefixes
|
|
23
|
+
from certbot.util import safe_open
|
|
24
|
+
|
|
25
|
+
from .unitc import Unitc
|
|
26
|
+
|
|
27
|
+
CONFIG_TLS_CERTIFICATE_PATH = "/listeners/*:443/tls/certificate"
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Configurator(common.Installer, interfaces.Authenticator):
|
|
33
|
+
"""Nginx Unit certificate authenticator and installer plugin for Certbot"""
|
|
34
|
+
|
|
35
|
+
description = """\
|
|
36
|
+
Nginx Unit certificate installer plugin for Certbot: \
|
|
37
|
+
saves the necessary validation files to a .well-known/acme-challenge/ directory within the \
|
|
38
|
+
nominated webroot path. Nginx Unit server must be running. \
|
|
39
|
+
HTTP challenge only (wildcards not supported)."""
|
|
40
|
+
|
|
41
|
+
MORE_INFO = """\
|
|
42
|
+
Authenticator plugin that performs http-01 challenge by saving
|
|
43
|
+
necessary validation resources to appropriate paths on the file
|
|
44
|
+
system. It expects that there is some other HTTP server configured
|
|
45
|
+
to serve all files under specified web root ({0})."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
|
48
|
+
super().__init__(*args, **kwargs)
|
|
49
|
+
self._configuration = None
|
|
50
|
+
self.unitc = Unitc()
|
|
51
|
+
self._entropy = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
52
|
+
|
|
53
|
+
self._challenge_path: str = ""
|
|
54
|
+
self._full_root: str = ""
|
|
55
|
+
self._performed: DefaultDict[str, Set[AnnotatedChallenge]] = collections.defaultdict(set)
|
|
56
|
+
self._created_dirs: List[str] = []
|
|
57
|
+
|
|
58
|
+
def get_all_names(self) -> Iterable[str]:
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
def deploy_cert(self, domain: str, cert_path: str, key_path: str, chain_path: str, fullchain_path: str) -> None:
|
|
62
|
+
"""Deploy certificate.
|
|
63
|
+
|
|
64
|
+
:param str domain: domain to deploy certificate file
|
|
65
|
+
:param str cert_path: absolute path to the certificate file
|
|
66
|
+
:param str key_path: absolute path to the private key file
|
|
67
|
+
:param str chain_path: absolute path to the certificate chain file
|
|
68
|
+
:param str fullchain_path: absolute path to the certificate fullchain
|
|
69
|
+
file (cert plus chain)
|
|
70
|
+
|
|
71
|
+
:raises .PluginError: when cert cannot be deployed
|
|
72
|
+
|
|
73
|
+
"""
|
|
74
|
+
logger.debug("deploy cert for domain: %s", domain)
|
|
75
|
+
|
|
76
|
+
cert_bundle_name = domain + "_" + self._entropy
|
|
77
|
+
self._upload_certificates(fullchain_path, key_path, cert_bundle_name)
|
|
78
|
+
|
|
79
|
+
certificates_chains = self._get_unit_configuration("/certificates")
|
|
80
|
+
|
|
81
|
+
old_certificate_bundle_names = []
|
|
82
|
+
for bundle_name, certificate in certificates_chains.items():
|
|
83
|
+
for chain in certificate["chain"]:
|
|
84
|
+
# @todo check validity chain["validity"]["until"]?
|
|
85
|
+
if bundle_name == cert_bundle_name or chain["subject"]["common_name"] != domain:
|
|
86
|
+
continue
|
|
87
|
+
old_certificate_bundle_names.append(bundle_name)
|
|
88
|
+
|
|
89
|
+
if self._configuration is None:
|
|
90
|
+
self._configuration = self._get_unit_configuration("/config")
|
|
91
|
+
self._update_certificate_name_list_to_config(cert_bundle_name, old_certificate_bundle_names)
|
|
92
|
+
|
|
93
|
+
display_util.notify(f"Remove old certificates for {domain}")
|
|
94
|
+
for old_bundle_name in old_certificate_bundle_names:
|
|
95
|
+
self._delete_certificates(old_bundle_name)
|
|
96
|
+
|
|
97
|
+
def _upload_certificates(self, fullchain_path: str, key_path: str, cert_bundle_name: str):
|
|
98
|
+
certificates = self._get_certificates_content(fullchain_path, key_path)
|
|
99
|
+
path = "/certificates/" + cert_bundle_name
|
|
100
|
+
success_message = "Certificate deployed"
|
|
101
|
+
error_message = "nginx unit copy to /certificates failed"
|
|
102
|
+
self.unitc.put(path, certificates, success_message, error_message)
|
|
103
|
+
|
|
104
|
+
def _delete_certificates(self, cert_bundle_name: str):
|
|
105
|
+
path = "/certificates/" + cert_bundle_name
|
|
106
|
+
success_message = "Certificate deleted"
|
|
107
|
+
error_message = "nginx unit delete from /certificates failed"
|
|
108
|
+
self.unitc.delete(path, None, success_message, error_message)
|
|
109
|
+
|
|
110
|
+
def _update_certificate_name_list_to_config(self, cert_bundle_name: str, bundle_names_to_remove):
|
|
111
|
+
self._ensure_tls_listener()
|
|
112
|
+
|
|
113
|
+
cert_bundle_names = self._configuration["listeners"]["*:443"]["tls"]["certificate"]
|
|
114
|
+
cert_bundle_names = [item for item in cert_bundle_names if item not in bundle_names_to_remove]
|
|
115
|
+
cert_bundle_names.append(cert_bundle_name)
|
|
116
|
+
self._configuration["listeners"]["*:443"]["tls"]["certificate"] = cert_bundle_names
|
|
117
|
+
|
|
118
|
+
path = "/config/listeners"
|
|
119
|
+
input_data = json.dumps(self._configuration["listeners"]).encode()
|
|
120
|
+
success_message = "Certificate deployed"
|
|
121
|
+
error_message = "nginx unit copy to /certificates failed"
|
|
122
|
+
|
|
123
|
+
self.unitc.put(path, input_data, success_message, error_message)
|
|
124
|
+
|
|
125
|
+
def _ensure_tls_listener(self):
|
|
126
|
+
if "listeners" not in self._configuration:
|
|
127
|
+
raise errors.PluginError("No listeners configured")
|
|
128
|
+
if "*:443" not in self._configuration["listeners"]:
|
|
129
|
+
if "*:80" not in self._configuration["listeners"]:
|
|
130
|
+
raise errors.PluginError("No '*:80' default listeners configured")
|
|
131
|
+
self._configuration["listeners"]["*:443"] = self._configuration["listeners"]["*:80"]
|
|
132
|
+
new_route = self._ensure_acme_route(self._configuration["listeners"]["*:443"]["pass"])
|
|
133
|
+
self._configuration["listeners"]["*:80"] = {"pass": new_route}
|
|
134
|
+
|
|
135
|
+
if "tls" not in self._configuration["listeners"]["*:443"]:
|
|
136
|
+
self._configuration["listeners"]["*:443"]["tls"] = {}
|
|
137
|
+
if "certificate" not in self._configuration["listeners"]["*:443"]["tls"]:
|
|
138
|
+
self._configuration["listeners"]["*:443"]["tls"]["certificate"] = []
|
|
139
|
+
|
|
140
|
+
def _ensure_challenge_listener(self):
|
|
141
|
+
if "listeners" not in self._configuration:
|
|
142
|
+
raise errors.PluginError("No listeners configured")
|
|
143
|
+
if "*:80" not in self._configuration["listeners"]:
|
|
144
|
+
raise errors.PluginError("No '*:80' default listeners configured")
|
|
145
|
+
if "pass" not in self._configuration["listeners"]["*:80"]:
|
|
146
|
+
raise errors.PluginError("Cannot configure the route for the *:80 listener")
|
|
147
|
+
|
|
148
|
+
actual_route = self._configuration["listeners"]["*:80"]["pass"]
|
|
149
|
+
default_route = self._ensure_acme_route(actual_route)
|
|
150
|
+
if actual_route == default_route:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
self._configuration["listeners"]["*:80"]["pass"] = default_route
|
|
154
|
+
success_message = "Updated listener for acme challenge"
|
|
155
|
+
error_message = "Update listener for acme challenge failed"
|
|
156
|
+
listener_route = "/config/listeners/*:80/pass"
|
|
157
|
+
self.unitc.put(listener_route, default_route.encode(), success_message, error_message)
|
|
158
|
+
|
|
159
|
+
def _ensure_acme_route(self, actual_route: str) -> str:
|
|
160
|
+
acme_challenge_url = "/.well-known/acme-challenge/*"
|
|
161
|
+
acme_base_path = "/srv/www/unit"
|
|
162
|
+
acme_route = [
|
|
163
|
+
{
|
|
164
|
+
"match": {"uri": acme_challenge_url},
|
|
165
|
+
"action": {"share": acme_base_path + "/$uri"},
|
|
166
|
+
}
|
|
167
|
+
]
|
|
168
|
+
if actual_route != "routes" and actual_route != "routes/acme":
|
|
169
|
+
acme_route.append({"pass": actual_route})
|
|
170
|
+
|
|
171
|
+
acme_route_json = json.dumps(acme_route)
|
|
172
|
+
|
|
173
|
+
if "routes" not in self._configuration or not self._configuration["routes"]:
|
|
174
|
+
self._configuration["routes"] = acme_route
|
|
175
|
+
self.unitc.put("/config/routes", acme_route_json.encode())
|
|
176
|
+
return "routes"
|
|
177
|
+
|
|
178
|
+
if isinstance(self._configuration["routes"], dict):
|
|
179
|
+
if "acme" in self._configuration["routes"]:
|
|
180
|
+
return "routes/acme"
|
|
181
|
+
|
|
182
|
+
self._configuration["routes"]["acme"] = acme_route
|
|
183
|
+
self.unitc.put("/config/routes/acme", acme_route_json.encode())
|
|
184
|
+
return "routes/acme"
|
|
185
|
+
|
|
186
|
+
if not isinstance(self._configuration["routes"], list):
|
|
187
|
+
raise errors.PluginError("Cannot configure the routes: unknown route type")
|
|
188
|
+
|
|
189
|
+
if not isinstance(self._configuration["routes"][0], dict):
|
|
190
|
+
raise errors.PluginError("Cannot configure the routes: unknown route[0] type")
|
|
191
|
+
|
|
192
|
+
first_route = self._configuration["routes"][0]
|
|
193
|
+
if "match" in first_route and "uri" in first_route["match"] and first_route["match"][
|
|
194
|
+
"uri"] == acme_challenge_url:
|
|
195
|
+
return "routes"
|
|
196
|
+
|
|
197
|
+
routes = acme_route + self._configuration["routes"]
|
|
198
|
+
self._configuration["routes"] = routes
|
|
199
|
+
self.unitc.put("/config/routes", json.dumps(routes).encode())
|
|
200
|
+
return "routes"
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _get_certificates_content(fullchain_path, key_path):
|
|
204
|
+
with open(key_path, "rb") as f:
|
|
205
|
+
certificates = f.read()
|
|
206
|
+
with open(fullchain_path, "rb") as f:
|
|
207
|
+
certificates += f.read()
|
|
208
|
+
return certificates
|
|
209
|
+
|
|
210
|
+
def _get_unit_configuration(self, path: str):
|
|
211
|
+
error_message = "nginx unit get configuration failed"
|
|
212
|
+
configuration_str = self.unitc.get(path, "Get configuration", error_message)
|
|
213
|
+
|
|
214
|
+
# @todo wrap configuration to a dedicated class
|
|
215
|
+
return json.loads(configuration_str)
|
|
216
|
+
|
|
217
|
+
def enhance(self, domain: str, enhancement: str, options: Optional[Union[List[str], str]] = None) -> None:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
def supported_enhancements(self) -> List[str]:
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
def save(self, title: Optional[str] = None, temporary: bool = False) -> None:
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
def rollback_checkpoints(self, rollback: int = 1) -> None:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
def recovery_routine(self) -> None:
|
|
230
|
+
pass
|
|
231
|
+
|
|
232
|
+
def config_test(self) -> None:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
def restart(self) -> None:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
def prepare(self) -> None:
|
|
239
|
+
"""Prepare the authenticator/installer."""
|
|
240
|
+
# @todo verify "unitc" executable
|
|
241
|
+
# @todo lock to prevent concurrent multi update
|
|
242
|
+
self._configuration = self._get_unit_configuration("/config")
|
|
243
|
+
|
|
244
|
+
def more_info(self) -> str: # pylint: disable=missing-function-docstring
|
|
245
|
+
return self.MORE_INFO.format(self.conf("path"))
|
|
246
|
+
|
|
247
|
+
### Authenticator
|
|
248
|
+
@classmethod
|
|
249
|
+
def add_parser_arguments(cls, add: Callable[..., None]) -> None:
|
|
250
|
+
add("path", default=['/srv/www/unit/'],
|
|
251
|
+
help="public_html / webroot path. Only one catch 'em all temporary "
|
|
252
|
+
"directory --nginx-unit-path /srv/www/unit/ (default: /srv/www/unit/)")
|
|
253
|
+
|
|
254
|
+
def get_chall_pref(self, domain: str) -> Iterable[Type[challenges.Challenge]]:
|
|
255
|
+
# pylint: disable=unused-argument,missing-function-docstring
|
|
256
|
+
return [challenges.HTTP01]
|
|
257
|
+
|
|
258
|
+
def perform(self, achalls: List[AnnotatedChallenge]) -> List[
|
|
259
|
+
challenges.ChallengeResponse]: # pylint: disable=missing-function-docstring
|
|
260
|
+
|
|
261
|
+
self._set_webroot(achalls)
|
|
262
|
+
self._create_challenge_dir()
|
|
263
|
+
|
|
264
|
+
self._ensure_challenge_listener()
|
|
265
|
+
|
|
266
|
+
return [self._perform_single(achall) for achall in achalls]
|
|
267
|
+
|
|
268
|
+
def _set_webroot(self, achalls: Iterable[AnnotatedChallenge]) -> None:
|
|
269
|
+
webroot_path = '/srv/www/unit/'
|
|
270
|
+
if self.conf("path"):
|
|
271
|
+
webroot_path = self.conf("path")[-1]
|
|
272
|
+
|
|
273
|
+
logger.info("Using the webroot path %s for all domains.", webroot_path)
|
|
274
|
+
|
|
275
|
+
if not os.path.isdir(webroot_path):
|
|
276
|
+
raise errors.PluginError(
|
|
277
|
+
"You should specify the webroot path (or temporary directory) '" +
|
|
278
|
+
webroot_path + "' does not exist or is not a directory")
|
|
279
|
+
|
|
280
|
+
self._challenge_path = os.path.abspath(webroot_path)
|
|
281
|
+
|
|
282
|
+
def _create_challenge_dir(self) -> None:
|
|
283
|
+
if not self._challenge_path:
|
|
284
|
+
raise errors.PluginError(
|
|
285
|
+
"Missing parts of nginx_unit plugin configuration.")
|
|
286
|
+
|
|
287
|
+
self._full_root = os.path.join(self._challenge_path, os.path.normcase(challenges.HTTP01.URI_ROOT_PATH))
|
|
288
|
+
logger.debug("Creating root challenges validation dir at %s", self._full_root)
|
|
289
|
+
|
|
290
|
+
# Change the permissions to be writable (certbot GH #1389)
|
|
291
|
+
# Umask is used instead of chmod to ensure the client can also
|
|
292
|
+
# run as non-root (certbot GH #1795)
|
|
293
|
+
old_umask = filesystem.umask(0o022)
|
|
294
|
+
try:
|
|
295
|
+
# We ignore the last prefix in the next iteration,
|
|
296
|
+
# as it does not correspond to a folder path ('/' or 'C:')
|
|
297
|
+
for prefix in sorted(get_prefixes(self._full_root)[:-1], key=len):
|
|
298
|
+
if os.path.isdir(prefix):
|
|
299
|
+
# Don't try to create directory if it already exists, as some filesystems
|
|
300
|
+
# won't reliably raise EEXIST or EISDIR if directory exists.
|
|
301
|
+
continue
|
|
302
|
+
try:
|
|
303
|
+
# Set owner as parent directory if possible, apply mode for Linux/Windows.
|
|
304
|
+
# For Linux, this is coupled with the "umask" call above because
|
|
305
|
+
# os.mkdir's "mode" parameter may not always work:
|
|
306
|
+
# https://docs.python.org/3/library/os.html#os.mkdir
|
|
307
|
+
filesystem.mkdir(prefix, 0o755)
|
|
308
|
+
self._created_dirs.append(prefix)
|
|
309
|
+
try:
|
|
310
|
+
filesystem.copy_ownership_and_apply_mode(
|
|
311
|
+
self._challenge_path, prefix, 0o755, copy_user=True, copy_group=True)
|
|
312
|
+
except (OSError, AttributeError) as exception:
|
|
313
|
+
logger.warning("Unable to change owner and uid of webroot directory")
|
|
314
|
+
logger.debug("Error was: %s", exception)
|
|
315
|
+
except OSError as exception:
|
|
316
|
+
raise errors.PluginError(
|
|
317
|
+
"Couldn't create root for http-01 "
|
|
318
|
+
"challenge responses: {0}".format(exception))
|
|
319
|
+
finally:
|
|
320
|
+
filesystem.umask(old_umask)
|
|
321
|
+
|
|
322
|
+
def _get_validation_path(self, root_path: str, achall: AnnotatedChallenge) -> str:
|
|
323
|
+
return os.path.join(root_path, achall.chall.encode("token"))
|
|
324
|
+
|
|
325
|
+
def _perform_single(self, achall: AnnotatedChallenge) -> challenges.ChallengeResponse:
|
|
326
|
+
response, validation = achall.response_and_validation()
|
|
327
|
+
|
|
328
|
+
root_path = self._full_root
|
|
329
|
+
validation_path = self._get_validation_path(root_path, achall)
|
|
330
|
+
logger.debug("Attempting to save validation to %s", validation_path)
|
|
331
|
+
|
|
332
|
+
# Change permissions to be world-readable, owner-writable (certbot GH #1795)
|
|
333
|
+
old_umask = filesystem.umask(0o022)
|
|
334
|
+
try:
|
|
335
|
+
with safe_open(validation_path, mode="wb", chmod=0o644) as validation_file:
|
|
336
|
+
validation_file.write(validation.encode())
|
|
337
|
+
finally:
|
|
338
|
+
filesystem.umask(old_umask)
|
|
339
|
+
|
|
340
|
+
self._performed[root_path].add(achall)
|
|
341
|
+
return response
|
|
342
|
+
|
|
343
|
+
def cleanup(self, achalls: List[AnnotatedChallenge]) -> None: # pylint: disable=missing-function-docstring
|
|
344
|
+
for achall in achalls:
|
|
345
|
+
root_path = self._full_root
|
|
346
|
+
if root_path is not None:
|
|
347
|
+
validation_path = self._get_validation_path(root_path, achall)
|
|
348
|
+
logger.debug("Removing %s", validation_path)
|
|
349
|
+
os.remove(validation_path)
|
|
350
|
+
self._performed[root_path].remove(achall)
|
|
351
|
+
|
|
352
|
+
not_removed: List[str] = []
|
|
353
|
+
while self._created_dirs:
|
|
354
|
+
path = self._created_dirs.pop()
|
|
355
|
+
try:
|
|
356
|
+
os.rmdir(path)
|
|
357
|
+
except OSError as exc:
|
|
358
|
+
not_removed.insert(0, path)
|
|
359
|
+
logger.info("Challenge directory %s was not empty, didn't remove", path)
|
|
360
|
+
logger.debug("Error was: %s", exc)
|
|
361
|
+
self._created_dirs = not_removed
|
|
362
|
+
logger.debug("All challenges cleaned up")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""certbot-nginx-unit tests"""
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Test for certbot_nginx.installer."""
|
|
2
|
+
import json
|
|
3
|
+
import tempfile
|
|
4
|
+
|
|
5
|
+
from unittest import mock
|
|
6
|
+
|
|
7
|
+
from certbot import errors
|
|
8
|
+
from certbot.compat import os
|
|
9
|
+
from certbot.tests import util as test_util
|
|
10
|
+
from certbot_nginx_unit.configurator import Configurator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def empty_configuration():
|
|
14
|
+
return {
|
|
15
|
+
"listeners": {},
|
|
16
|
+
"routes": [],
|
|
17
|
+
"applications": {}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def only_80_listener_configuration():
|
|
22
|
+
return {
|
|
23
|
+
"listeners": {
|
|
24
|
+
"*:80": {
|
|
25
|
+
"pass": "routes"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"routes": [
|
|
29
|
+
{
|
|
30
|
+
"action": {
|
|
31
|
+
"share": "/srv/www/unit/index.html"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def only_80_app_listener_configuration():
|
|
39
|
+
return {
|
|
40
|
+
"listeners": {
|
|
41
|
+
"*:80": {
|
|
42
|
+
"pass": "applications/dokuwiki"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"routes": [],
|
|
46
|
+
"application": {
|
|
47
|
+
"dokuwiki": {
|
|
48
|
+
"type": "php",
|
|
49
|
+
"root": "/path/to/app/",
|
|
50
|
+
"index": "doku.php"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def only_80_listener_configuration_after_cert_list():
|
|
57
|
+
return {
|
|
58
|
+
"listeners": {
|
|
59
|
+
"*:80": {"pass": "routes/acme"},
|
|
60
|
+
"*:443": {"pass": "routes/default"}
|
|
61
|
+
},
|
|
62
|
+
"routes": [
|
|
63
|
+
{
|
|
64
|
+
"match": {"uri": "/.well-known/acme-challenge/*"},
|
|
65
|
+
"action": {"share": "/srv/www/unit/$uri"},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"action": {"share": "/srv/www/unit/index.html"}
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def only_80_listener_configuration_after_cert_dictionary():
|
|
75
|
+
return {
|
|
76
|
+
"listeners": {
|
|
77
|
+
"*:80": {"pass": "routes/acme"},
|
|
78
|
+
"*:443": {"pass": "routes/default"}
|
|
79
|
+
},
|
|
80
|
+
"routes": {
|
|
81
|
+
"acme": {
|
|
82
|
+
"match": {"uri": "/.well-known/acme-challenge/*"},
|
|
83
|
+
"action": {"share": "/srv/www/unit/$uri"},
|
|
84
|
+
},
|
|
85
|
+
"default": {
|
|
86
|
+
"action": {"share": "/srv/www/unit/index.html"}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_configuration_side_effect(*args):
|
|
93
|
+
if args[0] == "/config":
|
|
94
|
+
return json.dumps(empty_configuration())
|
|
95
|
+
if args[0] == "/certificates":
|
|
96
|
+
return '{}'
|
|
97
|
+
return 'invalid json'
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_configuration_side_effect_80_listener(*args):
|
|
101
|
+
if args[0] == "/config":
|
|
102
|
+
return json.dumps(only_80_listener_configuration())
|
|
103
|
+
if args[0] == "/certificates":
|
|
104
|
+
return '{}'
|
|
105
|
+
return 'invalid json'
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def put_configuration_side_effect_80_listener(*args):
|
|
109
|
+
if args[0] == "/config/listeners":
|
|
110
|
+
return json.dumps(only_80_listener_configuration()['listeners'])
|
|
111
|
+
|
|
112
|
+
return '{}'
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ConfiguratorTest(test_util.ConfigTestCase):
|
|
116
|
+
"""Test for certbot_nginx.configutator"""
|
|
117
|
+
|
|
118
|
+
def setUp(self):
|
|
119
|
+
super().setUp()
|
|
120
|
+
|
|
121
|
+
self.configuration = self.config
|
|
122
|
+
self.config = None
|
|
123
|
+
logs_dir = tempfile.mkdtemp('logs')
|
|
124
|
+
self.config = self.get_nginx_unit_configurator(logs_dir)
|
|
125
|
+
|
|
126
|
+
def get_nginx_unit_configurator(self, logs_dir):
|
|
127
|
+
"""Create a Configurator with the specified options."""
|
|
128
|
+
|
|
129
|
+
backups = os.path.join(logs_dir, "backups")
|
|
130
|
+
self.configuration.backup_dir = backups
|
|
131
|
+
|
|
132
|
+
return Configurator(self.configuration, name="nginx_unit")
|
|
133
|
+
|
|
134
|
+
@mock.patch('certbot_nginx_unit.unitc')
|
|
135
|
+
def test_empty_configuration(self, unitc_mock):
|
|
136
|
+
unitc_mock.get.side_effect = get_configuration_side_effect
|
|
137
|
+
|
|
138
|
+
installer = self.config
|
|
139
|
+
installer.unitc = unitc_mock
|
|
140
|
+
installer.prepare()
|
|
141
|
+
|
|
142
|
+
with tempfile.NamedTemporaryFile() as cert_file:
|
|
143
|
+
assert [] == installer.get_all_names()
|
|
144
|
+
with self.assertRaises(errors.PluginError) as ctx:
|
|
145
|
+
installer.deploy_cert("domain", "cert.pem", cert_file.name, "chain_path", cert_file.name)
|
|
146
|
+
|
|
147
|
+
expected_msg = "No '*:80' default listeners configured"
|
|
148
|
+
self.assertEquals(str(ctx.exception), expected_msg)
|
|
149
|
+
|
|
150
|
+
@mock.patch('certbot_nginx_unit.unitc')
|
|
151
|
+
def test_only_80_listener_configuration(self, unitc_mock):
|
|
152
|
+
unitc_mock.get.side_effect = get_configuration_side_effect_80_listener
|
|
153
|
+
unitc_mock.put.side_effect = put_configuration_side_effect_80_listener
|
|
154
|
+
|
|
155
|
+
installer = self.config
|
|
156
|
+
installer.unitc = unitc_mock
|
|
157
|
+
installer.prepare()
|
|
158
|
+
|
|
159
|
+
notify = mock.patch('certbot.display.util.notify')
|
|
160
|
+
notify.start()
|
|
161
|
+
|
|
162
|
+
with tempfile.NamedTemporaryFile() as cert_file:
|
|
163
|
+
cert_file.write('certificate content'.encode())
|
|
164
|
+
cert_file.seek(0)
|
|
165
|
+
assert [] == installer.get_all_names()
|
|
166
|
+
installer.deploy_cert("domain", "cert.pem", cert_file.name, "chain_path", cert_file.name)
|
|
167
|
+
|
|
168
|
+
get_success_message = 'Get configuration'
|
|
169
|
+
get_error_message = 'nginx unit get configuration failed'
|
|
170
|
+
|
|
171
|
+
put_success_message = 'Certificate deployed'
|
|
172
|
+
put_error_message = 'nginx unit copy to /certificates failed'
|
|
173
|
+
|
|
174
|
+
entropy = installer._entropy
|
|
175
|
+
|
|
176
|
+
unitc_mock.get.assert_any_call("/config", get_success_message, get_error_message)
|
|
177
|
+
unitc_mock.get.assert_any_call('/certificates', get_success_message, get_error_message)
|
|
178
|
+
unitc_mock.put.assert_any_call(
|
|
179
|
+
"/certificates/domain_" + entropy,
|
|
180
|
+
b'certificate contentcertificate content',
|
|
181
|
+
'Certificate deployed',
|
|
182
|
+
'nginx unit copy to /certificates failed'
|
|
183
|
+
)
|
|
184
|
+
unitc_mock.put.assert_any_call(
|
|
185
|
+
'/config/routes',
|
|
186
|
+
b'[{"match": {"uri": "/.well-known/acme-challenge/*"}, "action": {"share": "/srv/www/unit/$uri"}}, {"action": {"share": "/srv/www/unit/index.html"}}]'
|
|
187
|
+
)
|
|
188
|
+
unitc_mock.put.assert_any_call(
|
|
189
|
+
'/config/listeners',
|
|
190
|
+
b'{"*:80": {"pass": "routes"}, "*:443": {"pass": "routes", "tls": {"certificate": ["domain_' + entropy.encode() + b'"]}}}',
|
|
191
|
+
put_success_message,
|
|
192
|
+
put_error_message
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
notify.stop()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
import subprocess
|
|
5
|
+
import logging
|
|
6
|
+
from certbot import errors
|
|
7
|
+
from certbot import util
|
|
8
|
+
from certbot.display import util as display_util
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Unitc(object):
|
|
14
|
+
def call(self, method: str, path: str, input_data: bytes | None = None,
|
|
15
|
+
success_message: str = "", error_message: str = "") -> str:
|
|
16
|
+
output = ""
|
|
17
|
+
with tempfile.TemporaryFile() as out:
|
|
18
|
+
try:
|
|
19
|
+
params = ["unitc", method, path]
|
|
20
|
+
logger.debug("Unic params: %s", " ".join(params))
|
|
21
|
+
proc = subprocess.run(params,
|
|
22
|
+
env=util.env_no_snap_for_external_calls(),
|
|
23
|
+
input=input_data,
|
|
24
|
+
stdout=out, stderr=out, check=False)
|
|
25
|
+
except (OSError, ValueError):
|
|
26
|
+
msg = "Unable to run the command: %s" + " ".join(params)
|
|
27
|
+
logger.error(msg)
|
|
28
|
+
raise errors.SubprocessError(msg)
|
|
29
|
+
|
|
30
|
+
out.seek(0)
|
|
31
|
+
output = out.read().decode("utf-8")
|
|
32
|
+
logger.debug("unic /certificate: %s", output)
|
|
33
|
+
# @todo from json check if error
|
|
34
|
+
if proc.returncode != 0 or '"error"' in output:
|
|
35
|
+
raise errors.Error(error_message)
|
|
36
|
+
else:
|
|
37
|
+
display_util.notify(success_message)
|
|
38
|
+
|
|
39
|
+
return output
|
|
40
|
+
|
|
41
|
+
def get(self, path: str, success_message: str = "", error_message: str = "") -> str:
|
|
42
|
+
return self.call("GET", path, None, success_message, error_message)
|
|
43
|
+
|
|
44
|
+
def put(self, path: str, input_data: bytes | None = None, success_message: str = "", error_message: str = ""):
|
|
45
|
+
self.call("PUT", path, input_data, success_message, error_message)
|
|
46
|
+
|
|
47
|
+
def delete(self, path: str, input_data: bytes | None = None, success_message: str = "", error_message: str = ""):
|
|
48
|
+
self.call("DELETE", path, input_data, success_message, error_message)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Manuel Baldassarri
|
|
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,180 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: certbot-nginx-unit
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Nginx Unit plugin for Certbot
|
|
5
|
+
Author-email: Manuel Baldassarri <m.baldassarri@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2024 Manuel Baldassarri
|
|
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
|
+
Project-URL: Homepage, https://github.com/kea/certbot-nginx-unit
|
|
28
|
+
Project-URL: Issues, https://github.com/kea/certbot-nginx-unit/issues
|
|
29
|
+
Classifier: Programming Language :: Python :: 3
|
|
30
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Intended Audience :: System Administrators
|
|
36
|
+
Classifier: Topic :: Security :: Cryptography
|
|
37
|
+
Classifier: Development Status :: 4 - Beta
|
|
38
|
+
Classifier: Environment :: Plugins
|
|
39
|
+
Classifier: Operating System :: OS Independent
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
License-File: LICENSE
|
|
42
|
+
Provides-Extra: test
|
|
43
|
+
Requires-Dist: pytest ; extra == 'test'
|
|
44
|
+
|
|
45
|
+
Certbot NGINX Unit plugin
|
|
46
|
+
=========================
|
|
47
|
+
|
|
48
|
+
This is a certbot plugin for using certbot in combination with NGINX Unit https://unit.nginx.org/
|
|
49
|
+
|
|
50
|
+
Requirement
|
|
51
|
+
===========
|
|
52
|
+
The command `unitc` should be installed and executable.
|
|
53
|
+
|
|
54
|
+
Current Features
|
|
55
|
+
=====================
|
|
56
|
+
|
|
57
|
+
* Supports NGINX Unit/1.31*
|
|
58
|
+
* Supports cerbot 1.21+
|
|
59
|
+
* install certificates
|
|
60
|
+
* automatic renewal certificates
|
|
61
|
+
|
|
62
|
+
Install
|
|
63
|
+
=======
|
|
64
|
+
|
|
65
|
+
You have to install the plugin and configure the unit listener for port 80
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
# unitc /config
|
|
69
|
+
```
|
|
70
|
+
```
|
|
71
|
+
{
|
|
72
|
+
"listeners": {
|
|
73
|
+
"*:80": {
|
|
74
|
+
"pass": "routes"
|
|
75
|
+
}
|
|
76
|
+
"routes": [
|
|
77
|
+
{
|
|
78
|
+
"action": {
|
|
79
|
+
"share": "/srv/www/unit/index.html"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Now, you can generate and automatically install the certificate with
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
# certbot --configurator nginx_unit -d www.myapp.com
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The result is a certificate created and installed.
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
# unitc /certificates
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
{
|
|
101
|
+
"www.myapp.com_20240202145800": {
|
|
102
|
+
"key": "RSA (2048 bits)",
|
|
103
|
+
"chain": [
|
|
104
|
+
{
|
|
105
|
+
<omissis>
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
and the configuration updated
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
# unitc /config
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
{
|
|
119
|
+
"listeners": {
|
|
120
|
+
"*:80": {
|
|
121
|
+
"pass": "routes"
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
"*:443": {
|
|
125
|
+
"pass": "routes",
|
|
126
|
+
"tls": {
|
|
127
|
+
"certificate": [
|
|
128
|
+
"www.myapp.com_20240202145800"
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
"routes": [
|
|
135
|
+
{
|
|
136
|
+
"match": {
|
|
137
|
+
"uri": "/.well-known/acme-challenge/*"
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
"action": {
|
|
141
|
+
"share": "/srv/www/unit/$uri"
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"action": {
|
|
146
|
+
"share": "/srv/www/unit/index.html"
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Auto-renew certificates
|
|
154
|
+
=======================
|
|
155
|
+
|
|
156
|
+
Certbot installs a timer on the system to renew certificates one month before the certificate expiration date.
|
|
157
|
+
|
|
158
|
+
Multiple domains/applications
|
|
159
|
+
=============================
|
|
160
|
+
|
|
161
|
+
You can run the certbot command for each domain
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
# certbot --configurator nginx_unit -d www.myapp1.com
|
|
165
|
+
# certbot --configurator nginx_unit -d www.myapp2.com
|
|
166
|
+
# unitc '/config/listeners/*:443'
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
{
|
|
171
|
+
"pass": "routes",
|
|
172
|
+
"tls": {
|
|
173
|
+
"certificate": [
|
|
174
|
+
"www.myapp1.com_20240202145800"
|
|
175
|
+
"www.myapp2.com_20240202145800"
|
|
176
|
+
]
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
certbot_nginx_unit/__init__.py,sha256=RgQE2Iv5Pb2y1Ob9pBDQ7AkfB2PDVhDmcynKRXZ3rVs,33
|
|
2
|
+
certbot_nginx_unit/configurator.py,sha256=y21SJEitifiMKAxLcZqw95DhP71oE0w1aNdP37hbkPc,15985
|
|
3
|
+
certbot_nginx_unit/unitc.py,sha256=X6ugHstfiuWA7RPpklmTWufp1MSiRnWsMABRNfzjvpk,1965
|
|
4
|
+
certbot_nginx_unit/tests/__init__.py,sha256=3nPrmfr4Kvv0Rn_LmkHveOeel2yLcQWHJwJCLzetMzQ,31
|
|
5
|
+
certbot_nginx_unit/tests/configurator_test.py,sha256=Qu3z7bE5gnxbdEvx0FcZ3zXOnIGfVSINagGLeuwgKN0,5967
|
|
6
|
+
certbot_nginx_unit-1.0.0.dist-info/LICENSE,sha256=YkZm3khKtc5NRDfdVIFK6L1v08VWgv3oyX0QmBFqwgo,1074
|
|
7
|
+
certbot_nginx_unit-1.0.0.dist-info/METADATA,sha256=8Jm-i8xxenBslVgHsnhkOyXcszEMgnlGYugYtscsuVs,4350
|
|
8
|
+
certbot_nginx_unit-1.0.0.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
|
9
|
+
certbot_nginx_unit-1.0.0.dist-info/entry_points.txt,sha256=O5eQe444A_eo4Bfk5jM3WMF2YhFjgZRpNG7Pst7jWNk,76
|
|
10
|
+
certbot_nginx_unit-1.0.0.dist-info/top_level.txt,sha256=8LDq2rRhZLXn2-IFroYXkRXYqYrqgkPkJghUUbDrnr8,19
|
|
11
|
+
certbot_nginx_unit-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
certbot_nginx_unit
|