Merge branch 'pam' of github.com:jumpserver/jumpserver into pam

pull/14871/head
ibuler 2025-02-10 18:29:22 +08:00
commit 53fa5a2320
13 changed files with 652 additions and 498 deletions

View File

@ -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,10 @@
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(30) }}"
delay_time: "{{ params.delay_time | default(2) }}"
prompt: "{{ params.prompt | default('.*') }}"
ignore_errors: true
when: ping_info is succeeded and check_conn_after_change
register: change_info
@ -58,5 +62,6 @@
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
when: check_conn_after_change

View File

@ -10,10 +10,30 @@ 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: delay_time
type: int
label: "{{ 'Params delay_time label' | trans }}"
default: 2
help_text: "{{ 'Params delay_time 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 +42,91 @@ i18n:
en: 'Custom password change by SSH command line'
Params commands help text:
zh: '自定义命令中如需包含账号的 账号、密码、SSH 连接的用户密码 字段,<br />请使用 &#123;username&#125;、&#123;password&#125;、&#123;login_password&#125;格式,执行任务时会进行替换 。<br />比如针对 Cisco 主机进行改密,一般需要配置五条命令:<br />1. enable<br />2. &#123;login_password&#125;<br />3. configure terminal<br />4. username &#123;username&#125; privilege 0 password &#123;password&#125; <br />5. end'
ja: 'カスタム コマンドに SSH 接続用のアカウント番号、パスワード、ユーザー パスワード フィールドを含める必要がある場合は、<br />&#123;ユーザー名&#125;、&#123;パスワード&#125;、&#123;login_password& を使用してください。 # 125; 形式。タスクの実行時に置き換えられます。 <br />たとえば、Cisco ホストのパスワードを変更するには、通常、次の 5 つのコマンドを設定する必要があります:<br />1.enable<br />2.&#123;login_password&#125;<br />3 .ターミナルの設定<br / >4. ユーザー名 &#123;ユーザー名&#125; 権限 0 パスワード &#123;パスワード&#125; <br />5. 終了'
en: 'If the custom command needs to include the account number, password, and user password field for SSH connection,<br />Please use &#123;username&#125;, &#123;password&#125;, &#123;login_password&# 125; format, which will be replaced when executing the task. <br />For example, to change the password of a Cisco host, you generally need to configure five commands:<br />1. enable<br />2. &#123;login_password&#125;<br />3. configure terminal<br / >4. username &#123;username&#125; privilege 0 password &#123;password&#125; <br />5. end'
zh: |
请将命令中的指定位置改成特殊符号 <br />
1. 改密账号 -> {username} <br />
2. 改密密码 -> {password} <br />
3. 登录用户密码 -> {login_password} <br />
<strong>多条命令使用换行分割,</strong>执行任务时系统会根据特殊符号替换真实数据。<br />
比如针对 Cisco 主机进行改密,一般需要配置五条命令:<br />
enable <br />
{login_password} <br />
configure terminal <br />
username {username} privilege 0 password {password} <br />
end <br />
ja: |
コマンド内の指定された位置を特殊記号に変更してください。<br />
新しいパスワード(アカウント変更) -> {username} <br />
新しいパスワード(パスワード変更) -> {password} <br />
ログインユーザーパスワード -> {login_password} <br />
<strong>複数のコマンドは改行で区切り、</strong>タスクを実行するときにシステムは特殊記号を使用して実際のデータを置き換えます。<br />
例えば、Cisco機器のパスワードを変更する場合、一般的には5つのコマンドを設定する必要があります<br />
enable <br />
{login_password} <br />
configure terminal <br />
username {username} privilege 0 password {password} <br />
end <br />
en: |
Please change the specified positions in the command to special symbols. <br />
Change password account -> {username} <br />
Change password -> {password} <br />
Login user password -> {login_password} <br />
<strong>Multiple commands are separated by new lines,</strong> and when executing tasks, <br />
the system will replace the special symbols with real data. <br />
For example, to change the password for a Cisco device, you generally need to configure five commands: <br />
enable <br />
{login_password} <br />
configure terminal <br />
username {username} privilege 0 password {password} <br />
end <br />
Params commands label:
zh: '自定义命令'
ja: 'カスタムコマンド'
en: 'Custom command'
Params recv_timeout label:
zh: '超时时间'
ja: 'タイムアウト'
en: 'Timeout'
Params recv_timeout help text:
zh: '等待命令结果返回的超时时间(秒)'
ja: 'コマンドの結果を待つタイムアウト時間(秒)'
en: 'The timeout for waiting for the command result to return (Seconds)'
Params delay_time label:
zh: '延迟发送时间'
ja: '遅延送信時間'
en: 'Delayed send time'
Params delay_time help text:
zh: '每条命令延迟发送的时间间隔(秒)'
ja: '各コマンド送信の遅延間隔(秒)'
en: 'Time interval for each command delay in sending (Seconds)'
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)

