Merge lp:~dpb/charms/precise/apache2/avoid-regen-cert into lp:charms/apache2
- Precise Pangolin (12.04)
- avoid-regen-cert
- Merge into trunk
Status: | Merged | ||||||||
---|---|---|---|---|---|---|---|---|---|
Merged at revision: | 57 | ||||||||
Proposed branch: | lp:~dpb/charms/precise/apache2/avoid-regen-cert | ||||||||
Merge into: | lp:charms/apache2 | ||||||||
Diff against target: |
303 lines (+206/-16) 4 files modified
README.md (+2/-1) hooks/hooks.py (+71/-15) hooks/tests/test_balancer_hook.py (+9/-0) hooks/tests/test_cert.py (+124/-0) |
||||||||
To merge this branch: | bzr merge lp:~dpb/charms/precise/apache2/avoid-regen-cert | ||||||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Marco Ceppi (community) | Approve | ||
Jorge Niedbalski (community) | Needs Fixing | ||
Review via email: mp+221102@code.launchpad.net |
Commit message
Description of the change
Don't regen a self-signed cert unless we need to:
- pulled out cert generation code
- added test cases for it
- made regeneration dependent on necessity (hostname/ip changed, servername changed, cert is missing/invalid)
- Included small fix for lp:1302645 to install python-yaml
Tim Van Steenburgh (tvansteenburgh) wrote : | # |
David Britton (dpb) wrote : | # |
On Mon, Jun 16, 2014 at 04:02:42PM -0000, Tim Van Steenburgh wrote:
> Hi David,
>
> I'd like to review this submission but your branch doesn't merge
> cleanly with lp:charms/apache2. Would you mind pushing an update?
>
Hi Tim --
I resolved that conflict, it's ready for a re-review. (the merge
conflict was trivial, tests all still pass).
--
David Britton <email address hidden>
- 57. By David Britton
-
merge up
Tim Van Steenburgh (tvansteenburgh) wrote : | # |
Thanks David!
+1 LGTM. And you get +1000 bonus points for adding unit tests. :D
A member of ~charmers will be along after me for a follow-up review and to push this change to the charm store.
Jorge Niedbalski (niedbalski) wrote : | # |
Hello David,
Thanks for your submission. I understand your motivation, but i am unsure if we should force to keep the same certificate without having an configuration option to force the certificate re-generation.
My suggestions for your changes:
- Implement a configuration directive ( ssl_cert_regenerate ) to allow users to force the self-signed certificate generation. Which would be the first check to do.
- On the function is_selfsigned_
Please Let me know your observations.
David Britton (dpb) wrote : | # |
On Fri, Jun 20, 2014 at 4:25 PM, Jorge Niedbalski <
<email address hidden>> wrote:
> Review: Needs Fixing
>
> Hello David,
>
> Thanks for your submission. I understand your motivation, but i am unsure
> if we should force to keep the same certificate without having an
> configuration option to force the certificate re-generation.
>
>
Hi Jorge --
Thanks for looking this over...
> My suggestions for your changes:
>
> - Implement a configuration directive ( ssl_cert_regenerate ) to allow
> users to force the self-signed certificate generation. Which would be the
> first check to do.
>
>
I'm not sure that would be a good investment here. This would be covered
in the existing charm (and in my change), by simply doing:
juju set ssl_certlocatio
SELFSIGNED is already mostly a testing/debugging feature. Having another
way to regenerate it with a config key seems a bit too much? What do you
think? If you still feel strongly, I think it's probably best left for an
add-on feature if someone feels the need to implement it.
> - On the function is_selfsigned_
> private key has changed (available on config.
> will allow us to determine quicker is the certificate is stale.
>
In the case of SELFSIGNED, the key is written by the charm to the
ssl_keylocation. Are you suggesting that the location of the key would
change by the admin through juju set? If so, I do check that. That case
will cause the self-signed cert to be regenerated.
Could you please explain further if I have misunderstood you?
>
>
> Diff comments:
>
> > +def is_selfsigned_
> > + """
> > + Do we need to generate a new self-signed cert?
> > +
> > + @param config: charm data from config-get
> > + @param cert_file: destination path of generated certificate
> > + @param key_file: destination path of generated private key
> > + """
> > + # Basic Existence Checks
> > + if not os.path.
> > + return True
> > + if not os.path.
> > + return True
> > +
> > + # Common Name
> > + from OpenSSL import crypto
> > + cert = crypto.
>
> If for whatever reason the load_certificate function fails, We should mark
> the certificate as stale, right? I think so.
>
>
If it failed -- threw an exception? returned None? -- it will actually
cause this method to error, and the hook, in turn, to error. There are a
couple of reasons this could fail, the ones I can think of seem appropriate
to allow the hook to error, instead of masking the problem, removing the
file (who knows what was there?), and generating another cert over the top
of it.
Presumably we generated the cert that we just failed to read, so I don't
think it would be helpful to continue generating certs like that.
Are you thinking of a particular use case or failure mode here? It would
help if I had something to test. I admit I may not be thinking of all the
options.
--
David Britton <email address hidden>
Jorge Niedbalski (niedbalski) wrote : | # |
Thanks for your quick reply.
> I'm not sure that would be a good investment here. This would be covered
> in the existing charm (and in my change), by simply doing:
>
> juju set ssl_certlocatio
>
> SELFSIGNED is already mostly a testing/debugging feature. Having another
> way to regenerate it with a config key seems a bit too much? What do you
> think? If you still feel strongly, I think it's probably best left for an
> add-on feature if someone feels the need to implement it.
>
Charm's features must be made for being used in production, never for debugging/testing purposes, so i assumed by default that everything should be configurable and reversible.
Looking at the ssl_certlocation will cover the suggested case and having
something more specific would be a 'nice to have' feature. But for now, is OK to me to
use the `certlocation` directive.
>
> > - On the function is_selfsigned_
> > private key has changed (available on config.
> > will allow us to determine quicker is the certificate is stale.
> >
>
> In the case of SELFSIGNED, the key is written by the charm to the
> ssl_keylocation. Are you suggesting that the location of the key would
> change by the admin through juju set? If so, I do check that. That case
> will cause the self-signed cert to be regenerated.
Yes, that variable can be changed via `juju set`, so we must cover that
case on your re-generation constraints.
> If it failed -- threw an exception? returned None? -- it will actually
> cause this method to error, and the hook, in turn, to error. There are a
> couple of reasons this could fail, the ones I can think of seem appropriate
> to allow the hook to error, instead of masking the problem, removing the
> file (who knows what was there?), and generating another cert over the top
> of it.
I am certainly not aware of all the possible failures here, just making sure
that you know the potential errors that should be caught here. If you
think is OK to error the hook and not will not be necessary to flag the certificate as stale, i am OK with that.
David Britton (dpb) wrote : | # |
On Mon, Jun 23, 2014 at 02:56:58PM -0000, Jorge Niedbalski wrote:
>
> Yes, that variable can be changed via `juju set`, so we must cover that
> case on your re-generation constraints.
I think we are good then:
if not os.path.
return True
That should catch the case you are talking about.
>
> I am certainly not aware of all the possible failures here, just making sure
> that you know the potential errors that should be caught here. If you
> think is OK to error the hook and not will not be necessary to flag the certificate as stale, i am OK with that.
Great -- Yes, I'm fine with that. These class of errors work best
making the charm error, IMO. I don't know why they would happen, and
it's dangerous to make assumptions about that.
Thanks!
--
David Britton <email address hidden>
Preview Diff
1 | === modified file 'README.md' |
2 | --- README.md 2014-05-21 18:31:11 +0000 |
3 | +++ README.md 2014-06-18 19:05:46 +0000 |
4 | @@ -17,7 +17,8 @@ |
5 | sudo apt-get install python-software-properties |
6 | sudo add-apt-repository ppa:chrisjohnston/flake8 |
7 | sudo apt-get update |
8 | - sudo apt-get install flake8 python-nose python-coverage python-testtools |
9 | + sudo apt-get install python-flake8 python-nose python-coverage \ |
10 | + python-testtools python-pyasn1 python-pyasn1-modules |
11 | |
12 | To fetch additional source dependencies and run the tests: |
13 | |
14 | |
15 | === modified file 'hooks/hooks.py' |
16 | --- hooks/hooks.py 2014-06-11 17:57:46 +0000 |
17 | +++ hooks/hooks.py 2014-06-18 19:05:46 +0000 |
18 | @@ -50,14 +50,12 @@ |
19 | # Supporting functions |
20 | ############################################################################### |
21 | |
22 | -#------------------------------------------------------------------------------ |
23 | -# apt_get_install( package ): Installs a package |
24 | -#------------------------------------------------------------------------------ |
25 | -def apt_get_install(packages=None): |
26 | - if packages is None: |
27 | +def apt_get_install(package=None): |
28 | + """Install a package.""" |
29 | + if package is None: |
30 | return False |
31 | cmd_line = ['apt-get', '-y', 'install', '-qq'] |
32 | - cmd_line.append(packages) |
33 | + cmd_line.append(package) |
34 | return subprocess.call(cmd_line) |
35 | |
36 | |
37 | @@ -181,6 +179,68 @@ |
38 | return True |
39 | |
40 | |
41 | +def gen_selfsigned_cert(config, cert_file, key_file): |
42 | + """ |
43 | + Create a self-signed certificate. |
44 | + |
45 | + @param config: charm data from config-get |
46 | + @param cert_file: destination path of generated certificate |
47 | + @param key_file: destination path of generated private key |
48 | + """ |
49 | + os.environ['OPENSSL_CN'] = config['servername'] |
50 | + os.environ['OPENSSL_PUBLIC'] = unit_get("public-address") |
51 | + os.environ['OPENSSL_PRIVATE'] = unit_get("private-address") |
52 | + run( |
53 | + ['openssl', 'req', '-new', '-x509', '-nodes', '-config', |
54 | + os.path.join(os.environ['CHARM_DIR'], 'data', 'openssl.cnf'), |
55 | + '-keyout', key_file, '-out', cert_file]) |
56 | + |
57 | + |
58 | +def is_selfsigned_cert_stale(config, cert_file, key_file): |
59 | + """ |
60 | + Do we need to generate a new self-signed cert? |
61 | + |
62 | + @param config: charm data from config-get |
63 | + @param cert_file: destination path of generated certificate |
64 | + @param key_file: destination path of generated private key |
65 | + """ |
66 | + # Basic Existence Checks |
67 | + if not os.path.exists(cert_file): |
68 | + return True |
69 | + if not os.path.exists(key_file): |
70 | + return True |
71 | + |
72 | + # Common Name |
73 | + from OpenSSL import crypto |
74 | + cert = crypto.load_certificate( |
75 | + crypto.FILETYPE_PEM, file(cert_file).read()) |
76 | + cn = cert.get_subject().commonName |
77 | + if config['servername'] != cn: |
78 | + return True |
79 | + |
80 | + # Subject Alternate Name |
81 | + from pyasn1.codec.der import decoder |
82 | + from pyasn1_modules import rfc2459 |
83 | + cert_addresses = set() |
84 | + unit_addresses = set( |
85 | + [unit_get("public-address"), unit_get("private-address")]) |
86 | + for i in range(0, cert.get_extension_count()): |
87 | + extension = cert.get_extension(i) |
88 | + try: |
89 | + names = decoder.decode( |
90 | + extension.get_data(), asn1Spec=rfc2459.SubjectAltName())[0] |
91 | + for name in names: |
92 | + cert_addresses.add(str(name.getComponent())) |
93 | + except: |
94 | + pass |
95 | + if cert_addresses != unit_addresses: |
96 | + log("subjAltName: Cert (%s) != Unit (%s), assuming stale" % ( |
97 | + cert_addresses, unit_addresses)) |
98 | + return True |
99 | + |
100 | + return False |
101 | + |
102 | + |
103 | def _get_key_file_location(config_data): |
104 | """Look at the config, generate the key file location.""" |
105 | key_file = None |
106 | @@ -208,9 +268,6 @@ |
107 | return chain_file |
108 | |
109 | |
110 | -############################################################################### |
111 | -# Hook functions |
112 | -############################################################################### |
113 | def config_get(scope=None): |
114 | """ |
115 | Wrapper around charm helper's config_get to replace an empty servername |
116 | @@ -229,6 +286,9 @@ |
117 | os.mkdir(default_apache2_service_config_dir, 0600) |
118 | apt_update(fatal=True) |
119 | apt_get_install("python-jinja2") |
120 | + apt_get_install("python-pyasn1") |
121 | + apt_get_install("python-pyasn1-modules") |
122 | + apt_get_install("python-yaml") |
123 | install_status = apt_get_install("apache2") |
124 | if install_status == 0: |
125 | ensure_package_status(service_affecting_packages, |
126 | @@ -519,12 +579,8 @@ |
127 | if cert_file is not None and key_file is not None: |
128 | # ssl_cert is SELFSIGNED so generate self-signed certificate for use. |
129 | if config_data['ssl_cert'] and config_data['ssl_cert'] == "SELFSIGNED": |
130 | - os.environ['OPENSSL_CN'] = config_data['servername'] |
131 | - os.environ['OPENSSL_PUBLIC'] = unit_get("public-address") |
132 | - os.environ['OPENSSL_PRIVATE'] = unit_get("private-address") |
133 | - run(['openssl', 'req', '-new', '-x509', '-nodes', '-config', |
134 | - os.path.join(os.environ['CHARM_DIR'], 'data', 'openssl.cnf'), |
135 | - '-keyout', key_file, '-out', cert_file]) |
136 | + if is_selfsigned_cert_stale(config_data, cert_file, key_file): |
137 | + gen_selfsigned_cert(config_data, cert_file, key_file) |
138 | |
139 | # Use SSL certificate and key provided either as a base64 string or |
140 | # shipped out with the charm. |
141 | |
142 | === modified file 'hooks/tests/test_balancer_hook.py' |
143 | --- hooks/tests/test_balancer_hook.py 2014-06-11 17:18:57 +0000 |
144 | +++ hooks/tests/test_balancer_hook.py 2014-06-18 19:05:46 +0000 |
145 | @@ -517,6 +517,9 @@ |
146 | 0600) |
147 | apt_get_install.assert_has_calls([ |
148 | call('python-jinja2'), |
149 | + call('python-pyasn1'), |
150 | + call('python-pyasn1-modules'), |
151 | + call('python-yaml'), |
152 | call('apache2'), |
153 | ]) |
154 | |
155 | @@ -537,6 +540,9 @@ |
156 | self.assertEqual(result, 'some result') |
157 | apt_get_install.assert_has_calls([ |
158 | call('python-jinja2'), |
159 | + call('python-pyasn1'), |
160 | + call('python-pyasn1-modules'), |
161 | + call('python-yaml'), |
162 | call('apache2'), |
163 | call('extra'), |
164 | ]) |
165 | @@ -560,6 +566,9 @@ |
166 | self.assertFalse(mkdir.called) |
167 | apt_get_install.assert_has_calls([ |
168 | call('python-jinja2'), |
169 | + call('python-pyasn1'), |
170 | + call('python-pyasn1-modules'), |
171 | + call('python-yaml'), |
172 | call('apache2'), |
173 | ]) |
174 | |
175 | |
176 | === added file 'hooks/tests/test_cert.py' |
177 | --- hooks/tests/test_cert.py 1970-01-01 00:00:00 +0000 |
178 | +++ hooks/tests/test_cert.py 2014-06-18 19:05:46 +0000 |
179 | @@ -0,0 +1,124 @@ |
180 | +import os |
181 | +import shutil |
182 | +import tempfile |
183 | + |
184 | +from testtools import TestCase |
185 | +from mock import patch |
186 | + |
187 | +import hooks |
188 | + |
189 | + |
190 | +original_open = open |
191 | + |
192 | + |
193 | +def _unit_get(value): |
194 | + return value |
195 | + |
196 | + |
197 | +def _unit_get_public_address_changed(value): |
198 | + if value == "public-address": |
199 | + return "changed" |
200 | + return "foo" |
201 | + |
202 | + |
203 | +def _unit_get_private_address_changed(value): |
204 | + if value == "private-address": |
205 | + return "changed" |
206 | + return "foo" |
207 | + |
208 | + |
209 | +class CertTests(TestCase): |
210 | + def setUp(self): |
211 | + super(CertTests, self).setUp() |
212 | + self.tempdir = tempfile.mkdtemp() |
213 | + |
214 | + def tearDown(self): |
215 | + super(CertTests, self).tearDown() |
216 | + shutil.rmtree(self.tempdir, ignore_errors=True) |
217 | + |
218 | + def test_is_selfsigned_cert_stale_missing_key(self): |
219 | + """Missing cert, we should regenerate.""" |
220 | + config = {"servername": "servername"} |
221 | + cert_file = os.path.join(self.tempdir, "cert") |
222 | + key_file = os.path.join(self.tempdir, "key") |
223 | + with open(cert_file, "w") as f: |
224 | + f.write("foo") |
225 | + self.assertTrue( |
226 | + hooks.is_selfsigned_cert_stale(config, cert_file, key_file)) |
227 | + |
228 | + def test_is_selfsigned_cert_stale_missing_cert(self): |
229 | + """Missing cert, we should regenerate.""" |
230 | + config = {"servername": "servername"} |
231 | + cert_file = os.path.join(self.tempdir, "cert") |
232 | + key_file = os.path.join(self.tempdir, "key") |
233 | + with open(key_file, "w") as f: |
234 | + f.write("foo") |
235 | + self.assertTrue( |
236 | + hooks.is_selfsigned_cert_stale(config, cert_file, key_file)) |
237 | + |
238 | + @patch("hooks.log") |
239 | + @patch("hooks.unit_get", return_value="unit") |
240 | + def test_is_selfsigned_cert_stale_cn_changed(self, unit_get, log): |
241 | + """With different servername, cert *should* be regenerated.""" |
242 | + config = {"servername": "servername"} |
243 | + cert_file = os.path.join(self.tempdir, "cert") |
244 | + key_file = os.path.join(self.tempdir, "key") |
245 | + hooks.gen_selfsigned_cert(config, cert_file, key_file) |
246 | + config["servername"] = "changed" |
247 | + self.assertTrue( |
248 | + hooks.is_selfsigned_cert_stale(config, cert_file, key_file)) |
249 | + |
250 | + @patch("hooks.log") |
251 | + @patch("hooks.unit_get", side_effect=_unit_get) |
252 | + def test_is_selfsigned_cert_stale_public_address_changed( |
253 | + self, unit_get, log): |
254 | + """With different servername, cert *should* be regenerated.""" |
255 | + config = {"servername": "servername"} |
256 | + cert_file = os.path.join(self.tempdir, "cert") |
257 | + key_file = os.path.join(self.tempdir, "key") |
258 | + hooks.gen_selfsigned_cert(config, cert_file, key_file) |
259 | + unit_get.side_effect = _unit_get_public_address_changed |
260 | + self.assertTrue( |
261 | + hooks.is_selfsigned_cert_stale(config, cert_file, key_file)) |
262 | + |
263 | + @patch("hooks.log") |
264 | + @patch("hooks.unit_get", side_effect=_unit_get) |
265 | + def test_is_selfsigned_cert_stale_private_address_changed( |
266 | + self, unit_get, log): |
267 | + """With different servername, cert *should* be regenerated.""" |
268 | + config = {"servername": "servername"} |
269 | + cert_file = os.path.join(self.tempdir, "cert") |
270 | + key_file = os.path.join(self.tempdir, "key") |
271 | + hooks.gen_selfsigned_cert(config, cert_file, key_file) |
272 | + unit_get.side_effect = _unit_get_private_address_changed |
273 | + self.assertTrue( |
274 | + hooks.is_selfsigned_cert_stale(config, cert_file, key_file)) |
275 | + |
276 | + @patch("hooks.log") |
277 | + @patch("hooks.unit_get", return_value="unit") |
278 | + def test_is_selfsigned_cert_stale_assert_false(self, unit_get, log): |
279 | + """Happy path, cert exists, doesn't need to be regenerated.""" |
280 | + config = {"servername": "servername"} |
281 | + cert_file = os.path.join(self.tempdir, "cert") |
282 | + key_file = os.path.join(self.tempdir, "key") |
283 | + hooks.gen_selfsigned_cert(config, cert_file, key_file) |
284 | + self.assertFalse( |
285 | + hooks.is_selfsigned_cert_stale(config, cert_file, key_file)) |
286 | + |
287 | + @patch("hooks.log") |
288 | + @patch("hooks.unit_get", return_value="unit") |
289 | + def test_gen_selfsigned_cert(self, unit_get, log): |
290 | + """ |
291 | + Happy path, make sure we can generate a cert, invalidate, |
292 | + and write another. |
293 | + """ |
294 | + config = {"servername": "servername"} |
295 | + cert_file = os.path.join(self.tempdir, "cert") |
296 | + key_file = os.path.join(self.tempdir, "key") |
297 | + hooks.gen_selfsigned_cert(config, cert_file, key_file) |
298 | + config["servername"] = "changed" |
299 | + self.assertTrue( |
300 | + hooks.is_selfsigned_cert_stale(config, cert_file, key_file)) |
301 | + hooks.gen_selfsigned_cert(config, cert_file, key_file) |
302 | + self.assertFalse( |
303 | + hooks.is_selfsigned_cert_stale(config, cert_file, key_file)) |
Hi David,
I'd like to review this submission but your branch doesn't merge cleanly with lp:charms/apache2. Would you mind pushing an update?
Thanks!
Tim