Add commands to export/import keys to/from ZK

This removes the filesystem-based keystore in favor of only using
ZooKeeper.  Zuul will no longer load missing keys from the filesystem,
nor will it write out decrypted copies of all keys to the filesystem.

This is more secure since it allows sites better control over when and
where secret data are written to disk.

To provide for system backups to aid in disaster recovery in the case
that the ZK data store is lost, two new scheduler commands are added:

* export-keys
* import-keys

These write the password-protected versions of the keys (in fact, a
raw dump of the ZK data) to the filesystem, and read the same data
back in.  An administrator can invoke export-keys before performing a
system backup, and run import-keys to restore the data.

A minor doc change recommending the use of ``zuul-scheduler stop`` was
added as well, since that section is being updated.

Change-Id: I5e6ea37c94ab73ec6f850591871c4127118414ed
This commit is contained in:
James E. Blair 2021-07-14 16:36:58 -07:00
parent 212d99bc7f
commit 0bf6e14720
11 changed files with 230 additions and 466 deletions

View File

@ -404,8 +404,18 @@ The following sections of ``zuul.conf`` are used by the scheduler:
Operation
~~~~~~~~~
To start the scheduler, run ``zuul-scheduler``. To stop it, kill the
PID which was saved in the pidfile specified in the configuration.
To start the scheduler, run ``zuul-scheduler``. To stop it, run
``zuul-scheduler stop``.
Zuul stores private keys for each project it knows about in ZooKeeper.
It is recommended that you periodically back up the private keys in
case the ZooKeeper data store is lost or damaged. The scheduler
provides two sub-commands for use in this case: ``zuul-scheduler
export-keys`` and ``zuul-scheduler import-keys``. Each takes an
argument to a filesystem path and will write the keys to, or read the
keys from that path. The data in the exported files is still secured
with the keystore passphrase, so be sure to retain it as well.
Reconfiguration
~~~~~~~~~~~~~~~

View File

@ -0,0 +1,13 @@
---
upgrade:
- |
Zuul no longer reads or writes project private key files from the
scheduler's filesystem. In order to load existing keys into
ZooKeeper, run version 4.6.0 of the scheduler at least once, if
you haven't already.
A new command ``zuul-scheduler export-keys`` has been added to
export the encrypted keys from ZooKeeper onto the filesystem for
backup. Likewise, ``zuul-scheduler import-keys`` will load a
previously-exported backup into ZooKeeper. It is recommended that
you use these commands in system backup scripts.

View File

@ -109,6 +109,7 @@ import zuul.executor.client
import zuul.lib.ansible
import zuul.lib.connections
import zuul.lib.auth
import zuul.lib.keystorage
import zuul.merger.client
import zuul.merger.merger
import zuul.merger.server
@ -4134,6 +4135,17 @@ class BaseTestCase(testtools.TestCase):
ChrootedKazooFixture(self.id())
)
def getZKTree(self, path, ret=None):
"""Return the contents of a ZK tree as a dictionary"""
if ret is None:
ret = {}
for key in self.zk_client.client.get_children(path):
subpath = os.path.join(path, key)
ret[subpath] = self.zk_client.client.get(
os.path.join(path, key))[0]
self.getZKTree(subpath, ret)
return ret
class SymLink(object):
def __init__(self, target):
@ -4424,8 +4436,7 @@ class ZuulTestCase(BaseTestCase):
'scheduler', 'command_socket',
os.path.join(self.test_root, 'scheduler.socket'))
if not self.config.has_option("keystore", "password"):
self.config.set("keystore", "password",
uuid.uuid4().hex)
self.config.set("keystore", "password", 'keystorepassword')
self.config.set('merger', 'git_dir', self.merger_src_root)
self.config.set('executor', 'git_dir', self.executor_src_root)
self.config.set('executor', 'private_key_file', self.private_key_file)
@ -4494,6 +4505,9 @@ class ZuulTestCase(BaseTestCase):
self.connection_event_queues = DefaultKeyDict(
lambda cn: ConnectionEventQueue(self.zk_client, cn)
)
# requires zk client
self.setupAllProjectKeys(self.config)
self.poller_events = {}
self._configureSmtp()
self._configureMqtt()
@ -4641,7 +4655,6 @@ class ZuulTestCase(BaseTestCase):
os.path.join(git_path, reponame))
# Make test_root persist after ansible run for .flag test
config.set('executor', 'trusted_rw_paths', self.test_root)
self.setupAllProjectKeys(config)
return config
@ -4727,38 +4740,27 @@ class ZuulTestCase(BaseTestCase):
def setupProjectKeys(self, source, project):
# Make sure we set up an RSA key for the project so that we
# don't spend time generating one:
if isinstance(project, dict):
project = list(project.keys())[0]
key_root = os.path.join(self.state_root, 'keys')
if not os.path.isdir(key_root):
os.mkdir(key_root, 0o700)
fn = os.path.join(key_root, '.version')
with open(fn, 'w') as f:
f.write('1')
# secrets key
private_key_file = os.path.join(
key_root, 'secrets', 'project', source, project, '0.pem')
private_key_dir = os.path.dirname(private_key_file)
self.log.debug("Installing test secrets keys for project %s at %s" % (
project, private_key_file))
if not os.path.isdir(private_key_dir):
os.makedirs(private_key_dir)
with open(os.path.join(FIXTURE_DIR, 'private.pem')) as i:
with open(private_key_file, 'w') as o:
o.write(i.read())
password = self.config.get("keystore", "password")
keystore = zuul.lib.keystorage.KeyStorage(
self.zk_client, password=password)
import_list = []
path = keystore.getProjectSecretsKeysPath(source, project)
# Strip /keystore, just like the export routine does
path = path.split('/', 2)[2]
with open(os.path.join(FIXTURE_DIR, 'secrets.json'), 'rb') as i:
import_list.append((path, i.read()))
# ssh key
private_key_file = os.path.join(
key_root, 'ssh', 'project', source, project, '0.pem')
private_key_dir = os.path.dirname(private_key_file)
self.log.debug("Installing test ssh keys for project %s at %s" % (
project, private_key_file))
if not os.path.isdir(private_key_dir):
os.makedirs(private_key_dir)
with open(os.path.join(FIXTURE_DIR, 'ssh.pem')) as i:
with open(private_key_file, 'w') as o:
o.write(i.read())
path = keystore.getSSHKeysPath(source, project)
path = path.split('/', 2)[2]
with open(os.path.join(FIXTURE_DIR, 'ssh.json'), 'rb') as i:
import_list.append((path, i.read()))
keystore.importKeys(import_list)
def getCurrentLtime(self):
"""Get the logical timestamp as seen by the Zookeeper cluster."""