View File

@ -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) }}"

View File

@ -1,133 +1,121 @@
# JumpServer PAM 客户端
该包提供了一个 Go 客户端,用于与 JumpServer PAM API 交互,以检索各种资产的密码。它简化了发送请求和处理响应的过程。
## 功能
- 在发送请求之前验证参数。
- 支持基于资产和账户的密码检索。
- 使用 HMAC-SHA256 签名进行身份验证,方便与 JumpServer PAM API 集成。
## 使用说明
1. **下载 Go 代码文件**
将代码文件下载到您的项目目录中。
2. **导入包**
在您的 Go 文件中导入该包,您即可直接使用其功能。
## 需求
- `Go 1.16+`
- `github.com/google/uuid`
- `gopkg.in/twindagger/httpsig.v1`
## 使用方法
### 初始化
要使用 JumpServer PAM 客户端,通过提供所需的 `endpoint`、`keyID` 和 `keySecret` 创建一个实例。
```go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"your_module_path/jms_pam"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
)
type APIClient struct {
Client *http.Client
APIURL string
KeyID string
KeySecret string
OrgID string
}
func NewAPIClient() *APIClient {
return &APIClient{
Client: &http.Client{},
APIURL: getEnv("API_URL", "http://127.0.0.1:8080"),
KeyID: getEnv("API_KEY_ID", "72b0b0aa-ad82-4182-a631-ae4865e8ae0e"),
KeySecret: getEnv("API_KEY_SECRET", "6fuSO7P1m4cj8SSlgaYdblOjNAmnxDVD7tr8"),
OrgID: getEnv("ORG_ID", "00000000-0000-0000-0000-000000000002"),
}
}
func getEnv(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}
func (c *APIClient) GetAccountSecret(asset, account string) (map[string]interface{}, error) {
u, err := url.Parse(c.APIURL)
if err != nil {
return nil, fmt.Errorf("failed to parse API URL: %v", err)
}
u.Path = "/api/v1/accounts/integration-applications/account-secret/"
q := u.Query()
q.Add("asset", asset)
q.Add("account", account)
u.RawQuery = q.Encode()
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %v", err)
}
date := time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT")
req.Header.Set("Accept", "application/json")
req.Header.Set("X-JMS-ORG", c.OrgID)
req.Header.Set("Date", date)
req.Header.Set("X-Source", "jms-pam")
headersList := []string{"(request-target)", "accept", "date", "x-jms-org"}
var signatureParts []string
for _, h := range headersList {
var value string
if h == "(request-target)" {
value = strings.ToLower(req.Method) + " " + req.URL.RequestURI()
} else {
canonicalKey := http.CanonicalHeaderKey(h)
value = req.Header.Get(canonicalKey)
}
signatureParts = append(signatureParts, fmt.Sprintf("%s: %s", h, value))
}
signatureString := strings.Join(signatureParts, "\n")
mac := hmac.New(sha256.New, []byte(c.KeySecret))
mac.Write([]byte(signatureString))
signatureB64 := base64.StdEncoding.EncodeToString(mac.Sum(nil))
headersJoined := strings.Join(headersList, " ")
authHeader := fmt.Sprintf(
`Signature keyId="%s",algorithm="hmac-sha256",headers="%s",signature="%s"`,
c.KeyID,
headersJoined,
signatureB64,
)
req.Header.Set("Authorization", authHeader)
resp, err := c.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned non-200 status: %d", resp.StatusCode)
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %v", err)
}
return result, nil
}
func main() {
client := jms_pam.NewJumpServerPAM(
"http://127.0.0.1", // 替换为您的 JumpServer 端点
"your-key-id", // 替换为您的实际 Key ID
"your-key-secret", // 替换为您的实际 Key Secret
"", // 留空以使用默认的组织 ID
)
}
```
### 创建密码请求
您可以通过指定资产或账户信息来创建请求。
```go
request, err := jms_pam.NewSecretRequest("Linux", "", "root", "")
client := NewAPIClient()
result, err := client.GetAccountSecret("ubuntu_docker", "root")
if err != nil {
fmt.Println("创建请求时出错:", err)
return
log.Fatalf("Error: %v", err)
}
fmt.Printf("Result: %+v\n", result)
}
```
### 发送请求
使用客户端的 `Send` 方法发送请求。
```go
secretObj, err := client.Send(request)
if err != nil {
fmt.Println("发送请求时出错:", err)
return
}
```
### 处理响应
检查密码是否成功检索,并相应地处理响应。
```go
if secretObj.Valid {
fmt.Println("密码:", secretObj.Secret)
} else {
fmt.Println("获取密码失败:", string(secretObj.Desc))
}
```
### 完整示例
以下是如何使用该客户端的完整示例:
```go
package main
import (
"fmt"
"your_module_path/jms_pam"
)
func main() {
client := jms_pam.NewJumpServerPAM(
"http://127.0.0.1",
"your-key-id",
"your-key-secret",
"",
)
request, err := jms_pam.NewSecretRequest("Linux", "", "root", "")
if err != nil {
fmt.Println("创建请求时出错:", err)
return
}
secretObj, err := client.Send(request)
if err != nil {
fmt.Println("发送请求时出错:", err)
return
}
if secretObj.Valid {
fmt.Println("密码:", secretObj.Secret)
} else {
fmt.Println("获取密码失败:", string(secretObj.Desc))
}
}
```
## 错误处理
该库会在创建 `SecretRequest` 时返回无效参数的错误。这包括对有效 UUID 的检查以及确保提供了必需的参数。
## 贡献
欢迎贡献!如有任何增强或错误修复,请提出问题或提交拉取请求。

