From 46389b5187cb0d5f990a4a71747bfeb21bf9840b Mon Sep 17 00:00:00 2001 From: "James E. Blair" Date: Fri, 31 Aug 2018 14:02:31 -0700 Subject: [PATCH] add-build-sshkey: Remove only the master key This implements a module to directly interact with the ssh-agent so that the master key may be removed from the ssh-agent without removing any per-project keys. Change-Id: Ife91ad8afa9b41b0e779a832e298aca8d61ae98b --- roles/add-build-sshkey/README.rst | 7 +- roles/add-build-sshkey/__init__.py | 0 roles/add-build-sshkey/library/__init__.py | 0 .../library/sshagent_remove_keys.py | 126 ++++++++++++++++++ .../library/test_sshagent_remove_keys.py | 85 ++++++++++++ .../tasks/create-key-and-replace.yaml | 7 +- tox.ini | 2 +- 7 files changed, 221 insertions(+), 6 deletions(-) create mode 100644 roles/add-build-sshkey/__init__.py create mode 100644 roles/add-build-sshkey/library/__init__.py create mode 100644 roles/add-build-sshkey/library/sshagent_remove_keys.py create mode 100644 roles/add-build-sshkey/library/test_sshagent_remove_keys.py diff --git a/roles/add-build-sshkey/README.rst b/roles/add-build-sshkey/README.rst index d2b5527f9..5119a14d6 100644 --- a/roles/add-build-sshkey/README.rst +++ b/roles/add-build-sshkey/README.rst @@ -3,9 +3,10 @@ Generate and install a build-local SSH key on all hosts This role is intended to be run on the Zuul Executor at the start of every job. It generates an SSH keypair and installs the public key in the authorized_keys file of every host in the inventory. It then -removes all keys from this job's SSH agent so that the original key -used to log into all of the hosts is no longer accessible, then adds -the newly generated private key. +removes the Zuul master key from this job's SSH agent so that the +original key used to log into all of the hosts is no longer accessible +(any per-project keys, if present, remain available), then adds the +newly generated private key. **Role Variables** diff --git a/roles/add-build-sshkey/__init__.py b/roles/add-build-sshkey/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/roles/add-build-sshkey/library/__init__.py b/roles/add-build-sshkey/library/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/roles/add-build-sshkey/library/sshagent_remove_keys.py b/roles/add-build-sshkey/library/sshagent_remove_keys.py new file mode 100644 index 000000000..b4f6ea699 --- /dev/null +++ b/roles/add-build-sshkey/library/sshagent_remove_keys.py @@ -0,0 +1,126 @@ +# Copyright 2018 Red Hat, Inc. +# +# 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 socket +import struct +import sys +import re + +from ansible.module_utils.basic import AnsibleModule + + +SSH_AGENT_FAILURE = 5 +SSH_AGENT_SUCCESS = 6 +SSH_AGENT_IDENTITIES_ANSWER = 12 + +SSH_AGENTC_REQUEST_IDENTITIES = 11 +SSH_AGENTC_REMOVE_IDENTITY = 18 + + +def unpack_string(data): + (l,) = struct.unpack('!i', data[:4]) + d = data[4:4 + l] + return (d, data[4 + l:]) + + +def pack_string(data): + ret = struct.pack('!i', len(data)) + return ret + data + + +class Agent(object): + def __init__(self): + path = os.environ['SSH_AUTH_SOCK'] + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.connect(path) + + def send(self, message_type, contents): + payload = struct.pack('!ib', len(contents) + 1, message_type) + payload += bytearray(contents) + self.sock.send(payload) + + def recv(self): + buf = b'' + while len(buf) < 5: + buf += self.sock.recv(1) + message_len, message_type = struct.unpack('!ib', buf[:5]) + buf = buf[5:] + while len(buf) < message_len - 1: + buf += self.sock.recv(1) + return message_type, buf + + def list(self): + self.send(SSH_AGENTC_REQUEST_IDENTITIES, b'') + mtype, data = self.recv() + if mtype != SSH_AGENT_IDENTITIES_ANSWER: + raise Exception("Invalid response to list") + (nkeys,) = struct.unpack('!i', data[:4]) + data = data[4:] + keys = [] + for i in range(nkeys): + blob, data = unpack_string(data) + comment, data = unpack_string(data) + keys.append((blob, comment)) + return keys + + def remove(self, blob): + self.send(SSH_AGENTC_REMOVE_IDENTITY, pack_string(blob)) + mtype, data = self.recv() + if mtype != SSH_AGENT_SUCCESS: + raise Exception("Key was not removed") + + +def run(remove): + a = Agent() + keys = a.list() + removed = [] + to_remove = re.compile(remove) + for blob, comment in keys: + if not to_remove.match(comment.decode('utf8')): + continue + a.remove(blob) + removed.append(comment) + return removed + + +def ansible_main(): + module = AnsibleModule( + argument_spec=dict( + remove=dict(required=True, type='str'))) + + removed = run(module.params.get('remove')) + + module.exit_json(changed=(removed != []), + removed=removed) + + +def cli_main(): + parser = argparse.ArgumentParser( + description="Remove ssh keys from agent" + ) + parser.add_argument('remove', nargs='+', + help='regex matching comments of keys to remove') + args = parser.parse_args() + + removed = run(args.remove) + print(removed) + + +if __name__ == '__main__': + if sys.stdin.isatty(): + cli_main() + else: + ansible_main() diff --git a/roles/add-build-sshkey/library/test_sshagent_remove_keys.py b/roles/add-build-sshkey/library/test_sshagent_remove_keys.py new file mode 100644 index 000000000..70fa55840 --- /dev/null +++ b/roles/add-build-sshkey/library/test_sshagent_remove_keys.py @@ -0,0 +1,85 @@ +# Copyright (C) 2018 Red Hat, Inc. +# +# 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. + +from __future__ import absolute_import + +import os +import testtools +import fixtures +import subprocess +import paramiko +import signal + +from .sshagent_remove_keys import Agent, run + + +class AgentFixture(fixtures.Fixture): + + def _setUp(self): + self.env = {} + with open('/dev/null', 'r+') as devnull: + ssh_agent = subprocess.Popen(['ssh-agent'], close_fds=True, + stdout=subprocess.PIPE, + stderr=devnull, + stdin=devnull) + (output, _) = ssh_agent.communicate() + output = output.decode('utf8') + for line in output.split("\n"): + if '=' in line: + line = line.split(";", 1)[0] + (key, value) = line.split('=') + self.env[key] = value + os.environ[key] = value + self.addCleanup(self.stop) + + def stop(self): + if 'SSH_AGENT_PID' in self.env: + os.kill(int(self.env['SSH_AGENT_PID']), signal.SIGTERM) + + +class TestAgent(testtools.TestCase): + def test_agent(self): + """Test the ssh agent library""" + self.useFixture(AgentFixture()) + tmpdir = fixtures.TempDir() + self.useFixture(tmpdir) + + k1_path = os.path.join(tmpdir.path, 'key1') + k1 = paramiko.RSAKey.generate(bits=1024) + k1.write_private_key_file(k1_path) + self.assertTrue(os.path.exists(k1_path)) + subprocess.call(['ssh-add', k1_path]) + + k2_path = os.path.join(tmpdir.path, 'key2') + k2 = paramiko.RSAKey.generate(bits=1024) + k2.write_private_key_file(k2_path) + self.assertTrue(os.path.exists(k2_path)) + with open(k2_path, 'r') as f: + k2_private = f.read() + proc = subprocess.Popen(['ssh-add', '-'], + stdin=subprocess.PIPE, + stderr=subprocess.PIPE) + proc.communicate(input=k2_private.encode('utf8')) + + a = Agent() + l = a.list() + self.assertEqual(2, len(l)) + + run('^(?!\(stdin\)).*') + + l = a.list() + self.assertEqual(1, len(l)) + self.assertTrue(b'stdin' in l[0][1]) diff --git a/roles/add-build-sshkey/tasks/create-key-and-replace.yaml b/roles/add-build-sshkey/tasks/create-key-and-replace.yaml index 4de69ea76..79a3304bc 100644 --- a/roles/add-build-sshkey/tasks/create-key-and-replace.yaml +++ b/roles/add-build-sshkey/tasks/create-key-and-replace.yaml @@ -29,8 +29,11 @@ mode: 0644 force: no -- name: Remove all keys from local agent - command: ssh-add -D +- name: Remove master key from local agent + # The master key has a filename, all others (e.g., per-project keys) + # have "(stdin)" as a comment. + sshagent_remove_keys: + remove: '^(?!\(stdin\)).*' delegate_to: localhost run_once: true diff --git a/tox.ini b/tox.ini index 035cfd24e..8419ce0e7 100644 --- a/tox.ini +++ b/tox.ini @@ -51,6 +51,6 @@ commands = {posargs} # These are ignored intentionally in openstack-infra projects; # please don't submit patches that solely correct them or enable them. # E402 - ansible modules put documentation before imports. Align to ansible. -ignore = E125,E129,E402,H +ignore = E125,E129,E402,E741,H show-source = True exclude = .venv,.tox,dist,doc,build,*.egg