1
tests/fixtures/secrets.json vendored Normal file
View File

@ -0,0 +1 @@
{"schema": 1, "keys": [{"version": 0, "created": 1626909706, "private_key": "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-256-CBC,8448A7DF16A5CF040EA107EB6B0A5235\n\n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\n-----END RSA PRIVATE KEY-----\n"}]}

1
tests/fixtures/ssh.json vendored Normal file
View File

@ -0,0 +1 @@
{"schema": 1, "keys": [{"version": 0, "created": 1626909706, "private_key": "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\nDEK-Info: AES-256-CBC,7FF563CF60708DF4F2999DF100EC742D\n\nqecR3BS3nKFoKuexputN2ubp1nfHwFWw6VvD4wb3uBXxgzUeaSP9qK7qEUJU6bU9\nPvTJTPLrnNTD0zwsVyw2uJEv3PTFZ7tCVEocUNP9LoGrta8xXZ3QGDzo3rJ6GPCK\nR4UsNZa4KStnqAtcxw5nhhGXPZT0FDBsT9dgSi6JeYZZIifojWZG+jaoSXEaRe+O\nF34ys73gsmWo9tOvW8mN+YuFTKc8TlCviIa+NnqDHotF7tNp8SxIPJqBE4lygiEo\nzuHKJOAlohvLqVQsKk1383w5Xj8nKO33EhsAidqy4VBwnu/e4pbMIgdBkCFqHiVb\nTIhb452LMpaZr/fVge8qOXr/oULLXkGppVVxza0M+9fnYIwjEDryrFy0JTk88JIg\n3tw9CSn6DEN8yDXtkZZnOvcbJKrlOZll3e1A7jJpVAALM/NdVbGqVq2q9bWCf+Ql\nCHY0g9DAd//0/wgl2IRQ1jfOPMIJATsgrj7h6g4vj/Fsv9vKO9LPO9wILAlHHEc9\nYd0rBDT7nLTgdRNghf+auRBvi8fZxfbGXuv86Ei1A9iS/bf6+SlVt8V8tHd9/mnx\nq7PHlwUXg3IPONn0U+o6/uIJKW+t55PdmhXUhPc1TbvVH3QbiywzzR+q+lw5QhgO\nqJqKiJhEsfSuAk5B2/xBQxQpuFGs+8MH/AaapUu8rD+skZphWnxZZLmVYHCG111o\ny36RmhSBXkWy8wwpM9VXyDAJ9WhjE0VpFX5/ZsST+UwZIeGmL6NU4NQ9jYS2KAGj\nfDRlU3O4iA92kCYyj/tNeetkj+pDO6otYdUSwbblE8jyCqulV2IYCabDe+cfauym\nTMaBdFNRw9zB0poqpNqbHflGihuoN7Gb37ST7ILNsqPLqIP52oRS/JHHeVuaiQsJ\naUM0L+rOOxRImkEukp0IS7I8X5m+KJ8tRe5s1i3ojBykGpCtodxLGcxEebM9ncC+\n3U2m4nsyBMeiqVd2DJgwi7Qep4YTUXwzSxzGxLNTXQfokN598pi9RTu0AcTf0/Rs\n4a7sOavbuRJMEAzvoDDHOAfm39qVP4HwJ0142Cg9Y0P6VUgJSGXSagsRwPIFSOpJ\nnanC8sANMG4zSK4Aujyf4S/roK4uK3p5nkmF+sn/bD7XD4ACYBwBos+cQxOBRkiZ\nOxEevbP2NWlkMFU8P8HhVJQyv8YvV+K2tWYE3TS/v/DUhE22sObepzwkE/69sZWb\nBTEMsZEIaC1nH0JDLJ7PQqRgcRWu8gzsTUQZtd9VPypenE1XmJp/9jPqAAa5Fa3v\nZPnUPDnykUBaiLKINb4daMcf4MMJ4F1cHB8hbGKkDqNoUIa0U8MT/t8aeSWquSyk\nCKHbIqDHJESn/04YPFrSEx/36bP77DiiA7SKI/YjqSF7h+QXGwAiO7P2FBD05YST\nkQtDwr2vPH2w2PK2aqfacedZREYROWh4XzCYuLjFd7dNQ8QhKkxtcX2RxR6EJHAf\nYgaCyz2Il9jkR+7e3uVNDwv5aUIPqa/+PiNQzhNR9nrP513cxuWbdZlIemft11MO\nwY53ubT/oiRK1K+5/ZNZUt5REVpLr1Kls8nrOACL2RlfFrtizmRslIOCGIDIIg9M\n-----END RSA PRIVATE KEY-----\n"}]}