View File

@ -1,96 +1,45 @@
# JumpServer PAM 客户端
该包提供了一个 Python 客户端,用于与 JumpServer PAM API 交互,以检索各种资产的密码。它简化了发送请求和处理响应的过程。
## 特性
- 在发送请求之前验证参数。
- 支持基于资产和账户的密码检索。
- 通过 HTTP 签名轻松集成 JumpServer PAM API。
## 安装
您可以通过 pip 安装该包:
```bash
pip install jms_pam-0.0.1-py3-none-any.whl
```
## 需求
- `Python 3.6+`
- `requests`
- `httpsig`
## 使用方法
### 初始化
要使用 JumpServer PAM 客户端,通过提供所需的 `endpoint`、`key_id` 和 `key_secret` 创建一个实例。
```python
from jms_pam import JumpServerPAM, SecretRequest
import requests
import os
from datetime import datetime
from httpsig.requests_auth import HTTPSignatureAuth
client = JumpServerPAM(
endpoint='http://127.0.0.1',
key_id='your-key-id',
key_secret='your-key-secret'
)
```
API_URL = os.getenv("API_URL", "http://127.0.0.1:8080")
KEY_ID = os.getenv("API_KEY_ID", "72b0b0aa-ad82-4182-a631-ae4865e8ae0e")
KEY_SECRET = os.getenv("API_KEY_SECRET", "6fuSO7P1m4cj8SSlgaYdblOjNAmnxDVD7tr8")
ORG_ID = os.getenv("ORG_ID", "00000000-0000-0000-0000-000000000002")
### 创建密码请求
您可以通过指定资产或账户信息来创建一个密码请求。
```python
request = SecretRequest(asset='Linux', account='root')
```
### 发送请求
使用客户端的 `send` 方法发送请求。
```python
secret_obj = client.send(request)
```
### 处理响应
检查密码是否成功检索,并相应地处理响应。
```python
if secret_obj.valid:
print('密码: %s' % secret_obj.secret)
else:
print('获取密码失败: %s' % secret_obj.desc)
```
### 完整示例
以下是如何使用该客户端的完整示例:
```python
from jms_pam import JumpServerPAM, SecretRequest
client = JumpServerPAM(
endpoint='http://127.0.0.1',
key_id='your-key-id',
key_secret='your-key-secret'
class APIClient:
def __init__(self):
self.session = requests.Session()
self.auth = HTTPSignatureAuth(
key_id=KEY_ID, secret=KEY_SECRET,
algorithm='hmac-sha256', headers=['(request-target)', 'accept', 'date', 'x-jms-org']
)
request = SecretRequest(asset='Linux', account='root')
secret_obj = client.send(request)
def get_account_secret(self, asset, account):
url = f"{API_URL}/api/v1/accounts/integration-applications/account-secret/"
headers = {
'Accept': 'application/json',
'X-JMS-ORG': ORG_ID,
'Date': datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT'),
'X-Source': 'jms-pam'
}
params = {"asset": asset, "account": account}
if secret_obj.valid:
print('密码: %s' % secret_obj.secret)
else:
print('获取密码失败: %s' % secret_obj.desc)
try:
response = self.session.get(url, auth=self.auth, headers=headers, params=params, timeout=10)
response.raise_for_status()
return response.json()
except requests.RequestException as e:
print(f"API 请求失败: {e}")
return None
# 示例调用
if __name__ == "__main__":
client = APIClient()
result = client.get_account_secret(asset="ubuntu_docker", account="root")
print(result)
```
## 错误处理
如果提供的参数不符合验证要求,库会引发 `RequestParamsError`。这包括对有效 UUID 的检查和参数之间的相互依赖性检查。
## 贡献
欢迎贡献!请打开一个问题或提交拉取请求,以进行任何增强或修复错误。

