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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.42.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [certbot.plugins]
2
+ nginx_unit = certbot_nginx_unit.configurator:Configurator
@@ -0,0 +1 @@
1
+ certbot_nginx_unit