Merge "Serve public keys through webapp" into feature/zuulv3
diff --git a/tests/fixtures/config/single-tenant/main.yaml b/tests/fixtures/config/single-tenant/main.yaml
index a22ed5c..d9868fa 100644
--- a/tests/fixtures/config/single-tenant/main.yaml
+++ b/tests/fixtures/config/single-tenant/main.yaml
@@ -4,3 +4,5 @@
gerrit:
config-repos:
- common-config
+ project-repos:
+ - org/project
diff --git a/tests/fixtures/public.pem b/tests/fixtures/public.pem
new file mode 100644
index 0000000..33a78c4
--- /dev/null
+++ b/tests/fixtures/public.pem
@@ -0,0 +1,14 @@
+-----BEGIN PUBLIC KEY-----
+MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAsGqZLUUwV/EZJKddMS20
+6mH7qYmqYhWLo/TUlpDt2JuEaBqCYV8mF9LsjpoqM/Pp0U/r5aQLDUXbRLDn+K+N
+qbvTJajYxHJicP1CAWg1eKUNZjUaya5HP4Ow1hS7AeiF4TSRdiwtHT/gJO2NSsav
+yc30/meKt0WBgbYlrBB81HEQjYWnajf/4so5E8DdrC9tAqmmzde1qcTz7ULouIz5
+3hjp/U3yVMFbpawv194jzHvddmAX3aEUByx2t6lP7dhOAEIEmzmh15hRbacxQI5a
+YWv+ZR0z9PqdwwD+DBbb1AwiX5MJjtIoVCmkEZvcUFiDicyteNMCa5ulpj2SF0oH
+4MlialOP6MiJnmxklDYO07AM/qomcU55pCD8ctu1yD/UydecLk0Uj/9XxqmPQJFE
+cstdXJZQfr5ZNnChOEg6oQ9UImWjav8HQsA6mFW1oAKbDMrgEewooWriqGW5pYtR
+7JBfph6Mt5HGaeH4uqYpb1fveHG1ODa7HBnlNo3qMendBb2wzHGCgtUgWnGfp24T
+sUOUlndCXYhsYbOZbCTW5GwElK0Gri06KPpybY43AIaxcxqilVh5Eapmq7axBm4Z
+zbTOfv15L0FIemEGgpnklevQbZNLIrcE0cS/13qJUvFaYX4yjrtEnzZ3ntjXrpFd
+gLPBKn7Aqf6lWz6BPi07axECAwEAAQ==
+-----END PUBLIC KEY-----
diff --git a/tests/unit/test_webapp.py b/tests/unit/test_webapp.py
index acff09a..8791a25 100644
--- a/tests/unit/test_webapp.py
+++ b/tests/unit/test_webapp.py
@@ -15,11 +15,12 @@
# License for the specific language governing permissions and limitations
# under the License.
+import os
import json
from six.moves import urllib
-from tests.base import ZuulTestCase
+from tests.base import ZuulTestCase, FIXTURE_DIR
class TestWebapp(ZuulTestCase):
@@ -85,3 +86,13 @@
self.assertEqual(1, len(data), data)
self.assertEqual("org/project1", data[0]['project'], data)
+
+ def test_webapp_keys(self):
+ with open(os.path.join(FIXTURE_DIR, 'public.pem')) as f:
+ public_pem = f.read()
+
+ req = urllib.request.Request(
+ "http://localhost:%s/tenant-one/keys/gerrit/org/project.pub" %
+ self.port)
+ f = urllib.request.urlopen(req)
+ self.assertEqual(f.read(), public_pem)
diff --git a/tools/encrypt_secret.py b/tools/encrypt_secret.py
new file mode 100644
index 0000000..4865edd
--- /dev/null
+++ b/tools/encrypt_secret.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python
+
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import argparse
+import os
+import subprocess
+import sys
+import tempfile
+from six.moves import urllib
+
+DESCRIPTION = """Encrypt a secret for Zuul.
+
+This program fetches a project-specific public key from a Zuul server and
+uses that to encrypt a secret. The only pre-requisite is an installed
+OpenSSL binary.
+"""
+
+
+def main():
+ parser = argparse.ArgumentParser(description=DESCRIPTION)
+ parser.add_argument('url',
+ help="The base URL of the zuul server and tenant. "
+ "E.g., https://zuul.example.com/tenant-name")
+ # TODO(jeblair,mordred): When projects have canonical names, use that here.
+ # TODO(jeblair): Throw a fit if SSL is not used.
+ parser.add_argument('source',
+ help="The Zuul source of the project.")
+ parser.add_argument('project',
+ help="The name of the project.")
+ parser.add_argument('--infile',
+ default=None,
+ help="A filename whose contents will be encrypted. "
+ "If not supplied, the value will be read from "
+ "standard input.")
+ parser.add_argument('--outfile',
+ default=None,
+ help="A filename to which the encrypted value will be "
+ "written. If not supplied, the value will be written "
+ "to standard output.")
+ args = parser.parse_args()
+
+ req = urllib.request.Request("%s/keys/%s/%s.pub" % (
+ args.url, args.source, args.project))
+ pubkey = urllib.request.urlopen(req)
+
+ if args.infile:
+ with open(args.infile) as f:
+ plaintext = f.read()
+ else:
+ plaintext = sys.stdin.read()
+
+ pubkey_file = tempfile.NamedTemporaryFile(delete=False)
+ try:
+ pubkey_file.write(pubkey.read())
+ pubkey_file.close()
+
+ p = subprocess.Popen(['openssl', 'rsautl', '-encrypt',
+ '-oaep', '-pubin', '-inkey',
+ pubkey_file.name],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE)
+ (stdout, stderr) = p.communicate(plaintext)
+ if p.returncode != 0:
+ raise Exception("Return code %s from openssl" % p.returncode)
+ ciphertext = stdout.encode('base64')
+ finally:
+ os.unlink(pubkey_file.name)
+
+ if args.outfile:
+ with open(args.outfile, "w") as f:
+ f.write(ciphertext)
+ else:
+ print(ciphertext)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/zuul/webapp.py b/zuul/webapp.py
index e16f0b4..3d8f991 100644
--- a/zuul/webapp.py
+++ b/zuul/webapp.py
@@ -22,6 +22,7 @@
from paste import httpserver
import webob
from webob import dec
+from cryptography.hazmat.primitives import serialization
"""Zuul main web app.
@@ -34,6 +35,7 @@
queue / pipeline structure of the system
- /status.json (backwards compatibility): same as /status
- /status/change/X,Y: return status just for gerrit change X,Y
+ - /keys/SOURCE/PROJECT.pub: return the public key for PROJECT
When returning status for a single gerrit change you will get an
array of changes, they will not include the queue structure.
@@ -96,9 +98,34 @@
return m.group(1)
return None
+ def _handle_keys(self, request, path):
+ m = re.match('/keys/(.*?)/(.*?).pub', path)
+ if not m:
+ raise webob.exc.HTTPNotFound()
+ source_name = m.group(1)
+ project_name = m.group(2)
+ source = self.scheduler.connections.getSource(source_name)
+ if not source:
+ raise webob.exc.HTTPNotFound()
+ project = source.getProject(project_name)
+ if not project:
+ raise webob.exc.HTTPNotFound()
+
+ # Serialize public key
+ pem_public_key = project.public_key.public_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PublicFormat.SubjectPublicKeyInfo
+ )
+
+ response = webob.Response(body=pem_public_key,
+ content_type='text/plain')
+ return response.conditional_response_app
+
def app(self, request):
tenant_name = request.path.split('/')[1]
path = request.path.replace('/' + tenant_name, '')
+ if path.startswith('/keys'):
+ return self._handle_keys(request, path)
path = self._normalize_path(path)
if path is None:
raise webob.exc.HTTPNotFound()