View File

@ -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) }}"

View File

@ -39,8 +39,7 @@ class DeviceTypes(BaseType):
'*': {
'ansible_enabled': True,
'ansible_config': {
'ansible_connection': 'local',
'first_conn_delay_time': 0.5,
'ansible_connection': 'local'
},
'ping_enabled': True,
'gather_facts_enabled': False,

View File

@ -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,25 @@ name:
from ansible.module_utils.basic import AnsibleModule
from libs.ansible.modules_utils.custom_common import (
from libs.ansible.modules_utils.remote_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(
if isinstance(commands, list):
commands = '\n'.join(commands)
commands = commands.format(
username=username, password=password, login_password=login_password
)
return commands
return commands.split('\n'), answers.split('\n')
# =========================================
# Module execution.
@ -89,17 +88,16 @@ 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)
with SSHClient(module) as client:
__, err_msg = client.execute(commands, answers)
if err_msg:
module.fail_json(
msg='There was a problem executing the command: %s' % err_msg

View File

@ -116,7 +116,7 @@ def main():
try:
client.close()
except Exception:
except Exception: # noqa
pass
return module.exit_json(**result)

View File

@ -7,14 +7,44 @@ __metaclass__ = type
DOCUMENTATION = '''
---
module: custom_rdp_ping
short_description: Use rdp to probe whether an asset is connectable
short_description: Use RDP to probe whether an asset is connectable.
description:
- Use rdp to probe whether an asset is connectable
- Use RDP to probe whether an asset is connectable.
options:
login_host:
description: Target host to connect.
type: str
required: False
default: localhost
login_port:
description: Target port to connect.
type: int
required: False
default: 22
login_user:
description: Login user for the connection.
type: str
required: False
default: root
login_password:
description: Login password.
type: str
required: False
no_log: True
login_secret_type:
description: Authentication method.
type: str
required: False
default: password
gateway_args:
description: Arguments for setting up an SSH tunnel.
type: dict
required: False
default: null
'''
EXAMPLES = '''
- name: >
Ping asset server.
- name: Ping asset server using RDP.
custom_rdp_ping:
login_host: 127.0.0.1
login_port: 3389
@ -24,12 +54,12 @@ EXAMPLES = '''
RETURN = '''
is_available:
description: Windows server availability.
description: Indicates if the Windows asset is available.
returned: always
type: bool
sample: true
conn_err_msg:
description: Connection error message.
description: Connection error message (if any).
returned: always
type: str
sample: ''
@ -41,11 +71,6 @@ from sshtunnel import SSHTunnelForwarder
from ansible.module_utils.basic import AnsibleModule
# =========================================
# Module execution.
#
def common_argument_spec():
options = dict(
login_host=dict(type='str', required=False, default='localhost'),
@ -67,13 +92,12 @@ class RDPConnectionManager:
self.result_queue = multiprocessing.Queue()
def build_connection_details(self):
connection_details = {
return {
'hostname': self.params['login_host'],
'port': self.params['login_port'],
'username': self.params['username'],
'password': self.params['password']
'username': self.params['login_user'],
'password': self.params['login_password']
}
return connection_details
def setup_ssh_tunnel(self):
gateway_args = self.params['gateway_args'] or {}
@ -90,8 +114,8 @@ class RDPConnectionManager:
self.connection_details['port']
)
)
tunnel.start()
self.connection_details['hostname'] = '127.0.0.1'
self.connection_details['port'] = tunnel.local_bind_port
self.ssh_tunnel = tunnel
@ -107,13 +131,23 @@ class RDPConnectionManager:
self.close_ssh_tunnel()
def check_rdp_connectivity(self):
connect_params = list(self.connection_details.values()) + ['', 0]
connect_params = [
self.connection_details['hostname'],
self.connection_details['port'],
self.connection_details['username'],
self.connection_details['password'],
'', # extra parameter (if needed)
0 # timeout (if needed)
]
try:
is_reachable = pyfreerdp.check_connectivity(*connect_params)
except Exception as ex:
is_reachable = False
self.result_queue.put(is_reachable)
def attempt_connection(self):
if self.params['login_secret_type'] != 'password':
error_message = f'unsupported authentication method: {self.params["login_secret_type"]}'
error_message = f"Unsupported authentication method: {self.params['login_secret_type']}"
return False, error_message
try:
@ -138,16 +172,20 @@ class RDPConnectionManager:
def main():
argument_spec = common_argument_spec()
module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True)
result = {'changed': False}
module_params = module.params
rdp_manager = RDPConnectionManager(module_params)
rdp_manager = RDPConnectionManager(module.params)
is_available, error_message = rdp_manager.attempt_connection()
result['is_available'] = is_available
# Prepare the result structure.
result = {
'changed': False,
'is_available': is_available,
'conn_err_msg': error_message
}
if not is_available:
module.fail_json(msg=f'Unable to connect to asset: {error_message}')
return module.exit_json(**result)
module.fail_json(msg=f"Unable to connect to asset: {error_message}", **result)
else:
module.exit_json(**result)
if __name__ == '__main__':

View File

@ -4,18 +4,35 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = '''
---
module: ssh_ping
short_description: Use ssh to probe whether an asset is connectable
description:
- Use ssh to probe whether an asset is connectable
- Use ssh to probe whether an asset is connectable.
options:
login_host:
description: The target host to connect.
type: str
required: True
login_port:
description: The port on the target host.
type: int
required: False
default: 22
login_user:
description: The username for the SSH connection.
type: str
required: True
login_password:
description: The password for the SSH connection.
type: str
required: True
no_log: True
'''
EXAMPLES = '''
- name: >
Ping asset server.
- name: Ping asset server using SSH.
ssh_ping:
login_host: 127.0.0.1
login_port: 22
@ -25,39 +42,27 @@ EXAMPLES = '''
RETURN = '''
is_available:
description: Ping server availability.
description: Indicate whether the target server is reachable via SSH.
returned: always
type: bool
sample: true
'''
from ansible.module_utils.basic import AnsibleModule
from libs.ansible.modules_utils.custom_common import (
SSHClient, common_argument_spec
)
# =========================================
# Module execution.
#
from libs.ansible.modules_utils.remote_client import SSHClient, common_argument_spec
def main():
options = common_argument_spec()
module = AnsibleModule(argument_spec=options, supports_check_mode=True,)
module = AnsibleModule(argument_spec=options, supports_check_mode=True)
result = {
'changed': False, 'is_available': True
}
client = SSHClient(module)
err = client.connect()
if err:
module.fail_json(msg='Unable to connect to asset: %s' % err)
result['is_available'] = False
result = {'changed': False, 'is_available': False}
return module.exit_json(**result)
with SSHClient(module) as client:
client.connect()
result['is_available'] = True
module.exit_json(**result)
if __name__ == '__main__':

View File

@ -1,207 +0,0 @@
import re
import time
import paramiko
from sshtunnel import SSHTunnelForwarder
class OldSSHTransport(paramiko.transport.Transport):
_preferred_pubkeys = (
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"ssh-rsa",
"rsa-sha2-256",
"rsa-sha2-512",
"ssh-dss",
)
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()
def get_connect_params(self):
params = {
'allow_agent': False, 'look_for_keys': False,
'hostname': self.module.params['login_host'],
'port': self.module.params['login_port'],
'key_filename': self.module.params['login_private_key_path'] or None
}
if self.module.params['become']:
params['username'] = self.module.params['become_user']
params['password'] = self.module.params['become_password']
params['key_filename'] = self.module.params['become_private_key_path'] or None
else:
params['username'] = self.module.params['login_user']
params['password'] = self.module.params['login_password']
params['key_filename'] = self.module.params['login_private_key_path'] or None
if self.module.params['old_ssh_version']:
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']
username = self.module.params['login_user']
if method == 'sudo':
switch_method = 'sudo su -'
password = self.module.params['become_password']
elif method == 'su':
switch_method = 'su -'
password = self.module.params['login_password']
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
def local_gateway_prepare(self):
gateway_args = self.module.params['gateway_args'] or ''
pattern = r"(?:sshpass -p ([^ ]+))?\s*ssh -o Port=(\d+)\s+-o StrictHostKeyChecking=no\s+([\w@]+)@([" \
r"\d.]+)\s+-W %h:%p -q(?: -i (.+))?'"
match = re.search(pattern, gateway_args)
if not match:
return
password, port, username, address, private_key_path = match.groups()
password = password if password else None
private_key_path = private_key_path if private_key_path else None
remote_hostname = self.module.params['login_host']
remote_port = self.module.params['login_port']
server = SSHTunnelForwarder(
(address, int(port)),
ssh_username=username,
ssh_password=password,
ssh_pkey=private_key_path,
remote_bind_address=(remote_hostname, remote_port)
)
server.start()
self.connect_params['hostname'] = '127.0.0.1'
self.connect_params['port'] = server.local_bind_port
self.gateway_server = server
def local_gateway_clean(self):
gateway_server = self.gateway_server
if not gateway_server:
return
gateway_server.stop()
def before_runner_start(self):
self.local_gateway_prepare()
def after_runner_end(self):
self.local_gateway_clean()
def connect(self):
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:
pass

View File

@ -0,0 +1,277 @@
import re
import signal
import time
from functools import wraps
import paramiko
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():
pattern_segments = (r'(\w+\'s )?' + p for p in SU_PROMPT_LOCALIZATIONS)
prompt_pattern = "|".join(pattern_segments) + r' ?(:|) ?'
return re.compile(prompt_pattern, 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),
delay_time=dict(type='int', required=False, default=2),
prompt=dict(type='str', required=False, default='.*'),
answers=dict(type='str', required=False, default='.*'),
commands=dict(type='raw', 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')
timeout = getattr(self, 'timeout', 0)
try:
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",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"ssh-rsa",
"rsa-sha2-256",
"rsa-sha2-512",
"ssh-dss",
)
class SSHClient:
def __init__(self, module):
self.module = module
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.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):
p = self.module.params
params = {
'allow_agent': False,
'look_for_keys': False,
'hostname': p['login_host'],
'port': p['login_port'],
'key_filename': p['login_private_key_path'] or None
}
if p['become']:
params['username'] = p['become_user']
params['password'] = p['become_password']
params['key_filename'] = p['become_private_key_path'] or None
else:
params['username'] = p['login_user']
params['password'] = p['login_password']
params['key_filename'] = p['login_private_key_path'] or None
if p['old_ssh_version']:
params['transport_factory'] = OldSSHTransport
return params
def switch_user(self):
p = self.module.params
if not p['become']:
return
method = p['become_method']
username = p['login_user']
if method == 'sudo':
switch_cmd = 'sudo su -'
pword = p['become_password']
elif method == 'su':
switch_cmd = 'su -'
pword = p['login_password']
else:
self.module.fail_json(msg=f'Become method {method} not supported.')
return
# Expected to see a prompt, type the password, and check the username
output, error = self.execute(
[f'{switch_cmd} {username}', pword, 'whoami'],
[become_prompt_re, DEFAULT_RE, username]
)
if error:
self.module.fail_json(msg=f'Failed to become user {username}. Output: {output}')
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(expression, content):
if isinstance(expression, str):
expression = re.compile(expression, re.DOTALL | re.IGNORECASE)
elif not isinstance(expression, re.Pattern):
raise ValueError(f'{expression} should be a regular expression')
return bool(expression.search(content))
@raise_timeout('Recv message')
def _get_match_recv(self, answer_reg=DEFAULT_RE):
buffer_str = ''
prev_str = ''
check_reg = self.prompt if answer_reg == DEFAULT_RE else answer_reg
while True:
if self.channel.recv_ready():
chunk = self.channel.recv(self.buffer_size).decode('utf-8', 'replace')
buffer_str += chunk
if buffer_str and buffer_str != prev_str:
if self.__match(check_reg, buffer_str):
break
prev_str = buffer_str
time.sleep(0.01)
return buffer_str
@raise_timeout('Wait send message')
def _check_send(self):
while not self.channel.send_ready():
time.sleep(0.01)
time.sleep(self.module.params['delay_time'])
def execute(self, commands, answers=None):
combined_output = ''
error_msg = ''
try:
answers = self._fit_answers(commands, answers)
for cmd, ans_regex in zip(commands, answers):
self._check_send()
self.channel.send(cmd + '\n')
combined_output += self._get_match_recv(ans_regex) + '\n'
except Exception as e:
error_msg = str(e)
return combined_output, error_msg
def local_gateway_prepare(self):
gateway_args = self.module.params['gateway_args'] or ''
pattern = (
r"(?:sshpass -p ([^ ]+))?\s*ssh -o Port=(\d+)\s+-o StrictHostKeyChecking=no\s+"
r"([\w@]+)@([\d.]+)\s+-W %h:%p -q(?: -i (.+))?'"
)
match = re.search(pattern, gateway_args)
if not match:
return
password, port, username, remote_addr, key_path = match.groups()
password = password or None
key_path = key_path or None
server = SSHTunnelForwarder(
(remote_addr, int(port)),
ssh_username=username,
ssh_password=password,
ssh_pkey=key_path,
remote_bind_address=(
self.module.params['login_host'],
self.module.params['login_port']
)
)
server.start()
self.connect_params['hostname'] = '127.0.0.1'
self.connect_params['port'] = server.local_bind_port
self.gateway_server = server
def local_gateway_clean(self):
if self.gateway_server:
self.gateway_server.stop()
def before_runner_start(self):
self.local_gateway_prepare()
def after_runner_end(self):
self.local_gateway_clean()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
try:
self.after_runner_end()
if self.channel:
self.channel.close()
if self.client:
self.client.close()
except Exception: # noqa
pass