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:
parent
ce56d5182a
commit
46389b5187
@ -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**
|
||||||
|
|
||||||
|
0
roles/add-build-sshkey/__init__.py
Normal file
0
roles/add-build-sshkey/__init__.py
Normal file
0
roles/add-build-sshkey/library/__init__.py
Normal file
0
roles/add-build-sshkey/library/__init__.py
Normal file
126
roles/add-build-sshkey/library/sshagent_remove_keys.py
Normal file
126
roles/add-build-sshkey/library/sshagent_remove_keys.py
Normal 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()
|
85
roles/add-build-sshkey/library/test_sshagent_remove_keys.py
Normal file
85
roles/add-build-sshkey/library/test_sshagent_remove_keys.py
Normal 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])
|
@ -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
|
||||||
|
|
||||||
|
2
tox.ini
2
tox.ini
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user