View File

@ -12,10 +12,6 @@
# License for the specific language governing permissions and limitations
# under the License.
import os
import fixtures
from unittest.mock import patch
from zuul.lib import encryption
from zuul.lib import keystorage
from zuul.zk import ZooKeeperClient
@ -23,55 +19,7 @@ from zuul.zk import ZooKeeperClient
from tests.base import BaseTestCase
class TestFileKeyStorage(BaseTestCase):
def _setup_keys(self, root, connection_name, project_name):
cn = os.path.join(root, connection_name)
if '/' in project_name:
pn = os.path.join(cn, os.path.dirname(project_name))
os.makedirs(pn)
fn = os.path.join(cn, project_name + '.pem')
with open(fn, 'w'):
pass
def assertFile(self, root, path, contents=None):
fn = os.path.join(root, path)
self.assertTrue(os.path.exists(fn))
if contents:
with open(fn) as f:
self.assertEqual(contents, f.read())
def assertPaths(self, root, paths):
seen = set()
for dirpath, dirnames, filenames in os.walk(root):
for d in dirnames:
seen.add(os.path.join(dirpath[len(root) + 1:], d))
for f in filenames:
seen.add(os.path.join(dirpath[len(root) + 1:], f))
self.assertEqual(set(paths), seen)
def test_key_storage(self):
root = self.useFixture(fixtures.TempDir()).path
self._setup_keys(root, 'gerrit', 'org/example')
keystorage.FileKeyStorage(root)
self.assertFile(root, '.version', '1')
self.assertPaths(root, [
'.version',
'secrets',
'secrets/project',
'secrets/project/gerrit',
'secrets/project/gerrit/org',
'secrets/project/gerrit/org/example',
'secrets/project/gerrit/org/example/0.pem',
'ssh',
'ssh/project',
'ssh/tenant',
])
# It shouldn't need to upgrade this time
keystorage.FileKeyStorage(root)
class TestZooKeeperKeyStorage(BaseTestCase):
class TestKeyStorage(BaseTestCase):
def setUp(self):
super().setUp()
@ -85,90 +33,8 @@ class TestZooKeeperKeyStorage(BaseTestCase):
self.addCleanup(self.zk_client.disconnect)
self.zk_client.connect()
def test_backup(self):
root = self.useFixture(fixtures.TempDir()).path
backup = keystorage.FileKeyStorage(root)
key_store = keystorage.ZooKeeperKeyStorage(
self.zk_client, password="DEADBEEF", backup=backup)
# Create keys in the backup keystore
backup_secrets_pk = encryption.serialize_rsa_private_key(
backup.getProjectSecretsKeys("github", "org/project")[0])
backup_ssh_keys = backup.getProjectSSHKeys("github", "org/project")
self.assertEqual(
encryption.serialize_rsa_private_key(
key_store.getProjectSecretsKeys("github", "org/project")[0]
), backup_secrets_pk)
self.assertEqual(
key_store.getProjectSSHKeys("github", "org/project"),
backup_ssh_keys)
# Keys should initially not be in the backup keystore
self.assertFalse(
backup.hasProjectSecretsKeys("github", "org/project1"))
self.assertFalse(
backup.hasProjectSSHKeys("github", "org/project1"))
self.assertIsNotNone(
key_store.getProjectSecretsKeys("github", "org/project1"))
self.assertIsNotNone(
key_store.getProjectSSHKeys("github", "org/project1"))
# Keys should now also exist in the backup keystore
self.assertTrue(
backup.hasProjectSecretsKeys("github", "org/project1"))
self.assertTrue(
backup.hasProjectSSHKeys("github", "org/project1"))
def test_key_store_upgrade(self):
# Test that moving an unencrypted key on the file system to ZK
# (encrypted) works as expected and the backup keys stay the same.
root = self.useFixture(fixtures.TempDir()).path
backup = keystorage.FileKeyStorage(root)
key_store = keystorage.ZooKeeperKeyStorage(
self.zk_client, password="DECAFBAD", backup=backup)
# Create reference keys in the backup key store
ref_secrets_pk = encryption.serialize_rsa_private_key(
backup.getProjectSecretsKeys("github", "org/project")[0])
ref_ssh_keys = backup.getProjectSSHKeys("github", "org/project")
# Make sure we get the backup keys via the primary key store.
self.assertEqual(
encryption.serialize_rsa_private_key(
key_store.getProjectSecretsKeys("github", "org/project")[0]
), ref_secrets_pk)
self.assertEqual(key_store.getProjectSSHKeys("github", "org/project"),
ref_ssh_keys)
# Make sure we can still read the updates keys from the backup
# key store.
self.assertEqual(
encryption.serialize_rsa_private_key(
backup.getProjectSecretsKeys("github", "org/project")[0]
), ref_secrets_pk)
self.assertEqual(backup.getProjectSSHKeys("github", "org/project"),
ref_ssh_keys)
# Make sure that the backup key store is not used after keys have been
# written to the primary key store.
exc = AssertionError("Keys should not be loaded from backup store")
# with patch.object(backup, "getProjectSecretsKeys", side_effect=exc):
with patch.object(backup, "getProjectSecretsKeys", side_effect=exc):
self.assertEqual(
encryption.serialize_rsa_private_key(
key_store.getProjectSecretsKeys("github", "org/project")[0]
), ref_secrets_pk)
exc = AssertionError("SSH keys should not be loaded from backup store")
with patch.object(backup, "getProjectSSHKeys", side_effect=exc):
self.assertEqual(
key_store.getProjectSSHKeys("github", "org/project"),
ref_ssh_keys)
def test_without_backup(self):
key_store = keystorage.ZooKeeperKeyStorage(
key_store = keystorage.KeyStorage(
self.zk_client, password="DECAFBAD")
secrets_pk = encryption.serialize_rsa_private_key(
key_store.getProjectSecretsKeys("github", "org/project")[0])

View File

@ -19,6 +19,8 @@ import os
import re
import shutil
import socket
import subprocess
import sys
import textwrap
import threading
import time
@ -9002,3 +9004,46 @@ class TestEventProcessing(ZuulTestCase):
dict(name='tagjob', result='SUCCESS'),
dict(name='checkjob', result='SUCCESS', changes='1,1'),
], ordered=False)
class TestKeyExportImport(ZuulTestCase):
tenant_config_file = 'config/single-tenant/main.yaml'
def test_export_import(self):
# Test a round trip export/import of keys
export_root = os.path.join(self.test_root, 'export')
config_file = os.path.join(self.test_root, 'zuul.conf')
with open(config_file, 'w') as f:
self.config.write(f)
# Save a copy of the keys in ZK
old_data = self.getZKTree('/keystorage')
# Export keys
p = subprocess.Popen(
[os.path.join(sys.prefix, 'bin/zuul-scheduler'),
'-c', config_file,
'export-keys', export_root],
stdout=subprocess.PIPE)
out, _ = p.communicate()
self.log.debug(out.decode('utf8'))
# Delete keys from ZK
self.zk_client.client.delete('/keystorage', recursive=True)
# Make sure it's really gone
with testtools.ExpectedException(NoNodeError):
self.getZKTree('/keystorage')
# Import keys
p = subprocess.Popen(
[os.path.join(sys.prefix, 'bin/zuul-scheduler'),
'-c', config_file,
'import-keys', export_root],
stdout=subprocess.PIPE)
out, _ = p.communicate()
self.log.debug(out.decode('utf8'))
# Make sure the new data matches the original
new_data = self.getZKTree('/keystorage')
self.assertEqual(new_data, old_data)

