From 0032a44d2d6991b0344a538e25d0d58745f0112b Mon Sep 17 00:00:00 2001 From: jiangweidong <1053570670@qq.com> Date: Thu, 24 Oct 2024 17:08:09 +0800 Subject: [PATCH] feat: Custom change password supports configuration of interactive items --- .../change_secret/custom/ssh/main.yml | 6 +- .../change_secret/custom/ssh/manifest.yml | 95 +++++- .../verify_account/custom/ssh/main.yml | 1 + .../automations/ping/custom/ssh/main.yml | 1 + .../commands/services/services/celery_base.py | 2 +- apps/libs/ansible/modules/custom_command.py | 42 ++- apps/libs/ansible/modules/ssh_ping.py | 13 +- .../{custom_common.py => paramiko_client.py} | 280 +++++++++++------- 8 files changed, 302 insertions(+), 138 deletions(-) rename apps/libs/ansible/modules_utils/{custom_common.py => paramiko_client.py} (51%) diff --git a/apps/accounts/automations/change_secret/custom/ssh/main.yml b/apps/accounts/automations/change_secret/custom/ssh/main.yml index 54707a7d5..bce6a088f 100644 --- a/apps/accounts/automations/change_secret/custom/ssh/main.yml +++ b/apps/accounts/automations/change_secret/custom/ssh/main.yml @@ -20,6 +20,7 @@ become_private_key_path: "{{ jms_custom_become_private_key_path | default(None) }}" old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}" + recv_timeout: "{{ params.recv_timeout | default(30) }}" register: ping_info delegate_to: localhost @@ -39,7 +40,9 @@ name: "{{ account.username }}" password: "{{ account.secret }}" commands: "{{ params.commands }}" - first_conn_delay_time: "{{ first_conn_delay_time | default(0.5) }}" + answers: "{{ params.answers }}" + recv_timeout: "{{ params.recv_timeout | default(5) }}" + prompt: "{{ params.prompt | default('.*') }}" ignore_errors: true when: ping_info is succeeded register: change_info @@ -58,4 +61,5 @@ become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}" old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}" + recv_timeout: "{{ params.recv_timeout | default(30) }}" delegate_to: localhost diff --git a/apps/accounts/automations/change_secret/custom/ssh/manifest.yml b/apps/accounts/automations/change_secret/custom/ssh/manifest.yml index 1b0a38a00..01628c272 100644 --- a/apps/accounts/automations/change_secret/custom/ssh/manifest.yml +++ b/apps/accounts/automations/change_secret/custom/ssh/manifest.yml @@ -10,10 +10,25 @@ protocol: ssh priority: 50 params: - name: commands - type: list + type: text label: "{{ 'Params commands label' | trans }}" - default: [ '' ] + default: '' help_text: "{{ 'Params commands help text' | trans }}" + - name: recv_timeout + type: int + label: "{{ 'Params recv_timeout label' | trans }}" + default: 30 + help_text: "{{ 'Params recv_timeout help text' | trans }}" + - name: prompt + type: str + label: "{{ 'Params prompt label' | trans }}" + default: '.*' + help_text: "{{ 'Params prompt help text' | trans }}" + - name: answers + type: text + label: "{{ 'Params answer label' | trans }}" + default: '.*' + help_text: "{{ 'Params answer help text' | trans }}" i18n: SSH account change secret: @@ -22,11 +37,81 @@ i18n: en: 'Custom password change by SSH command line' Params commands help text: - zh: '自定义命令中如需包含账号的 账号、密码、SSH 连接的用户密码 字段,
请使用 {username}、{password}、{login_password}格式,执行任务时会进行替换 。
比如针对 Cisco 主机进行改密,一般需要配置五条命令:
1. enable
2. {login_password}
3. configure terminal
4. username {username} privilege 0 password {password}
5. end' - ja: 'カスタム コマンドに SSH 接続用のアカウント番号、パスワード、ユーザー パスワード フィールドを含める必要がある場合は、
{ユーザー名}、{パスワード}、{login_password& を使用してください。 # 125; 形式。タスクの実行時に置き換えられます。
たとえば、Cisco ホストのパスワードを変更するには、通常、次の 5 つのコマンドを設定する必要があります:
1.enable
2.{login_password}
3 .ターミナルの設定
4. ユーザー名 {ユーザー名} 権限 0 パスワード {パスワード}
5. 終了' - en: 'If the custom command needs to include the account number, password, and user password field for SSH connection,
Please use {username}, {password}, {login_password&# 125; format, which will be replaced when executing the task.
For example, to change the password of a Cisco host, you generally need to configure five commands:
1. enable
2. {login_password}
3. configure terminal
4. username {username} privilege 0 password {password}
5. end' + zh: | + 请将命令中的指定位置改成特殊符号
+ 1. 改密账号 -> {username}
+ 2. 改密密码 -> {password}
+ 3. 登录用户密码 -> {login_password}
+ 多条命令使用换行分割,执行任务时系统会根据特殊符号替换真实数据。
+ 比如针对 Cisco 主机进行改密,一般需要配置五条命令:
+ enable
+ {login_password}
+ configure terminal
+ username {username} privilege 0 password {password}
+ end
+ ja: | + コマンド内の指定された位置を特殊記号に変更してください。
+ 新しいパスワード(アカウント変更) -> {username}
+ 新しいパスワード(パスワード変更) -> {password}
+ ログインユーザーパスワード -> {login_password}
+ 複数のコマンドは改行で区切り、タスクを実行するときにシステムは特殊記号を使用して実際のデータを置き換えます。
+ 例えば、Cisco機器のパスワードを変更する場合、一般的には5つのコマンドを設定する必要があります:
+ enable
+ {login_password}
+ configure terminal
+ username {username} privilege 0 password {password}
+ end
+ en: | + Please change the specified positions in the command to special symbols.
+ Change password account -> {username}
+ Change password -> {password}
+ Login user password -> {login_password}
+ Multiple commands are separated by new lines, and when executing tasks,
+ the system will replace the special symbols with real data.
+ For example, to change the password for a Cisco device, you generally need to configure five commands:
+ enable
+ {login_password}
+ configure terminal
+ username {username} privilege 0 password {password}
+ end
Params commands label: zh: '自定义命令' ja: 'カスタムコマンド' en: 'Custom command' + + Params recv_timeout label: + zh: '超时时间(秒)' + ja: 'タイムアウト(秒)' + en: 'Timeout (Seconds)' + + Params recv_timeout help text: + zh: '等待命令结果返回的超时时间' + ja: 'コマンドの結果を待つタイムアウト時間' + en: 'The timeout for waiting for the command result to return' + + Params prompt label: + zh: '提示符' + ja: 'ヒント' + en: 'Prompt' + + Params prompt help text: + zh: '终端连接后显示的提示符信息(正则表达式)' + ja: 'ターミナル接続後に表示されるプロンプト情報(正規表現)' + en: 'Prompt information displayed after terminal connection (Regular expression)' + + Params answer label: + zh: '命令结果' + ja: 'コマンド結果' + en: 'Command result' + + Params answer help text: + zh: | + 根据结果匹配度决定是否执行下一条命令,输入框的内容和上方 “自定义命令” 内容按行一一对应(正则表达式) + ja: | + 結果の一致度に基づいて次のコマンドを実行するかどうかを決定します。 + 入力欄の内容は、上の「カスタムコマンド」の内容と行ごとに対応しています(せいきひょうげん) + en: | + Decide whether to execute the next command based on the result match. + The input content corresponds line by line with the content + of the `Custom command` above. (Regular expression) diff --git a/apps/accounts/automations/verify_account/custom/ssh/main.yml b/apps/accounts/automations/verify_account/custom/ssh/main.yml index 31178666f..831c1c783 100644 --- a/apps/accounts/automations/verify_account/custom/ssh/main.yml +++ b/apps/accounts/automations/verify_account/custom/ssh/main.yml @@ -21,3 +21,4 @@ become_private_key_path: "{{ account.become.ansible_ssh_private_key_file | default(None) }}" old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}" + recv_timeout: "{{ params.recv_timeout | default(30) }}" diff --git a/apps/assets/automations/ping/custom/ssh/main.yml b/apps/assets/automations/ping/custom/ssh/main.yml index 89b92bcaa..de651cac3 100644 --- a/apps/assets/automations/ping/custom/ssh/main.yml +++ b/apps/assets/automations/ping/custom/ssh/main.yml @@ -21,4 +21,5 @@ become_private_key_path: "{{ jms_custom_become_private_key_path | default(None) }}" old_ssh_version: "{{ jms_asset.old_ssh_version | default(False) }}" gateway_args: "{{ jms_asset.ansible_ssh_common_args | default(None) }}" + recv_timeout: "{{ params.recv_timeout | default(30) }}" diff --git a/apps/common/management/commands/services/services/celery_base.py b/apps/common/management/commands/services/services/celery_base.py index edfd7bb0e..53b2ebe2f 100644 --- a/apps/common/management/commands/services/services/celery_base.py +++ b/apps/common/management/commands/services/services/celery_base.py @@ -14,7 +14,7 @@ class CeleryBaseService(BaseService): print('\n- Start Celery as Distributed Task Queue: {}'.format(self.queue.capitalize())) ansible_config_path = os.path.join(settings.APPS_DIR, 'libs', 'ansible', 'ansible.cfg') ansible_modules_path = os.path.join(settings.APPS_DIR, 'libs', 'ansible', 'modules') - os.environ.setdefault('LC_ALL', 'C.UTF-8') + os.environ.setdefault('LC_ALL', 'en_US.UTF-8') os.environ.setdefault('PYTHONOPTIMIZE', '1') os.environ.setdefault('ANSIBLE_FORCE_COLOR', 'True') os.environ.setdefault('ANSIBLE_CONFIG', ansible_config_path) diff --git a/apps/libs/ansible/modules/custom_command.py b/apps/libs/ansible/modules/custom_command.py index 55d0537ab..02b3357e5 100644 --- a/apps/libs/ansible/modules/custom_command.py +++ b/apps/libs/ansible/modules/custom_command.py @@ -33,13 +33,8 @@ options: commands: description: - Custom change password commands. - type: list + type: str required: true - first_conn_delay_time: - description: - - Delay for executing the command after SSH connection(unit: s) - type: float - required: false ''' EXAMPLES = ''' @@ -51,7 +46,7 @@ EXAMPLES = ''' login_password: "123456" name: "jms" password: "123456" - commands: ['passwd {username}', '{password}', '{password}'] + commands: 'passwd {username}\n{password}\n{password}'] ''' RETURN = ''' @@ -63,21 +58,23 @@ name: from ansible.module_utils.basic import AnsibleModule -from libs.ansible.modules_utils.custom_common import ( +from libs.ansible.modules_utils.paramiko_client import ( SSHClient, common_argument_spec ) -def get_commands(module): +def get_commands_and_answers(module) -> (list, list): username = module.params['name'] password = module.params['password'] - commands = module.params['commands'] or [] + commands = module.params['commands'] or '' + answers = module.params['answers'] or '' login_password = module.params['login_password'] - for index, command in enumerate(commands): - commands[index] = command.format( - username=username, password=password, login_password=login_password - ) - return commands + commands = commands.format( + username=username, password=password, login_password=login_password + ) + + return commands.split('\n'), answers.split('\n') + # ========================================= # Module execution. @@ -89,21 +86,20 @@ def main(): argument_spec.update( name=dict(required=True, aliases=['user']), password=dict(aliases=['pass'], no_log=True), - commands=dict(type='list', required=False), ) module = AnsibleModule(argument_spec=argument_spec) - ssh_client = SSHClient(module) - commands = get_commands(module) + commands, answers = get_commands_and_answers(module) if not commands: module.fail_json( msg='No command found, please go to the platform details to add' ) - output, err_msg = ssh_client.execute(commands) - if err_msg: - module.fail_json( - msg='There was a problem executing the command: %s' % err_msg - ) + with SSHClient(module) as client: + output, err_msg = client.execute(commands, answers) + if err_msg: + module.fail_json( + msg='There was a problem executing the command: %s' % err_msg + ) user = module.params['name'] module.exit_json(changed=True, user=user) diff --git a/apps/libs/ansible/modules/ssh_ping.py b/apps/libs/ansible/modules/ssh_ping.py index f5b08c63b..2fd552fe4 100644 --- a/apps/libs/ansible/modules/ssh_ping.py +++ b/apps/libs/ansible/modules/ssh_ping.py @@ -34,7 +34,7 @@ is_available: from ansible.module_utils.basic import AnsibleModule -from libs.ansible.modules_utils.custom_common import ( +from libs.ansible.modules_utils.paramiko_client import ( SSHClient, common_argument_spec ) @@ -49,14 +49,11 @@ def main(): module = AnsibleModule(argument_spec=options, supports_check_mode=True,) result = { - 'changed': False, 'is_available': True + 'changed': False, 'is_available': False } - client = SSHClient(module) - err = client.connect() - if err: - module.fail_json(msg='Unable to connect to asset: %s' % err) - result['is_available'] = False - + with SSHClient(module) as client: + client.connect() + result['is_available'] = True return module.exit_json(**result) diff --git a/apps/libs/ansible/modules_utils/custom_common.py b/apps/libs/ansible/modules_utils/paramiko_client.py similarity index 51% rename from apps/libs/ansible/modules_utils/custom_common.py rename to apps/libs/ansible/modules_utils/paramiko_client.py index c70fba275..e762dcd99 100644 --- a/apps/libs/ansible/modules_utils/custom_common.py +++ b/apps/libs/ansible/modules_utils/paramiko_client.py @@ -1,10 +1,105 @@ import re +import signal import time import paramiko + +from functools import wraps + from sshtunnel import SSHTunnelForwarder +DEFAULT_RE = '.*' +SU_PROMPT_LOCALIZATIONS = [ + 'Password', + '암호', + 'パスワード', + 'Adgangskode', + 'Contraseña', + 'Contrasenya', + 'Hasło', + 'Heslo', + 'Jelszó', + 'Lösenord', + 'Mật khẩu', + 'Mot de passe', + 'Parola', + 'Parool', + 'Pasahitza', + 'Passord', + 'Passwort', + 'Salasana', + 'Sandi', + 'Senha', + 'Wachtwoord', + 'ססמה', + 'Лозинка', + 'Парола', + 'Пароль', + 'गुप्तशब्द', + 'शब्दकूट', + 'సంకేతపదము', + 'හස්පදය', + '密码', + '密碼', + '口令', + ] + + +def get_become_prompt_re(): + b_password_string = "|".join((r'(\w+\'s )?' + p) for p in SU_PROMPT_LOCALIZATIONS) + b_password_string = b_password_string + ' ?(:|:) ?' + return re.compile(b_password_string, flags=re.IGNORECASE) + + +become_prompt_re = get_become_prompt_re() + + +def common_argument_spec(): + options = dict( + login_host=dict(type='str', required=False, default='localhost'), + login_port=dict(type='int', required=False, default=22), + login_user=dict(type='str', required=False, default='root'), + login_password=dict(type='str', required=False, no_log=True), + login_secret_type=dict(type='str', required=False, default='password'), + login_private_key_path=dict(type='str', required=False, no_log=True), + gateway_args=dict(type='str', required=False, default=''), + recv_timeout=dict(type='int', required=False, default=30), + prompt=dict(type='str', required=False, default='.*'), + answers=dict(type='str', required=False, default='.*'), + commands=dict(type='str', required=False), + + become=dict(type='bool', default=False, required=False), + become_method=dict(type='str', required=False), + become_user=dict(type='str', required=False), + become_password=dict(type='str', required=False, no_log=True), + become_private_key_path=dict(type='str', required=False, no_log=True), + + old_ssh_version=dict(type='bool', default=False, required=False), + ) + return options + + +def raise_timeout(name=''): + def decorate(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + def handler(signum, frame): + raise TimeoutError(f'{name} timed out, wait {timeout}s') + + try: + timeout = getattr(self, 'timeout', 0) + if timeout > 0: + signal.signal(signal.SIGALRM, handler) + signal.alarm(timeout) + return func(self, *args, **kwargs) + except Exception as error: + signal.alarm(0) + raise error + return wrapper + return decorate + + class OldSSHTransport(paramiko.transport.Transport): _preferred_pubkeys = ( "ssh-ed25519", @@ -18,41 +113,24 @@ class OldSSHTransport(paramiko.transport.Transport): ) -def common_argument_spec(): - options = dict( - login_host=dict(type='str', required=False, default='localhost'), - login_port=dict(type='int', required=False, default=22), - login_user=dict(type='str', required=False, default='root'), - login_password=dict(type='str', required=False, no_log=True), - login_secret_type=dict(type='str', required=False, default='password'), - login_private_key_path=dict(type='str', required=False, no_log=True), - first_conn_delay_time=dict(type='float', required=False, default=0.5), - gateway_args=dict(type='str', required=False, default=''), - - become=dict(type='bool', default=False, required=False), - become_method=dict(type='str', required=False), - become_user=dict(type='str', required=False), - become_password=dict(type='str', required=False, no_log=True), - become_private_key_path=dict(type='str', required=False, no_log=True), - - old_ssh_version=dict(type='bool', default=False, required=False), - ) - return options - - class SSHClient: - TIMEOUT = 20 - SLEEP_INTERVAL = 2 - COMPLETE_FLAG = 'complete' - def __init__(self, module): self.module = module - self.channel = None - self.is_connect = False self.gateway_server = None self.client = paramiko.SSHClient() self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) self.connect_params = self.get_connect_params() + self._channel = None + self.buffer_size = 1024 + self.connect_params = self.get_connect_params() + self.prompt = self.module.params['prompt'] + self.timeout = self.module.params['recv_timeout'] + + @property + def channel(self): + if self._channel is None: + self.connect() + return self._channel def get_connect_params(self): params = { @@ -73,22 +151,7 @@ class SSHClient: params['transport_factory'] = OldSSHTransport return params - def _get_channel(self): - self.channel = self.client.invoke_shell() - # 读取首次登陆终端返回的消息 - self.channel.recv(2048) - # 网络设备一般登录有延迟,等终端有返回后再执行命令 - delay_time = self.module.params['first_conn_delay_time'] - time.sleep(delay_time) - - @staticmethod - def _is_match_user(user, content): - # 正常命令切割后是[命令,用户名,交互前缀] - content_list = content.split() if len(content.split()) >= 3 else None - return content_list and user in content_list - def switch_user(self): - self._get_channel() if not self.module.params['become']: return method = self.module.params['become_method'] @@ -102,22 +165,72 @@ class SSHClient: else: self.module.fail_json(msg='Become method %s not support' % method) return - commands = [f'{switch_method} {username}', password] - su_output, err_msg = self.execute(commands) - if err_msg: - return err_msg - i_output, err_msg = self.execute( - [f'whoami && echo "{self.COMPLETE_FLAG}"'], - validate_output=True - ) - if err_msg: - return err_msg - if self._is_match_user(username, i_output): - err_msg = '' - else: - err_msg = su_output - return err_msg + __, e_msg = self.execute( + [f'{switch_method} {username}', password, 'whoami'], + [become_prompt_re, DEFAULT_RE, username] + ) + if e_msg: + self.module.fail_json(msg='Become user %s failed.' % username) + + def connect(self): + self.before_runner_start() + try: + self.client.connect(**self.connect_params) + self._channel = self.client.invoke_shell() + self._get_match_recv() + self.switch_user() + except Exception as error: + self.module.fail_json(msg=str(error)) + + @staticmethod + def _fit_answers(commands, answers): + if answers is None or not isinstance(answers, list): + answers = [DEFAULT_RE] * len(commands) + elif len(answers) < len(commands): + answers += [DEFAULT_RE] * (len(commands) - len(answers)) + return answers + + @staticmethod + def __match(re_, content): + re_pattern = re_ + if isinstance(re_, str): + re_pattern = re.compile(re_, re.DOTALL | re.IGNORECASE) + elif not isinstance(re_pattern, re.Pattern): + raise ValueError(f'{re_} should be a regular expression') + return bool(re_pattern.search(content)) + + @raise_timeout('Recv message') + def _get_match_recv(self, answer_reg=DEFAULT_RE): + last_output, output = '', '' + while True: + if self.channel.recv_ready(): + recv = self.channel.recv(self.buffer_size).decode() + output += recv + if output and last_output != output: + fin_reg = self.prompt if answer_reg == DEFAULT_RE else answer_reg + if self.__match(fin_reg, output): + break + last_output = output + time.sleep(0.01) + return output + + @raise_timeout('Wait send message') + def _check_send(self): + while not self.channel.send_ready(): + time.sleep(0.01) + + def execute(self, commands, answers=None): + all_output, error_msg = '', '' + try: + answers = self._fit_answers(commands, answers) + for index, command in enumerate(commands): + self._check_send() + self.channel.send(command + '\n') + all_output += f'{self._get_match_recv(answers[index])}\n' + except Exception as e: + error_msg = str(e) + return all_output, error_msg def local_gateway_prepare(self): gateway_args = self.module.params['gateway_args'] or '' @@ -160,48 +273,15 @@ class SSHClient: def after_runner_end(self): self.local_gateway_clean() - def connect(self): + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): try: - self.before_runner_start() - self.client.connect(**self.connect_params) - self.is_connect = True - err_msg = self.switch_user() self.after_runner_end() - except Exception as err: - err_msg = str(err) - return err_msg - - def _get_recv(self, size=1024, encoding='utf-8'): - output = self.channel.recv(size).decode(encoding) - return output - - def execute(self, commands, validate_output=False): - if not self.is_connect: - self.connect() - output, error_msg = '', '' - try: - for command in commands: - self.channel.send(command + '\n') - if not validate_output: - time.sleep(self.SLEEP_INTERVAL) - output += self._get_recv() - continue - start_time = time.time() - while self.COMPLETE_FLAG not in output: - if time.time() - start_time > self.TIMEOUT: - error_msg = output - print("切换用户操作超时,跳出循环。") - break - time.sleep(self.SLEEP_INTERVAL) - received_output = self._get_recv().replace(f'"{self.COMPLETE_FLAG}"', '') - output += received_output - except Exception as e: - error_msg = str(e) - return output, error_msg - - def __del__(self): - try: - self.channel.close() - self.client.close() - except Exception: + if self.channel: + self.channel.close() + if self.client: + self.client.close() + except Exception: # noqa pass