From ad3c90b74ee8370b8a2bab79db536c61a403eb9c Mon Sep 17 00:00:00 2001 From: nicholasyang Date: Mon, 9 Oct 2023 15:16:59 +0800 Subject: [PATCH] Dev: implement ssh-agent support for bootstrap (jsc#PED-5774) --- crmsh/bootstrap.py | 161 ++++++++++++++++++++++++++------------- crmsh/sh.py | 93 ++++++++++++++++++----- crmsh/ssh_key.py | 174 ++++++++++++++++++++++++++++++++++++------- crmsh/ui_cluster.py | 12 ++- crmsh/ui_corosync.py | 2 +- crmsh/utils.py | 12 +++ 6 files changed, 355 insertions(+), 99 deletions(-) diff --git a/crmsh/bootstrap.py b/crmsh/bootstrap.py index 731db47cac..a33598164f 100644 --- a/crmsh/bootstrap.py +++ b/crmsh/bootstrap.py @@ -143,7 +143,8 @@ def __init__(self): COROSYNC_AUTH, "/var/lib/heartbeat/crm/*", "/var/lib/pacemaker/cib/*", "/var/lib/corosync/*", "/var/lib/pacemaker/pengine/*", PCMK_REMOTE_AUTH, "/var/lib/csync2/*", "~/.config/crm/*"] - self.ssh_key_file = None + self.use_ssh_agent = False + self.skip_ssh = False @classmethod def set_context(cls, options): @@ -485,7 +486,7 @@ def is_online(): if not xmlutil.CrmMonXmlParser.is_node_online(cluster_node): shutil.copy(COROSYNC_CONF_ORIG, corosync.conf()) sync_file(corosync.conf()) - ServiceManager(sh.LocalOnlyClusterShell(sh.LocalShell())).stop_service("corosync") + ServiceManager(sh.ClusterShellAdaptorForLocalShell(sh.LocalShell())).stop_service("corosync") print() utils.fatal("Cannot see peer node \"{}\", please check the communication IP".format(cluster_node)) return True @@ -833,11 +834,22 @@ def _parse_user_at_host(s: str, default_user: str) -> typing.Tuple[str, str]: def init_ssh(): user_host_list = [_parse_user_at_host(x, _context.current_user) for x in _context.user_at_node_list] - init_ssh_impl( - _context.current_user, - ssh_key.KeyFile(_context.ssh_key_file) if _context.ssh_key_file is not None else None, - user_host_list, - ) + if _context.use_ssh_agent: + try: + ssh_agent = ssh_key.AgentClient() + keys = ssh_agent.list() + key = keys[utils.ask_for_selection( + [key.public_key() for key in keys], + "Select a public key", + 0, + _context.yes_to_all, + )] + except ssh_key.Error: + logger.error("Cannot get a public key from ssh-agent.") + raise + else: + key = None + init_ssh_impl(_context.current_user, key, user_host_list) if user_host_list: service_manager = ServiceManager() for user, node in user_host_list: @@ -852,8 +864,9 @@ def init_ssh_impl(local_user: str, ssh_public_key: typing.Optional[ssh_key.Key], If user_node_list is not empty, those user and host will also be configured. If ssh_public_key is specified, it will be added to authorized_keys; if not, a new key pair will be generated for each node. """ - ServiceManager(sh.LocalOnlyClusterShell(sh.LocalShell())).start_service("sshd.service", enable=True) - shell = sh.SSHShell(sh.LocalShell(), local_user) + ServiceManager(sh.ClusterShellAdaptorForLocalShell(sh.LocalShell())).start_service("sshd.service", enable=True) + local_shell = sh.LocalShell() + shell = sh.SSHShell(local_shell, local_user) authorized_key_manager = ssh_key.AuthorizedKeyManager(shell) if ssh_public_key is not None: # Use specified key. Do not generate new ones. @@ -870,6 +883,22 @@ def init_ssh_impl(local_user: str, ssh_public_key: typing.Optional[ssh_key.Key], authorized_key_manager.add(node, user, ssh_public_key) else: _init_ssh_on_remote_nodes(local_user, ssh_public_key, user_node_list) + user_by_host = utils.HostUserConfig() + for user, node in user_node_list: + user_by_host.add(user, node) + user_by_host.add(local_user, utils.this_node()) + user_by_host.save_remote([node for user, node in user_node_list]) + for user, node in user_node_list: + change_user_shell('hacluster', node) + # Starting from here, ClusterShell is available + shell = sh.ClusterShell(local_shell, UserOfHost.instance()) + # FIXME: AuthorizedKeyManager cannot operate a user on remote host using root permission provided by ClusterShell + authorized_key_manager = ssh_key.AuthorizedKeyManager(shell) + _init_ssh_for_secondary_user_on_remote_nodes( + shell, authorized_key_manager, + [node for user, node in user_node_list], + 'hacluster', + ) def _init_ssh_on_remote_nodes( @@ -879,16 +908,13 @@ def _init_ssh_on_remote_nodes( ): # Swap public ssh key between remote node and local public_key_list = list() - hacluster_public_key_list = list() for i, (remote_user, node) in enumerate(user_node_list): utils.ssh_copy_id(local_user, remote_user, node) # After this, login to remote_node is passwordless public_key_list.append(swap_public_ssh_key(node, local_user, remote_user, local_user, remote_user, add=True)) - hacluster_public_key_list.append(swap_public_ssh_key(node, 'hacluster', 'hacluster', local_user, remote_user, add=True)) if len(user_node_list) > 1: shell = sh.LocalShell() shell_script = _merge_authorized_keys(public_key_list) - hacluster_shell_script = _merge_authorized_keys(hacluster_public_key_list) for i, (remote_user, node) in enumerate(user_node_list): result = shell.su_subprocess_run( local_user, @@ -899,22 +925,25 @@ def _init_ssh_on_remote_nodes( ) if result.returncode != 0: utils.fatal('Failed to add public keys to {}@{}: {}'.format(remote_user, node, result.stdout)) - result = shell.su_subprocess_run( - local_user, - 'ssh {} {}@{} sudo -H -u {} /bin/sh'.format(constants.SSH_OPTION, remote_user, node, 'hacluster'), - input=hacluster_shell_script, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - ) - if result.returncode != 0: - utils.fatal('Failed to add public keys to {}@{}: {}'.format(remote_user, node, result.stdout)) - user_by_host = utils.HostUserConfig() - for user, node in user_node_list: - user_by_host.add(user, node) - user_by_host.add(local_user, utils.this_node()) - user_by_host.save_remote([node for user, node in user_node_list]) - for user, node in user_node_list: - change_user_shell('hacluster', node) + + +def _init_ssh_for_secondary_user_on_remote_nodes( + cluster_shell: sh.ClusterShell, + authorized_key_manager: ssh_key.AuthorizedKeyManager, + nodes: typing.Iterable[str], + user: str, +): + key_file_manager = ssh_key.KeyFileManager(cluster_shell) + local_keys = [ssh_key.KeyFile(path) for path in key_file_manager.list_public_key_for_user(None, user)] + assert local_keys + for node in nodes: + if not sh.SSHShell(cluster_shell.local_shell, user).can_run_as(node, user): + for key in local_keys: + authorized_key_manager.add(node, user, key) + remote_keys = key_file_manager.ensure_key_pair_exists_for_user(node, user) + for key in remote_keys: + authorized_key_manager.add(None, user, key) + def _merge_authorized_keys(keys: typing.List[str]) -> bytes: @@ -1704,26 +1733,48 @@ def join_ssh(seed_host, seed_user): """ if not seed_host: utils.fatal("No existing IP/hostname specified (use -c option)") - local_user = _context.current_user - ServiceManager(sh.LocalOnlyClusterShell(sh.LocalShell())).start_service("sshd.service", enable=True) - configure_ssh_key(local_user) - if 0 != utils.ssh_copy_id_no_raise(local_user, seed_user, seed_host): - msg = f"Failed to login to {seed_user}@{seed_host}. Please check the credentials." - sudoer = userdir.get_sudoer() - if sudoer and seed_user != sudoer: - args = ['sudo crm'] - args += [x for x in sys.argv[1:]] - for i, arg in enumerate(args): - if arg == '-c' or arg == '--cluster-node' and i + 1 < len(args): - if '@' not in args[i+1]: - args[i + 1] = f'{sudoer}@{seed_host}' - msg += '\nOr, run "{}".'.format(' '.join(args)) - raise ValueError(msg) - # After this, login to remote_node is passwordless - swap_public_ssh_key(seed_host, local_user, seed_user, local_user, seed_user, add=True) - configure_ssh_key('hacluster') - swap_public_ssh_key(seed_host, 'hacluster', 'hacluster', local_user, seed_user, add=True) + + if _context.use_ssh_agent: + try: + ssh_agent = ssh_key.AgentClient() + keys = ssh_agent.list() + key = keys[utils.ask_for_selection( + [key.public_key() for key in keys], + "Select a public key", + 0, + _context.yes_to_all, + )] + except ssh_key.Error: + logger.error("Cannot get a public key from ssh-agent.") + raise + else: + key = None + return join_ssh_impl(local_user, seed_host, seed_user, key, _context.skip_ssh) + + +def join_ssh_impl(local_user, seed_host, seed_user, key: typing.Optional[ssh_key.Key], skip_key_swap: bool): + ServiceManager(sh.ClusterShellAdaptorForLocalShell(sh.LocalShell())).start_service("sshd.service", enable=True) + if key is not None: + ssh_key.AuthorizedKeyManager(sh.SSHShell(sh.LocalShell(), local_user)).add(None, local_user, key) + elif not skip_key_swap: + configure_ssh_key(local_user) + if 0 != utils.ssh_copy_id_no_raise(local_user, seed_user, seed_host): + msg = f"Failed to login to {seed_user}@{seed_host}. Please check the credentials." + sudoer = userdir.get_sudoer() + if sudoer and seed_user != sudoer: + args = ['sudo crm'] + args += [x for x in sys.argv[1:]] + for i, arg in enumerate(args): + if arg == '-c' or arg == '--cluster-node' and i + 1 < len(args): + if '@' not in args[i+1]: + args[i + 1] = f'{sudoer}@{seed_host}' + msg += '\nOr, run "{}".'.format(' '.join(args)) + raise ValueError(msg) + # After this, login to remote_node is passwordless + swap_public_ssh_key(seed_host, local_user, seed_user, local_user, seed_user, add=True) + configure_ssh_key('hacluster') + swap_public_ssh_key(seed_host, 'hacluster', 'hacluster', local_user, seed_user, add=True) # This makes sure the seed host has its own SSH keys in its own # authorized_keys file (again, to help with the case where the @@ -2197,7 +2248,7 @@ def update_nodeid(nodeid, node=None): if is_qdevice_configured: start_qdevice_on_join_node(seed_host) else: - ServiceManager(sh.LocalOnlyClusterShell(sh.LocalShell())).disable_service("corosync-qdevice.service") + ServiceManager(sh.ClusterShellAdaptorForLocalShell(sh.LocalShell())).disable_service("corosync-qdevice.service") def adjust_priority_in_rsc_defaults(is_2node_wo_qdevice): @@ -2246,7 +2297,7 @@ def start_qdevice_on_join_node(seed_host): qnetd_addr = corosync.get_value("quorum.device.net.host") qdevice_inst = qdevice.QDevice(qnetd_addr, cluster_node=seed_host) qdevice_inst.certificate_process_on_join() - ServiceManager(sh.LocalOnlyClusterShell(sh.LocalShell())).start_service("corosync-qdevice.service", enable=True) + ServiceManager(sh.ClusterShellAdaptorForLocalShell(sh.LocalShell())).start_service("corosync-qdevice.service", enable=True) def get_cluster_node_ip(node: str) -> str: @@ -2402,7 +2453,7 @@ def bootstrap_init(context): _context.cluster_node = args[1] if stage and _context.cluster_is_running and \ - not ServiceManager(shell=sh.LocalOnlyClusterShell(sh.LocalShell())).service_is_active(CSYNC2_SERVICE): + not ServiceManager(shell=sh.ClusterShellAdaptorForLocalShell(sh.LocalShell())).service_is_active(CSYNC2_SERVICE): _context.skip_csync2 = True _context.node_list_in_cluster = utils.list_cluster_nodes() elif not _context.cluster_is_running: @@ -2449,12 +2500,16 @@ def bootstrap_add(context): options += '-i {} '.format(nic) options = " {}".format(options.strip()) if options else "" + if context.use_ssh_agent: + options += ' --skip-ssh' + + shell = sh.ClusterShell(sh.LocalShell(), UserOfHost.instance(), _context.use_ssh_agent) for (user, node) in (_parse_user_at_host(x, _context.current_user) for x in _context.user_at_node_list): print() logger.info("Adding node {} to cluster".format(node)) cmd = 'crm cluster join -y {} -c {}@{}'.format(options, _context.current_user, utils.this_node()) logger.info("Running command on {}: {}".format(node, cmd)) - out = sh.cluster_shell().get_stdout_or_raise_error(cmd, node) + out = shell.get_stdout_or_raise_error(cmd, node) print(out) @@ -2470,7 +2525,7 @@ def bootstrap_join(context): check_tty() - corosync_active = ServiceManager(sh.LocalOnlyClusterShell(sh.LocalShell())).service_is_active("corosync.service") + corosync_active = ServiceManager(sh.ClusterShellAdaptorForLocalShell(sh.LocalShell())).service_is_active("corosync.service") if corosync_active and _context.stage != "ssh": utils.fatal("Abort: Cluster is currently active. Run this command on a node joining the cluster.") @@ -2849,7 +2904,7 @@ def bootstrap_arbitrator(context): utils.fatal("Failed to copy {} from {}".format(BOOTH_CFG, _context.cluster_node)) # TODO: verify that the arbitrator IP in the configuration is us? logger.info("Enabling and starting the booth arbitrator service") - ServiceManager(sh.LocalOnlyClusterShell(sh.LocalShell())).start_service("booth@booth", enable=True) + ServiceManager(sh.ClusterShellAdaptorForLocalShell(sh.LocalShell())).start_service("booth@booth", enable=True) def get_stonith_timeout_generally_expected(): diff --git a/crmsh/sh.py b/crmsh/sh.py index 27ce1a24c3..58a71ae541 100644 --- a/crmsh/sh.py +++ b/crmsh/sh.py @@ -21,6 +21,7 @@ import logging import os import pwd +import re import socket import subprocess import typing @@ -95,12 +96,15 @@ def geteuid() -> int: def get_effective_user_name() -> str: return pwd.getpwuid(LocalShell.geteuid()).pw_name + def __init__(self, additional_environ: typing.Dict[str, str] = None): + self.additional_environ = additional_environ + def can_run_as(self, user: str): return self.geteuid() == 0 or self.get_effective_user_name() == user def su_subprocess_run( self, - user: str, + user: typing.Optional[str], cmd: str, tty=False, preserve_env: typing.Optional[typing.List[str]] = None, @@ -111,7 +115,7 @@ def su_subprocess_run( This variant is the most flexible one as it pass unknown kwargs to the underlay subprocess.run. However, it accepts only cmdline but not argv, as the argv is used internally to switch user. """ - if self.get_effective_user_name() == user: + if user is None or self.get_effective_user_name() == user: args = ['/bin/sh', '-c', cmd] elif 0 == self.geteuid(): args = ['su', user, '--login', '-c', cmd] @@ -125,10 +129,16 @@ def su_subprocess_run( cmd, None, user, f"non-root user '{self.get_effective_user_name()}' cannot switch to another user" ) - logger.debug('su_subprocess_run: %s, %s', args, kwargs) - return subprocess.run(args, **kwargs) + if not self.additional_environ: + logger.debug('su_subprocess_run: %s, %s', args, kwargs) + return subprocess.run(args, **kwargs) + else: + logger.debug('su_subprocess_run: %s, env=%s, %s', args, self.additional_environ, kwargs) + env = dict(os.environ) + env.update(self.additional_environ) + return subprocess.run(args, env=env, **kwargs) - def get_rc_stdout_stderr_raw(self, user: str, cmd: str, input: typing.Optional[bytes] = None): + def get_rc_stdout_stderr_raw(self, user: typing.Optional[str], cmd: str, input: typing.Optional[bytes] = None): result = self.su_subprocess_run( user, cmd, input=input, @@ -137,13 +147,13 @@ def get_rc_stdout_stderr_raw(self, user: str, cmd: str, input: typing.Optional[b ) return result.returncode, result.stdout, result.stderr - def get_rc_stdout_stderr(self, user: str, cmd: str, input: typing.Optional[str] = None): + def get_rc_stdout_stderr(self, user: typing.Optional[str], cmd: str, input: typing.Optional[str] = None): rc, stdout, stderr = self.get_rc_stdout_stderr_raw(user, cmd, input.encode('utf-8') if input is not None else None) return rc, Utils.decode_str(stdout).strip(), Utils.decode_str(stderr).strip() def get_rc_and_error( self, - user: str, + user: typing.Optional[str], cmd: str, ) -> typing.Tuple[int, typing.Optional[str]]: """Run a command for its side effects. Returns (rc, error_message) @@ -174,7 +184,7 @@ def get_rc_and_error( def get_stdout_or_raise_error( self, - user: str, + user: typing.Optional[str], cmd: str, success_exit_status: typing.Optional[typing.Set[int]] = None, ): @@ -263,28 +273,75 @@ class ClusterShell: For remote nodes, the local and remote user used for SSH sessions are determined from cluster configuration recorded during bootstrap. """ - def __init__(self, local_shell: LocalShell, user_of_host: UserOfHost): + def __init__( + self, + local_shell: LocalShell, + user_of_host: UserOfHost, + forward_ssh_agent: bool = False, + raise_ssh_error: bool = False, # whether to raise AuthorizationError when ssh returns with 255 + ): self.local_shell = local_shell self.user_of_host = user_of_host + self.forward_ssh_agent = forward_ssh_agent + self.raise_ssh_error = raise_ssh_error + + def can_run_as(self, host: typing.Optional[str], user: str) -> bool: + result = self.subprocess_run_without_input( + host, user, 'true', + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return 0 == result.returncode def subprocess_run_without_input(self, host: typing.Optional[str], user: typing.Optional[str], cmd: str, **kwargs): assert 'input' not in kwargs and 'stdin' not in kwargs if host is None or host == self.local_shell.hostname(): - return subprocess.run( - ['/bin/sh'], - input=cmd.encode('utf-8'), - **kwargs, - ) + if user is None: + return subprocess.run( + ['/bin/sh'], + input=cmd.encode('utf-8'), + **kwargs, + ) + else: + # TODO: implement forward_ssh_agent + return self.local_shell.su_subprocess_run( + user, cmd, + **kwargs, + ) else: if user is None: user = 'root' local_user, remote_user = self.user_of_host.user_pair_for_ssh(host) - return self.local_shell.su_subprocess_run( + result = self.local_shell.su_subprocess_run( local_user, - 'ssh {} {}@{} sudo -H -u {} /bin/sh'.format(constants.SSH_OPTION, remote_user, host, user), + 'ssh {} {} {}@{} sudo -H -u {} {} /bin/sh'.format( + '-A' if self.forward_ssh_agent else '', + constants.SSH_OPTION, + remote_user, + host, + user, + '--preserve-env=SSH_AUTH_SOCK' if self.forward_ssh_agent else '', + constants.SSH_OPTION, + ), input=cmd.encode('utf-8'), **kwargs, ) + if self.raise_ssh_error and result.returncode == 255: + raise AuthorizationError(cmd, host, remote_user, Utils.decode_str(result.stderr).strip()) + else: + return result + + def get_rc_and_error(self, host: typing.Optional[str], user: str, cmd: str): + result = self.subprocess_run_without_input( + host, user, cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + if result.returncode == 0: + return 0, None + else: + return result.returncode, Utils.decode_str(result.stdout).strip() def get_rc_stdout_stderr_raw_without_input(self, host, cmd) -> typing.Tuple[int, bytes, bytes]: result = self.subprocess_run_without_input( @@ -319,6 +376,8 @@ def get_stdout_or_raise_error( class ShellUtils: + CONTROL_CHARACTER_PATTER = re.compile('[\u0000-\u001F]') + @classmethod def get_stdout(cls, cmd, input_s=None, stderr_on=True, shell=True, raw=False): ''' @@ -366,7 +425,7 @@ def get_stdout_stderr(cls, cmd, input_s=None, shell=True, raw=False, no_reg=Fals return proc.returncode, stdout_data.strip(), stderr_data.strip() -class LocalOnlyClusterShell(ClusterShell): +class ClusterShellAdaptorForLocalShell(ClusterShell): """A adaptor to wrap a LocalShell as a ClusterShell. Some modules depend on shell and are called both during bootstrap and after bootstrap. Use a LocalShell as their diff --git a/crmsh/ssh_key.py b/crmsh/ssh_key.py index 5014310a86..51668c5f66 100644 --- a/crmsh/ssh_key.py +++ b/crmsh/ssh_key.py @@ -1,10 +1,11 @@ import logging import os import pwd +import re +import subprocess import tempfile import typing -from crmsh import utils from crmsh import sh @@ -16,6 +17,14 @@ def __init__(self, msg: str): super().__init__(msg) +class AgentNotAvailableError(Error): + pass + + +class NoKeysInAgentError(Error): + pass + + class Key: def public_key(self) -> str: raise NotImplementedError @@ -38,6 +47,14 @@ def public_key(self) -> str: return self._public_key +class InMemoryPublicKey(Key): + def __init__(self, content: str): + self.content = content + + def public_key(self) -> str: + return self.content + + class AuthorizedKeyManager: def __init__(self, shell: sh.SSHShell): self._shell = shell @@ -49,9 +66,7 @@ def add(self, host: typing.Optional[str], user: str, key: Key): self._add_remote(host, user, key) def _add_local(self, user: str, key: Key): - public_key = key.public_key() - file = f'~{user}/.ssh/authorized_keys' - cmd = f'''grep "{public_key}" {file} > /dev/null || sed -i '$a {public_key}' {file}''' + cmd = self._add_by_editing_file(user, key) rc, output = self._shell.local_shell.get_rc_and_error(user, cmd) if rc != 0: # unlikely @@ -59,25 +74,134 @@ def _add_local(self, user: str, key: Key): def _add_remote(self, host: str, user: str, key: Key): if self._shell.can_run_as(host, user): - rc, _ = self._shell.get_rc_and_error( - host, user, - f"grep '{key.public_key()}' ~{user}/.ssh/authorized_key > /dev/null", - ) - if rc == 0: - return - if isinstance(key, KeyFile) and key.public_key_file() is not None: - user_info = pwd.getpwnam(user) - if os.stat(key.public_key_file()).st_uid == user_info.pw_uid: - cmd = "ssh-copy-id -f -i '{}' '{}@{}' &> /dev/null".format(key.public_key_file(), user, host) - logger.info("Configuring SSH passwordless with %s@%s", user, host) - result = self._shell.local_shell.su_subprocess_run(self._shell.local_user, cmd, tty=True, preserve_env=['SSH_AUTH_SOCK']) - else: - with tempfile.NamedTemporaryFile('w', encoding='utf-8') as tmp: - os.chown(tmp.fileno(), user_info.pw_uid, user_info.pw_gid) - print(key.public_key(), file=tmp) - cmd = "ssh-copy-id -f -i '{}' '{}@{}' &> /dev/null".format(tmp.name, user, host) + shell_user = user + elif self._shell.can_run_as(host, 'root'): + shell_user = 'root' + else: + shell_user = None + if shell_user is not None: + cmd = self._add_by_editing_file(user, key) + rc, msg = self._shell.get_rc_and_error(host, shell_user, cmd) + if rc != 0: + raise Error(f'Failed configuring SSH passwordless with {user}@{host}: {msg}') + else: + if isinstance(key, KeyFile) and key.public_key_file() is not None: + user_info = pwd.getpwnam(user) + if os.stat(key.public_key_file()).st_uid == user_info.pw_uid: + cmd = "ssh-copy-id -f -i '{}' '{}@{}' &> /dev/null".format(key.public_key_file(), user, host) logger.info("Configuring SSH passwordless with %s@%s", user, host) - result = self._shell.local_shell.su_subprocess_run(self._shell.local_user, cmd, tty=True) - if result.returncode != 0: - raise Error(f'Failed configuring SSH passwordless with {user}@{host}.') - # TODO: error handling + result = self._shell.local_shell.su_subprocess_run( + self._shell.local_user, cmd, + tty=True, preserve_env=['SSH_AUTH_SOCK'], + ) + else: + with tempfile.NamedTemporaryFile('w', encoding='utf-8') as tmp: + os.chown(tmp.fileno(), user_info.pw_uid, user_info.pw_gid) + print(key.public_key(), file=tmp) + cmd = "ssh-copy-id -f -i '{}' '{}@{}' &> /dev/null".format(tmp.name, user, host) + logger.info("Configuring SSH passwordless with %s@%s", user, host) + result = self._shell.local_shell.su_subprocess_run( + self._shell.local_user, cmd, + tty=True, preserve_env=['SSH_AUTH_SOCK'], + ) + if result.returncode != 0: + raise Error(f'Failed configuring SSH passwordless with {user}@{host}.') + else: + key.public_key() + # TODO + assert False + + @classmethod + def _add_by_editing_file(cls, user: str, key: Key): + public_key = key.public_key() + file = f'~{user}/.ssh/authorized_keys' + cmd = f'''if [ ! grep '{public_key}' {file} > /dev/null ]; then + touch {file} + sed -i '$a {public_key}' {file} +fi''' + return cmd + + +class AgentClient: + def __init__(self, socket_path: typing.Optional[str] = None): + if socket_path is None: + if 'SSH_AUTH_SOCK' not in os.environ: + raise AgentNotAvailableError("ssh-agent is not available.") + self.socket_path = None + else: + self.socket_path = socket_path + self.shell = sh.LocalShell(additional_environ={'SSH_AUTH_SOCK': self.socket_path} if self.socket_path else None) + + def list(self) -> typing.List[Key]: + cmd = 'ssh-add -L' + rc, stdout, stderr = self.shell.get_rc_stdout_stderr(None, cmd) + if rc == 1: + raise NoKeysInAgentError(stderr) + elif rc == 2: + raise AgentNotAvailableError(stderr) + elif rc != 0: + raise sh.CommandFailure(cmd, None, None, stderr) + return [InMemoryPublicKey(line) for line in stdout.splitlines()] + + +class KeyFileManager: + KNOWN_KEY_TYPES = ['rsa', 'ed25519', 'ecdsa'] # dsa is not listed here as it is not so secure + KNOWN_PUBLIC_KEY_FILENAME_PATTERN = re.compile('/id_(?:{})\\.pub$'.format('|'.join(KNOWN_KEY_TYPES))) + + def __init__(self, shell: sh.ClusterShell): + self.cluster_shell = sh.ClusterShell(shell.local_shell, shell.user_of_host, raise_ssh_error=True) + + def list_public_key_for_user(self, host: typing.Optional[str], user: str) -> typing.List[str]: + result = self.cluster_shell.subprocess_run_without_input( + host, user, + f'ls ~/.ssh/id_*.pub', + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + return list() + return [ + filename + for filename in sh.Utils.decode_str(result.stdout).splitlines() + if self.KNOWN_PUBLIC_KEY_FILENAME_PATTERN.search(filename) + ] + + def load_public_keys_for_user(self, host: typing.Optional[str], user: str) -> typing.List[InMemoryPublicKey]: + filenames = self.list_public_key_for_user(host, user) + if not filenames: + return list() + cmd = f'cat ~{user}/.ssh/{{{",".join(filenames)}}}' + result = self.cluster_shell.subprocess_run_without_input( + host, user, + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + raise sh.CommandFailure(cmd, host, user, sh.Utils.decode_str(result.stderr).strip()) + return [InMemoryPublicKey(line) for line in sh.Utils.decode_str(result.stdout).splitlines()] + + def ensure_key_pair_exists_for_user(self, host: typing.Optional[str], user: str) -> typing.List[InMemoryPublicKey]: + script = '''if [ ! \\( {condition} \\) ]; then + ssh-keygen -t rsa -f ~/.ssh/id_rsa -q -C 'Cluster internal on {host}' -N '' <> /dev/null +fi +for file in ~/.ssh/id_{{{pattern}}}.pub; do + if [ -f "$file" ]; then cat "$file"; fi +done +'''.format( + condition=' -o '.join([f'-f ~/.ssh/id_{t}' for t in self.KNOWN_KEY_TYPES]), + host=host, + pattern=','.join(self.KNOWN_KEY_TYPES), + ) + result = self.cluster_shell.subprocess_run_without_input( + host, user, + script, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=True, + ) + if result.returncode != 0: + print(script) + print(result.stdout) + raise sh.CommandFailure(f'Script({script[:16]}...) failed. rc = {result.returncode}', host, user, sh.Utils.decode_str(result.stderr).strip()) + return [InMemoryPublicKey(line) for line in sh.Utils.decode_str(result.stdout).splitlines()] diff --git a/crmsh/ui_cluster.py b/crmsh/ui_cluster.py index ddf3d45cb0..bbac224257 100644 --- a/crmsh/ui_cluster.py +++ b/crmsh/ui_cluster.py @@ -63,7 +63,7 @@ def script_args(args): def get_cluster_name(): cluster_name = None - if not ServiceManager(sh.LocalOnlyClusterShell(sh.LocalShell())).service_is_active("corosync.service"): + if not ServiceManager(sh.ClusterShellAdaptorForLocalShell(sh.LocalShell())).service_is_active("corosync.service"): name = corosync.get_values('totem.cluster_name') if name: cluster_name = name[0] @@ -381,6 +381,8 @@ def looks_like_hostnames(lst): help="Skip csync2 initialization (an experimental option)") parser.add_argument("--no-overwrite-sshkey", action="store_true", dest="no_overwrite_sshkey", help='Avoid "/root/.ssh/id_rsa" overwrite if "-y" option is used (False by default; Deprecated)') + parser.add_argument('--use-ssh-agent', action='store_true', + help="Use an existing key from ssh-agent instead of creating new key pairs") network_group = parser.add_argument_group("Network configuration", "Options for configuring the network and messaging layer.") network_group.add_argument("-i", "--interface", dest="nic_list", metavar="IF", action=CustomAppendAction, choices=utils.interface_choice(), default=[], @@ -450,7 +452,7 @@ def looks_like_hostnames(lst): boot_context.ui_context = context boot_context.stage = stage boot_context.args = args - boot_context.cluster_is_running = ServiceManager(sh.LocalOnlyClusterShell(sh.LocalShell())).service_is_active("pacemaker.service") + boot_context.cluster_is_running = ServiceManager(sh.ClusterShellAdaptorForLocalShell(sh.LocalShell())).service_is_active("pacemaker.service") boot_context.type = "init" boot_context.initialize_qdevice() boot_context.validate_option() @@ -497,6 +499,10 @@ def do_join(self, context, *args): "-c", "--cluster-node", metavar="[USER@]HOST", dest="cluster_node", help="User and host to login to an existing cluster node. The host can be specified with either a hostname or an IP.", ) + network_group.add_argument('--use-ssh-agent', action='store_true', + help="Use an existing key from ssh-agent instead of creating new key pairs") + network_group.add_argument('--skip-ssh', action='store_true', + help="Skip ssh initialization (used internally by --use-ssh-agent)") network_group.add_argument("-i", "--interface", dest="nic_list", metavar="IF", action=CustomAppendAction, choices=utils.interface_choice(), default=[], help="Bind to IP address on interface IF. Use -i second time for second interface") options, args = parse_options(parser, args) @@ -563,7 +569,7 @@ def do_rename(self, context, new_name): ''' Rename the cluster. ''' - if not ServiceManager(sh.LocalOnlyClusterShell(sh.LocalShell())).service_is_active("corosync.service"): + if not ServiceManager(sh.ClusterShellAdaptorForLocalShell(sh.LocalShell())).service_is_active("corosync.service"): context.fatal_error("Can't rename cluster when cluster service is stopped") old_name = cib_factory.get_property('cluster-name') if old_name and new_name == old_name: diff --git a/crmsh/ui_corosync.py b/crmsh/ui_corosync.py index ae0c8da3e8..804023ad51 100644 --- a/crmsh/ui_corosync.py +++ b/crmsh/ui_corosync.py @@ -62,7 +62,7 @@ def do_status(self, context, status_type="ring"): ''' Quick cluster health status. Corosync status or QNetd status ''' - if not ServiceManager(sh.LocalOnlyClusterShell(sh.LocalShell())).service_is_active("corosync.service"): + if not ServiceManager(sh.ClusterShellAdaptorForLocalShell(sh.LocalShell())).service_is_active("corosync.service"): logger.error("corosync.service is not running!") return False diff --git a/crmsh/utils.py b/crmsh/utils.py index cea3d0525c..e87dc78294 100644 --- a/crmsh/utils.py +++ b/crmsh/utils.py @@ -309,6 +309,18 @@ def ask_for_choice(question: str, choices: typing.List[str], default: int = None return i +def ask_for_selection( + candidates: typing.Sequence[str], + question: str, + default: typing.Optional[int] = None, + yes_to_all: bool = False, +) -> int: + """Print a list of candidates, and ask the user to select one of them.""" + for i, item in enumerate(candidates): + logger.info('%s: %s', i+1, item) + return ask_for_choice(question, [str(i+1) for i in range(len(candidates))], default, yes_to_all=yes_to_all) - 1 + + # holds part of line before \ split # for a multi-line input _LINE_BUFFER = ''