Merge lp:~dpb/charms/precise/apache2/avoid-regen-cert into lp:charms/apache2

Proposed by David Britton
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
Reviewer Review Type Date Requested Status
Marco Ceppi (community) Approve
Jorge Niedbalski (community) Needs Fixing
Review via email: mp+221102@code.launchpad.net

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

To post a comment you must log in.
Revision history for this message
Tim Van Steenburgh (tvansteenburgh) 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?

Thanks!
Tim

Revision history for this message
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

Revision history for this message
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.

Revision history for this message
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_cert_stale() Please check first if the private key has changed (available on config.get('ssl_keylocation') that will allow us to determine quicker is the certificate is stale.

Please Let me know your observations.

review: Needs Fixing
Revision history for this message
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_certlocation="a_new_cert.cert"

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_cert_stale() Please check first if the
> private key has changed (available on config.get('ssl_keylocation') that
> 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_cert_stale(config, cert_file, key_file):
> > + """
> > + 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.exists(cert_file):
> > + return True
> > + if not os.path.exists(key_file):
> > + return True
> > +
> > + # Common Name
> > + from OpenSSL import crypto
> > + cert = crypto.load_certificate(
>
> 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>

Revision history for this message
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_certlocation="a_new_cert.cert"
>
> 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_cert_stale() Please check first if the
> > private key has changed (available on config.get('ssl_keylocation') that
> > 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.

Revision history for this message
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.exists(cert_file):
        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>

Revision history for this message
Marco Ceppi (marcoceppi) wrote :

LGTM

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
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))

Subscribers

People subscribed via source and target branches

to all changes: