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
This commit is contained in:
James E. Blair 2018-08-31 14:02:31 -07:00
parent ce56d5182a
commit 46389b5187
7 changed files with 221 additions and 6 deletions

View File

@ -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 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 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 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 removes the Zuul master key from this job's SSH agent so that the
used to log into all of the hosts is no longer accessible, then adds original key used to log into all of the hosts is no longer accessible
the newly generated private key. (any per-project keys, if present, remain available), then adds the
newly generated private key.
**Role Variables** **Role Variables**

View File

View File

@ -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()

View File

@ -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])

View File

@ -29,8 +29,11 @@
mode: 0644 mode: 0644
force: no force: no
- name: Remove all keys from local agent - name: Remove master key from local agent
command: ssh-add -D # 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 delegate_to: localhost
run_once: true run_once: true

View File

@ -51,6 +51,6 @@ commands = {posargs}
# These are ignored intentionally in openstack-infra projects; # These are ignored intentionally in openstack-infra projects;
# please don't submit patches that solely correct them or enable them. # please don't submit patches that solely correct them or enable them.
# E402 - ansible modules put documentation before imports. Align to ansible. # E402 - ansible modules put documentation before imports. Align to ansible.
ignore = E125,E129,E402,H ignore = E125,E129,E402,E741,H
show-source = True show-source = True
exclude = .venv,.tox,dist,doc,build,*.egg exclude = .venv,.tox,dist,doc,build,*.egg