View File

@ -42,9 +42,23 @@ class Scheduler(zuul.cmd.ZuulDaemonApp):
'listed, all tenants will be validated. '
'Note: this requires the gearman server and '
'will distribute work to mergers.')
parser.add_argument('command',
choices=zuul.scheduler.COMMANDS,
nargs='?')
subparser = parser.add_subparsers(help='sub-command help')
parser.set_defaults(command=None)
for command in zuul.scheduler.COMMANDS:
p = subparser.add_parser(command)
p.set_defaults(command=command)
import_parser = subparser.add_parser(
'import-keys',
help='import project keys to ZooKeeper')
import_parser.set_defaults(command='import-keys')
import_parser.add_argument('path', type=str,
help='filesystem root to read keys')
export_parser = subparser.add_parser(
'export-keys',
help='export project keys from ZooKeeper')
export_parser.set_defaults(command='export-keys')
export_parser.add_argument('path', type=str,
help='filesystem root to write keys')
return parser
def parseArguments(self, args=None):
@ -124,6 +138,11 @@ class Scheduler(zuul.cmd.ZuulDaemonApp):
os.kill(self.gear_server_pid, signal.SIGKILL)
def run(self):
if self.args.command == 'export-keys':
return zuul.scheduler.export_keys(self.config, self.args.path)
if self.args.command == 'import-keys':
return zuul.scheduler.import_keys(self.config, self.args.path)
if self.args.command in zuul.scheduler.COMMANDS:
self.send_command(self.args.command)
sys.exit(0)

View File

@ -41,7 +41,7 @@ from zuul.lib.config import get_default
from zuul.lib.logutil import get_annotated_logger
from zuul.lib.statsd import get_statsd
from zuul.lib import filecomments
from zuul.lib.keystorage import ZooKeeperKeyStorage
from zuul.lib.keystorage import KeyStorage
from zuul.lib.varnames import check_varnames
import gear
@ -3052,7 +3052,7 @@ class ExecutorServer(BaseMergeServer):
self.keep_jobdir = keep_jobdir
self.jobdir_root = jobdir_root
self.keystore = ZooKeeperKeyStorage(
self.keystore = KeyStorage(
self.zk_client,
password=self._get_key_store_password())
self._running = False

View File

@ -12,12 +12,9 @@
# License for the specific language governing permissions and limitations
# under the License.
import abc
import io
import json
import logging
import os
import tempfile
import time
import cachetools
@ -30,236 +27,8 @@ from zuul.zk import ZooKeeperBase
RSA_KEY_SIZE = 2048
class Migration(object):
class KeyStorage(ZooKeeperBase):
log = logging.getLogger("zuul.KeyStorage")
version = 0
parent = None
def verify(self, root):
fn = os.path.join(root, '.version')
if not os.path.exists(fn):
return False
with open(fn) as f:
data = int(f.read().strip())
if data == self.version:
return True
return False
def writeVersion(self, root):
fn = os.path.join(root, '.version')
with open(fn, 'w') as f:
f.write(str(self.version))
def upgrade(self, root):
pass
def verifyAndUpgrade(self, root):
if self.verify(root):
return
if self.parent:
self.parent.verifyAndUpgrade(root)
self.log.info("Upgrading key storage to version %s" % self.version)
self.upgrade(root)
self.writeVersion(root)
self.log.info("Finished upgrading key storage to version %s" %
self.version)
if not self.verify(root):
raise Exception("Inconsistent result after migration")
class MigrationV1(Migration):
version = 1
parent = None
"""Upgrade from the unversioned schema to version 1.
The original schema had secret keys in key_dir/connection/project.pem
This updates us to:
key_dir/
secrets/
project/
<connection>/
<project>/
<keyid>.pem
ssh/
project/
<connection>/
<project>/
<keyid>.pem
tenant/
<tenant>/
<keyid>.pem
Where keyids are integers to support future key rollover. In this
case, they will all be 0.
"""
def upgrade(self, root):
tmpdir = tempfile.mkdtemp(dir=root)
tmpdirname = os.path.basename(tmpdir)
connection_names = []
for connection_name in os.listdir(root):
if connection_name == tmpdirname:
continue
# Move existing connections out of the way (in case one of
# them was called 'secrets' or 'ssh'.
os.rename(os.path.join(root, connection_name),
os.path.join(tmpdir, connection_name))
connection_names.append(connection_name)
os.makedirs(os.path.join(root, 'secrets', 'project'), 0o700)
os.makedirs(os.path.join(root, 'ssh', 'project'), 0o700)
os.makedirs(os.path.join(root, 'ssh', 'tenant'), 0o700)
for connection_name in connection_names:
connection_root = os.path.join(tmpdir, connection_name)
for (dirpath, dirnames, filenames) in os.walk(connection_root):
subdir = os.path.relpath(dirpath, connection_root)
for fn in filenames:
key_name = os.path.join(subdir, fn)
project_name = key_name[:-len('.pem')]
key_dir = os.path.join(root, 'secrets', 'project',
connection_name, project_name)
os.makedirs(key_dir, 0o700)
old = os.path.join(tmpdir, connection_name, key_name)
new = os.path.join(key_dir, '0.pem')
self.log.debug("Moving key from %s to %s", old, new)
os.rename(old, new)
for (dirpath, dirnames, filenames) in os.walk(
connection_root, topdown=False):
os.rmdir(dirpath)
os.rmdir(tmpdir)
class KeyStorage(abc.ABC):
log = logging.getLogger("zuul.KeyStorage")
@abc.abstractmethod
def getProjectSSHKeys(self, connection_name, project_name):
"""Return the private and public SSH keys for the project
A new key will be created if necessary.
:returns: A tuple containing the PEM encoded private key and
base64 encoded public key.
"""
pass
@abc.abstractmethod
def getProjectSecretsKeys(self, connection_name, project_name):
"""Return the private and public secrets keys for the project
A new key will be created if necessary.
:returns: A tuple (private_key, public_key)
"""
pass
class FileKeyStorage(KeyStorage):
log = logging.getLogger("zuul.FileKeyStorage")
current_version = MigrationV1
def __init__(self, root):
self.root = root
migration = self.current_version()
migration.verifyAndUpgrade(root)
def getProjectSecretsKeyFile(self, connection, project, version=None):
"""Return the path to the private key used for the project's secrets"""
# We don't actually support multiple versions yet
if version is None:
version = '0'
return os.path.join(self.root, 'secrets', 'project',
connection, project, version + '.pem')
def getProjectSSHKeyFile(self, connection, project, version=None):
"""Return the path to the private ssh key for the project"""
# We don't actually support multiple versions yet
if version is None:
version = '0'
return os.path.join(self.root, 'ssh', 'project',
connection, project, version + '.pem')
def hasProjectSSHKeys(self, connection_name, project_name):
return os.path.isfile(
self.getProjectSSHKeyFile(connection_name, project_name))
def setProjectSSHKey(self, connection_name, project_name, private_key):
private_key_file = self.getProjectSSHKeyFile(connection_name,
project_name)
key_dir = os.path.dirname(private_key_file)
if not os.path.isdir(key_dir):
os.makedirs(key_dir, 0o700)
private_key.write_private_key_file(private_key_file)
return private_key_file
def getProjectSSHKeys(self, connection_name, project_name):
private_key_file = self._ensureSSHKeyFile(connection_name,
project_name)
key = paramiko.RSAKey.from_private_key_file(private_key_file)
with open(private_key_file, 'r') as f:
private_key = f.read()
public_key = key.get_base64()
return (private_key, 'ssh-rsa ' + public_key)
def _ensureSSHKeyFile(self, connection_name, project_name):
private_key_file = self.getProjectSSHKeyFile(connection_name,
project_name)
if os.path.exists(private_key_file):
return private_key_file
self.log.info("Generating SSH public key for project %s", project_name)
pk = paramiko.RSAKey.generate(bits=RSA_KEY_SIZE)
return self.setProjectSSHKey(connection_name, project_name, pk)
def hasProjectSecretsKeys(self, connection_name, project_name):
return os.path.isfile(
self.getProjectSecretsKeyFile(connection_name, project_name))
def setProjectSecretsKey(self, connection_name, project_name, private_key):
filename = self.getProjectSecretsKeyFile(connection_name, project_name)
key_dir = os.path.dirname(filename)
if not os.path.isdir(key_dir):
os.makedirs(key_dir, 0o700)
# Dump keys to filesystem. We only save the private key
# because the public key can be constructed from it.
self.log.info("Saving RSA keypair for project %s to %s",
project_name, filename)
pem_private_key = encryption.serialize_rsa_private_key(private_key)
# Ensure private key is read/write for zuul user only.
with open(os.open(filename,
os.O_CREAT | os.O_WRONLY, 0o600), 'wb') as f:
f.write(pem_private_key)
return filename
def getProjectSecretsKeys(self, connection_name, project_name):
private_key_file = self._ensureSecretsKeyFile(connection_name,
project_name)
# Load keypair
with open(private_key_file, "rb") as f:
return encryption.deserialize_rsa_keypair(f.read())
def _ensureSecretsKeyFile(self, connection_name, project_name):
filename = self.getProjectSecretsKeyFile(connection_name, project_name)
if os.path.isfile(filename):
return filename
self.log.info("Generating RSA keypair for project %s", project_name)
private_key, public_key = encryption.generate_rsa_keypair()
return self.setProjectSecretsKey(connection_name, project_name,
private_key)
class ZooKeeperKeyStorage(ZooKeeperBase, KeyStorage):
log = logging.getLogger("zuul.ZooKeeperKeyStorage")
SECRETS_PATH = "/keystorage/{}/{}/secrets"
SSH_PATH = "/keystorage/{}/{}/ssh"
@ -267,31 +36,58 @@ class ZooKeeperKeyStorage(ZooKeeperBase, KeyStorage):
super().__init__(zookeeper_client)
self.password = password
self.password_bytes = password.encode("utf-8")
self.backup = backup
def _walk(self, root):
ret = []
children = self.kazoo_client.get_children(root)
if children:
for child in children:
path = '/'.join([root, child])
ret.extend(self._walk(path))
else:
data, _ = self.kazoo_client.get(root)
try:
json.loads(data)
ret.append((root, data))
except Exception:
self.log.error("Unable to load keys at %s", root)
# Keep processing exports
return ret
def exportKeys(self):
paths = self._walk('/keystorage')
# Drop /keystorage from the start of the path
return [(path.split('/', 2)[2], data) for (path, data) in paths]
def importKeys(self, import_list):
for path, data in import_list:
try:
json.loads(data)
except Exception:
self.log.error("Unable to load keys at %s", path)
# Abort import on bad data
raise
path = '/'.join(['/keystorage', path])
try:
self.kazoo_client.create(path, value=data, makepath=True)
except kazoo.exceptions.NodeExistsError:
self.kazoo_client.set(path, value=data)
def getSSHKeysPath(self, connection_name, project_name):
key_project_name = strings.unique_project_name(project_name)
key_path = self.SSH_PATH.format(connection_name, key_project_name)
return key_path
@cachetools.cached(cache={})
def getProjectSSHKeys(self, connection_name, project_name):
key_project_name = strings.unique_project_name(project_name)
key_path = self.SSH_PATH.format(connection_name, key_project_name)
key_path = self.getSSHKeysPath(connection_name, project_name)
try:
key = self._getSSHKey(key_path)
except kazoo.exceptions.NoNodeError:
self.log.debug("Could not find existing SSH key for %s/%s",
connection_name, project_name)
if self.backup and self.backup.hasProjectSSHKeys(
connection_name, project_name
):
self.log.info("Using SSH key for %s/%s from backup key store",
connection_name, project_name)
pk, _ = self.backup.getProjectSSHKeys(connection_name,
project_name)
with io.StringIO(pk) as o:
key = paramiko.RSAKey.from_private_key(o)
else:
self.log.info("Generating a new SSH key for %s/%s",
connection_name, project_name)
key = paramiko.RSAKey.generate(bits=RSA_KEY_SIZE)
self.log.info("Generating a new SSH key for %s/%s",
connection_name, project_name)
key = paramiko.RSAKey.generate(bits=RSA_KEY_SIZE)
key_version = 0
key_created = int(time.time())
@ -302,10 +98,6 @@ class ZooKeeperKeyStorage(ZooKeeperBase, KeyStorage):
# creating the same SSH key.
key = self._getSSHKey(key_path)
# Make sure the SSH key is also stored in the backup keystore
if self.backup:
self.backup.setProjectSSHKey(connection_name, project_name, key)
with io.StringIO() as o:
key.write_private_key(o)
private_key = o.getvalue()
@ -337,32 +129,27 @@ class ZooKeeperKeyStorage(ZooKeeperBase, KeyStorage):
data = json.dumps(keydata).encode("utf-8")
self.kazoo_client.create(key_path, value=data, makepath=True)
@cachetools.cached(cache={})
def getProjectSecretsKeys(self, connection_name, project_name):
def getProjectSecretsKeysPath(self, connection_name, project_name):
key_project_name = strings.unique_project_name(project_name)
key_path = self.SECRETS_PATH.format(connection_name, key_project_name)
return key_path
@cachetools.cached(cache={})
def getProjectSecretsKeys(self, connection_name, project_name):
key_path = self.getProjectSecretsKeysPath(
connection_name, project_name)
try:
pem_private_key = self._getSecretsKeys(key_path)
except kazoo.exceptions.NoNodeError:
self.log.debug("Could not find existing secrets key for %s/%s",
connection_name, project_name)
if self.backup and self.backup.hasProjectSecretsKeys(
connection_name, project_name):
self.log.info(
"Using secrets key for %s/%s from backup key store",
connection_name, project_name)
private_key, public_key = self.backup.getProjectSecretsKeys(
connection_name, project_name)
else:
self.log.info("Generating a new secrets key for %s/%s",
connection_name, project_name)
private_key, public_key = encryption.generate_rsa_keypair()
self.log.info("Generating a new secrets key for %s/%s",
connection_name, project_name)
private_key, public_key = encryption.generate_rsa_keypair()
pem_private_key = encryption.serialize_rsa_private_key(
private_key, self.password_bytes)
key_version = 0
key_created = int(time.time())
try:
self._storeSecretsKeys(key_path, pem_private_key,
key_version, key_created)
@ -374,11 +161,6 @@ class ZooKeeperKeyStorage(ZooKeeperBase, KeyStorage):
private_key, public_key = encryption.deserialize_rsa_keypair(
pem_private_key, self.password_bytes)
# Make sure the private key is also stored in the backup keystore
if self.backup:
self.backup.setProjectSecretsKey(connection_name, project_name,
private_key)
return private_key, public_key
def _getSecretsKeys(self, key_path):

View File

@ -36,7 +36,7 @@ from zuul.lib import commandsocket
from zuul.lib.ansible import AnsibleManager
from zuul.lib.config import get_default
from zuul.lib.gear_utils import getGearmanFunctions
from zuul.lib.keystorage import FileKeyStorage, ZooKeeperKeyStorage
from zuul.lib.keystorage import KeyStorage
from zuul.lib.logutil import get_annotated_logger
from zuul.lib.queue import NamedQueue
from zuul.lib.statsd import get_statsd, normalize_statsd_name
@ -230,10 +230,9 @@ class Scheduler(threading.Thread):
def start(self):
super(Scheduler, self).start()
self.keystore = ZooKeeperKeyStorage(
self.keystore = KeyStorage(
self.zk_client,
password=self._get_key_store_password(),
backup=FileKeyStorage(self._get_key_dir()))
password=self._get_key_store_password())
self._command_running = True
self.log.debug("Starting command processor")
@ -790,19 +789,6 @@ class Scheduler(threading.Thread):
except KeyError:
raise RuntimeError("No key store password configured!")
def _get_key_dir(self):
state_dir = get_default(self.config, 'scheduler', 'state_dir',
'/var/lib/zuul', expand_user=True)
key_dir = os.path.join(state_dir, 'keys')
if not os.path.exists(key_dir):
os.mkdir(key_dir, 0o700)
st = os.stat(key_dir)
mode = st.st_mode & 0o777
if mode != 0o700:
raise Exception("Project key directory %s must be mode 0700; "
"current mode is %o" % (key_dir, mode))
return key_dir
def _checkTenantSourceConf(self, config):
tenant_config = None
script = False
@ -2014,3 +2000,42 @@ class Scheduler(threading.Thread):
# Release the semaphore in any case
tenant = buildset.item.pipeline.tenant
tenant.semaphore_handler.release(item, job)
def export_keys(config, path):
zk_client = ZooKeeperClient.fromConfig(config)
zk_client.connect()
try:
password = config["keystore"]["password"]
except KeyError:
raise RuntimeError("No key store password configured!")
keystore = KeyStorage(zk_client, password=password)
for key_path, key_data in keystore.exportKeys():
print(key_path)
key_root, fn = key_path.rsplit('/', 1)
root = os.path.join(path, key_root)
os.makedirs(root, exist_ok=True)
with open(os.path.join(root, fn), 'wb') as f:
f.write(key_data)
def import_keys(config, path):
zk_client = ZooKeeperClient.fromConfig(config)
zk_client.connect()
try:
password = config["keystore"]["password"]
except KeyError:
raise RuntimeError("No key store password configured!")
keystore = KeyStorage(zk_client, password=password)
import_list = []
for (dirpath, dirnames, filenames) in os.walk(path):
for fn in filenames:
key_root = dirpath[len(path):]
if key_root.startswith('/'):
key_root = key_root[1:]
key_root = os.path.join(key_root, fn)
print(key_root)
with open(os.path.join(dirpath, fn), 'rb') as f:
key_data = f.read()
import_list.append((key_root, key_data))
keystore.importKeys(import_list)