优化注释

This commit is contained in:
sky22333
2026-04-21 22:43:09 +08:00
parent 628980f1fd
commit dda2624bc4
13 changed files with 32 additions and 312 deletions

View File

@@ -54,10 +54,8 @@ class AnsibleManager:
line = f"{host['address']} ansible_user={host['username']} ansible_port={host['port']} "
if host['auth_method'] == 'key':
# 使用私钥认证
line += "ansible_ssh_private_key_file=/root/.ssh/id_ed25519 "
elif host['auth_method'] == 'password':
# 使用密码认证
password = host.get('password')
if password:
line += f"ansible_ssh_pass={password} "
@@ -65,7 +63,6 @@ class AnsibleManager:
line += "ansible_ssh_common_args='-o StrictHostKeyChecking=no'"
inventory_content.append(line)
# 创建临时文件
fd, inventory_path = tempfile.mkstemp(prefix='ansible_inventory_')
with os.fdopen(fd, 'w') as f:
f.write('\n'.join(inventory_content))
@@ -77,16 +74,13 @@ class AnsibleManager:
if target_hosts is None:
target_hosts = self.db.get_hosts()
# 生成临时 inventory 文件
inventory_path = self.generate_inventory(target_hosts)
try:
# 初始化必要的对象
loader = DataLoader()
inventory = InventoryManager(loader=loader, sources=inventory_path)
variable_manager = VariableManager(loader=loader, inventory=inventory)
# 创建 play 源数据
play_source = dict(
name="Ansible Ad-Hoc",
hosts='managed_hosts',
@@ -94,13 +88,9 @@ class AnsibleManager:
tasks=[dict(action=dict(module='shell', args=command))]
)
# 创建 play 对象
play = Play().load(play_source, variable_manager=variable_manager, loader=loader)
# 创建回调插件对象
results_callback = ResultCallback()
# 创建任务队列管理器
tqm = None
try:
tqm = TaskQueueManager(
@@ -110,27 +100,23 @@ class AnsibleManager:
passwords=dict(),
stdout_callback=results_callback
)
# 执行 play
tqm.run(play)
finally:
if tqm is not None:
tqm.cleanup()
# 处理结果
results = {
'success': {},
'failed': {},
'unreachable': {}
}
# 处理成功的结果
for host, result in results_callback.host_ok.items():
results['success'][host] = {
'stdout': result._result.get('stdout', ''),
'stderr': result._result.get('stderr', ''),
'rc': result._result.get('rc', 0)
}
# 记录日志
host_id = next((h['id'] for h in target_hosts if h['address'] == host), None)
if host_id:
self.db.log_command(
@@ -140,7 +126,6 @@ class AnsibleManager:
'success'
)
# 处理失败的结果
for host, result in results_callback.host_failed.items():
results['failed'][host] = {
'msg': result._result.get('msg', ''),
@@ -155,7 +140,6 @@ class AnsibleManager:
'failed'
)
# 处理不可达的结果
for host, result in results_callback.host_unreachable.items():
results['unreachable'][host] = {
'msg': result._result.get('msg', '')
@@ -172,21 +156,17 @@ class AnsibleManager:
return results
finally:
# 清理临时文件
os.remove(inventory_path)
def execute_ping(self, target_hosts):
"""执行 Ansible ping 模块"""
# 生成临时 inventory 文件
inventory_path = self.generate_inventory(target_hosts)
try:
# 初始化必要的对象
loader = DataLoader()
inventory = InventoryManager(loader=loader, sources=inventory_path)
variable_manager = VariableManager(loader=loader, inventory=inventory)
# 创建 play 源数据
play_source = dict(
name="Ansible Ping",
hosts='managed_hosts',
@@ -194,13 +174,9 @@ class AnsibleManager:
tasks=[dict(action=dict(module='ping'))]
)
# 创建 play 对象
play = Play().load(play_source, variable_manager=variable_manager, loader=loader)
# 创建回调插件对象
results_callback = ResultCallback()
# 创建任务队列管理器
tqm = None
try:
tqm = TaskQueueManager(
@@ -210,23 +186,19 @@ class AnsibleManager:
passwords=dict(),
stdout_callback=results_callback
)
# 执行 play
tqm.run(play)
finally:
if tqm is not None:
tqm.cleanup()
# 处理结果
results = {
'success': {},
'failed': {},
'unreachable': {}
}
# 处理成功的结果
for host, result in results_callback.host_ok.items():
results['success'][host] = result._result
# 记录日志
host_id = next((h['id'] for h in target_hosts if h['address'] == host), None)
if host_id:
self.db.log_command(
@@ -236,7 +208,6 @@ class AnsibleManager:
'success'
)
# 处理失败的结果
for host, result in results_callback.host_failed.items():
results['failed'][host] = result._result
host_id = next((h['id'] for h in target_hosts if h['address'] == host), None)
@@ -248,7 +219,6 @@ class AnsibleManager:
'failed'
)
# 处理不可达的结果
for host, result in results_callback.host_unreachable.items():
results['unreachable'][host] = result._result
host_id = next((h['id'] for h in target_hosts if h['address'] == host), None)
@@ -263,7 +233,6 @@ class AnsibleManager:
return results
finally:
# 清理临时文件
os.remove(inventory_path)
def get_host_facts(self, host_id):
@@ -272,7 +241,6 @@ class AnsibleManager:
if not host:
return None
# 执行 setup 模块获取主机信息
results = self.execute_command('ansible_facts', [host])
if host['address'] in results['success']:
return results['success'][host['address']]
@@ -281,10 +249,8 @@ class AnsibleManager:
def run_playbook(self, play, target_hosts=None):
"""运行 playbook"""
try:
# 初始化必要的对象
loader = DataLoader()
# 根据是否指定target_hosts来生成inventory
if target_hosts:
inventory_path = self.generate_inventory(target_hosts)
else:
@@ -293,10 +259,8 @@ class AnsibleManager:
inventory = InventoryManager(loader=loader, sources=inventory_path)
variable_manager = VariableManager(loader=loader, inventory=inventory)
# 创建回调插件对象
results_callback = ResultCallback()
# 创建任务队列管理器
tqm = None
try:
tqm = TaskQueueManager(
@@ -306,7 +270,6 @@ class AnsibleManager:
passwords=dict(),
stdout_callback=results_callback
)
# 执行 play
for play_item in play:
play_obj = Play().load(play_item, variable_manager=variable_manager, loader=loader)
tqm.run(play_obj)
@@ -327,11 +290,9 @@ class AnsibleManager:
if not isinstance(hosts, list):
hosts = [hosts]
# 获取选中主机的地址列表
selected_hosts_data = []
all_hosts = self.db.get_hosts()
for host in all_hosts:
# 兼容字符串ID和数字ID转为字符串进行比较
host_id_str = str(host['id'])
if host_id_str in [str(h) for h in hosts]:
selected_hosts_data.append(host)
@@ -339,7 +300,6 @@ class AnsibleManager:
if not selected_hosts_data:
raise Exception("没有找到选中的主机")
# 使用选中主机的地址列表创建主机组
hosts_str = ','.join([h['address'] for h in selected_hosts_data])
play = [{
@@ -364,7 +324,6 @@ class AnsibleManager:
}]
try:
# 执行并获取结果
result = self.run_playbook(play, target_hosts=selected_hosts_data)
return result
except Exception as e:
@@ -395,7 +354,6 @@ class AnsibleManager:
}]
try:
# 执行并获取结果
result = self.run_playbook(play, target_hosts=all_hosts)
return result
except Exception as e:
@@ -403,22 +361,17 @@ class AnsibleManager:
def execute_custom_playbook(self, playbook_content, target_hosts=None):
"""执行自定义Playbook"""
# 创建临时playbook文件
fd, playbook_path = tempfile.mkstemp(prefix='ansible_playbook_', suffix='.yml')
with os.fdopen(fd, 'w') as f:
f.write(playbook_content)
try:
# 如果提供了特定主机则生成临时inventory
inventory_option = []
if target_hosts:
inventory_path = self.generate_inventory(target_hosts)
inventory_option = ['-i', inventory_path]
# 构建ansible-playbook命令
cmd = ['ansible-playbook', playbook_path] + inventory_option + ['-v']
# 创建日志处理函数和回调
logs = []
log_lock = threading.Lock()
@@ -428,7 +381,6 @@ class AnsibleManager:
with log_lock:
logs.append(decoded_line)
# 执行命令,实时捕获输出
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
@@ -436,16 +388,12 @@ class AnsibleManager:
universal_newlines=False
)
# 启动线程处理输出
output_thread = threading.Thread(target=process_output, args=(process,))
output_thread.daemon = True
output_thread.start()
# 等待命令执行完成
process.wait()
output_thread.join()
# 解析结果
result = {
'success': process.returncode == 0,
'return_code': process.returncode,
@@ -456,7 +404,6 @@ class AnsibleManager:
return result
finally:
# 清理临时文件
os.remove(playbook_path)
if target_hosts:
os.remove(inventory_path)
@@ -469,27 +416,23 @@ class AnsibleManager:
'unreachable': []
}
# 正则表达式匹配成功、失败和不可达的主机
success_pattern = re.compile(r'([\w\.-]+)\s+:\s+ok=\d+')
failed_pattern = re.compile(r'([\w\.-]+)\s+:\s+.*failed=([1-9]\d*)')
unreachable_pattern = re.compile(r'([\w\.-]+)\s+:\s+.*unreachable=([1-9]\d*)')
for line in logs:
# 检查成功的主机
success_match = success_pattern.search(line)
if success_match and not failed_pattern.search(line) and not unreachable_pattern.search(line):
host = success_match.group(1)
if host not in summary['success']:
summary['success'].append(host)
# 检查失败的主机
failed_match = failed_pattern.search(line)
if failed_match:
host = failed_match.group(1)
if host not in summary['failed']:
summary['failed'].append(host)
# 检查不可达的主机
unreachable_match = unreachable_pattern.search(line)
if unreachable_match:
host = unreachable_match.group(1)

94
app.py
View File

@@ -19,47 +19,37 @@ import datetime
import logging
from crypto_utils import CryptoUtils, set_crypto_keys, derive_key_from_credentials
# 新增获取客户端真实IP的函数
def get_client_ip():
"""获取客户端真实IP地址
优先从代理转发的头信息中获取真实IP如不存在则返回直连IP
"""
# 尝试从常见的代理头中获取
if request.headers.get('X-Forwarded-For'):
# 取列表中第一个IP(通常是原始客户端)
return request.headers.get('X-Forwarded-For').split(',')[0].strip()
elif request.headers.get('X-Real-IP'):
return request.headers.get('X-Real-IP')
# 如果没有代理头则使用直接IP
return request.remote_addr
app = Flask(__name__, static_folder='public', static_url_path='')
app.secret_key = secrets.token_hex(32)
# 设置令牌过期时间为5小时
JWT_EXPIRATION = 5 * 60 * 60 # 5小时以秒为单位
JWT_SECRET = app.secret_key
db = Database()
ansible = AnsibleManager(db)
crypto = CryptoUtils()
# 账号密码变量
ADMIN_USERNAME = os.getenv('ADMIN_USERNAME')
ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD')
# 检查必要的环境变量
if not ADMIN_USERNAME or not ADMIN_PASSWORD:
app.logger.warning("未设置管理员凭证环境变量(ADMIN_USERNAME/ADMIN_PASSWORD),请设置这些环境变量以确保系统安全")
# 配置WebSocket
sock = Sock(app)
sock.init_app(app)
UPLOAD_FOLDER = '/tmp/ansible_uploads'
# 确保上传目录存在
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
# 简化allowed_file函数
def allowed_file(filename):
"""检查文件是否允许上传,当前策略是允许所有文件"""
return True
@@ -147,7 +137,6 @@ def auth_required(f):
if not ensure_crypto_key():
return jsonify({'error': '系统加密配置错误,请联系管理员'}), 500
# 将用户信息添加到request中以便视图函数使用
request.user = user
return f(*args, **kwargs)
return decorated_function
@@ -162,7 +151,6 @@ def before_request():
@app.after_request
def after_request(response):
# 记录API请求
if request.path.startswith("/api/"):
status = 'success' if response.status_code < 400 else 'failed'
db.add_access_log(
@@ -181,36 +169,22 @@ def login():
username = data.get('username')
password = data.get('password')
# 确保环境变量已设置
if not ADMIN_USERNAME or not ADMIN_PASSWORD:
app.logger.error("系统未配置管理员凭证")
return jsonify({'success': False, 'message': '系统配置错误'}), 500
if username == ADMIN_USERNAME and password == ADMIN_PASSWORD:
# 从用户凭证派生加密密钥
try:
key, salt = derive_key_from_credentials(username, password)
# 设置全局加密密钥
set_crypto_keys(key, salt)
app.logger.info(f"已从用户凭证成功派生加密密钥,长度为: {len(key)} 字节")
# 生成JWT令牌
token = generate_token('admin')
# 创建包含token的响应
response_data = {'success': True, 'message': '登录成功', 'token': token}
response = jsonify(response_data)
# 将token也存在cookie中方便前端获取
# secure=True表示只在HTTPS连接中发送
# httponly=True表示JavaScript不能访问cookie增加安全性
# samesite='Lax'防止CSRF攻击
response.set_cookie(
'token',
token,
max_age=JWT_EXPIRATION,
# secure=True, # 生产环境建议开启
httponly=True,
samesite='Lax'
)
@@ -230,26 +204,21 @@ def serve_react_app(path):
"""处理前端路由 - 所有路由都交给React处理除非是静态文件"""
app.logger.info(f"serve_react_app 处理 路径: '{path}'")
# 显式处理终端路径(同时处理有斜杠和无斜杠的情况)
if path.startswith('terminal'):
app.logger.info(f"明确处理终端路径: {path}")
return send_from_directory(app.static_folder, 'index.html')
# 如果是API请求或WebSocket路由不处理已有专门的处理器
if path.startswith('api/') or path.startswith('ws/'):
app.logger.info(f"API或WebSocket路径返回404: {path}")
return jsonify({'error': 'Not found'}), 404
# 检查请求的路径是否对应 public 目录下的一个实际存在的文件
static_file_path = os.path.join(app.static_folder, path)
app.logger.info(f"尝试查找静态文件: {static_file_path}")
if path != "" and os.path.exists(static_file_path) and not os.path.isdir(static_file_path):
app.logger.info(f"找到静态文件,返回: {static_file_path}")
# 如果是实际文件(如 CSS, JS, 图片),则直接提供该文件
return send_from_directory(app.static_folder, path)
else:
app.logger.info(f"未找到静态文件返回index.html用于前端路由: {path}")
# 否则,提供 public/index.html让 React Router 处理路由
return send_from_directory(app.static_folder, 'index.html')
@app.route('/api/hosts', methods=['GET'])
@@ -259,14 +228,11 @@ def get_hosts():
"""获取所有主机列表"""
hosts = db.get_hosts()
for host in hosts:
# 不返回明文密码到前端,但保留加密形式用于识别
# 根据认证方式调整 is_password_encrypted 的含义
if host['auth_method'] == 'password':
host['is_password_encrypted'] = crypto.is_encrypted(host['encrypted_password'])
else:
host['is_password_encrypted'] = False # 密钥认证,没有加密密码
host['password'] = '********' # 始终不返回明文密码
# 删除不需要返回的字段
host['is_password_encrypted'] = False
host['password'] = '********'
if 'encrypted_password' in host:
del host['encrypted_password']
return jsonify(hosts)
@@ -278,13 +244,11 @@ def get_host(host_id):
"""获取单个主机信息"""
host = db.get_host(host_id)
if host:
# 根据认证方式调整 is_password_encrypted 的含义
if host['auth_method'] == 'password':
host['is_password_encrypted'] = crypto.is_encrypted(host['encrypted_password'])
else:
host['is_password_encrypted'] = False # 密钥认证,没有加密密码
host['password'] = '********' # 始终不返回明文密码
# 删除不需要返回的字段
host['is_password_encrypted'] = False
host['password'] = '********'
if 'encrypted_password' in host:
del host['encrypted_password']
return jsonify(host)
@@ -346,7 +310,6 @@ def update_host(host_id):
if not all(field in host_data for field in required_fields):
return jsonify({'error': 'Missing required fields'}), 400
# 检查主机是否存在
if not db.get_host(host_id):
return jsonify({'error': 'Host not found'}), 404
@@ -358,7 +321,6 @@ def update_host(host_id):
@auth_required
def delete_host(host_id):
"""删除主机"""
# 检查主机是否存在
if not db.get_host(host_id):
return jsonify({'error': 'Host not found'}), 404
@@ -377,7 +339,6 @@ def execute_command():
if not command:
return jsonify({'error': 'Command is required'}), 400
# 确定目标主机
if host_ids == 'all':
target_hosts = db.get_hosts()
else:
@@ -394,7 +355,6 @@ def execute_command():
if not target_hosts:
return jsonify({'error': 'No valid target hosts'}), 400
# 执行命令并获取结果
results = ansible.execute_command(command, target_hosts)
return jsonify(results)
@@ -426,10 +386,7 @@ def ping_host(host_id):
if not host:
return jsonify({'error': 'Host not found'}), 404
# 使用 Ansible 执行 ping 模块
results = ansible.execute_ping([host])
# 解析结果
host_address = host['address']
if host_address in results['success']:
return jsonify({'status': 'success', 'message': '连接正常'})
@@ -443,27 +400,23 @@ def terminal_ws(ws, host_id):
"""处理终端 WebSocket 连接"""
app.logger.info(f"处理WebSocket连接请求: host_id={host_id}")
# 检查授权令牌
token = request.args.get('token')
if not token:
app.logger.error("终端WebSocket错误: 未提供令牌")
ws.send(json.dumps({"error": "Authorization required"}))
return
# 验证令牌是否有效
try:
# 令牌格式:host_id:timestamp:签名
# token 格式: host_id:timestamp:signature
parts = token.split(':')
if len(parts) != 3 or parts[0] != str(host_id):
raise ValueError("Invalid token format")
# 检查时间戳是否在有效期内5分钟
token_timestamp = int(parts[1])
current_time = int(time.time())
if current_time - token_timestamp > 300: # 5分钟有效期
raise ValueError("Token expired")
# 验证签名
message = f"{host_id}:{token_timestamp}"
expected_signature = hmac.new(
app.secret_key.encode(),
@@ -489,7 +442,6 @@ def terminal_ws(ws, host_id):
try:
with ssh_client_for_host(host, timeout=10) as ssh:
# 默认终端大小
term_width = 100
term_height = 30
@@ -515,7 +467,6 @@ def terminal_ws(ws, host_id):
app.logger.info("WebSocket连接已建立后台线程已启动")
# 发送初始欢迎信息
welcome_msg = "\r\n\x1b[1;32m*** 已连接到主机 ***\x1b[0m\r\n"
ws.send(welcome_msg)
@@ -767,7 +718,6 @@ def sftp_delete(host_id):
with sftp_client_for_host(host) as sftp:
if is_directory:
# 检查目录是否为空
if sftp.listdir(path):
return jsonify({'error': 'Directory is not empty'}), 400
sftp.rmdir(path)
@@ -794,12 +744,11 @@ def sftp_download(host_id):
try:
filename = os.path.basename(path)
with sftp_client_for_host(host) as sftp:
# 检查文件状态
file_attr = sftp.stat(path)
if stat.S_ISDIR(file_attr.st_mode):
return jsonify({'error': 'Cannot download a directory'}), 400
# 为防止路径遍历漏洞,只处理文件名
# 仅使用文件名,避免目录遍历。
temp_path = os.path.join('/tmp', secure_filename(filename))
sftp.get(path, temp_path)
@@ -807,7 +756,6 @@ def sftp_download(host_id):
with open(temp_path, 'rb') as f:
content = f.read()
# 创建响应对象
response = Response(content)
response.headers['Content-Type'] = 'application/octet-stream'
response.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
@@ -825,11 +773,9 @@ def not_found_error(error):
"""处理404错误"""
app.logger.error(f"404错误: 路径={request.path}, IP={request.remote_addr}, 方法={request.method}")
# 如果是API或WebSocket请求返回JSON错误
if request.path.startswith('/api/') or request.path.startswith('/ws/'):
return jsonify({'error': 'Not found'}), 404
# 其他所有路径交给前端路由处理与serve_react_app一致
return send_from_directory(app.static_folder, 'index.html')
@app.errorhandler(500)
@@ -879,12 +825,10 @@ def api_upload():
remote_path = request.form.get('remote_path', '/tmp/')
hosts_json = request.form.get('hosts', 'all')
# 保存文件
file_path = os.path.join(UPLOAD_FOLDER, filename)
file.save(file_path)
try:
# 确定上传类型和目标主机
remote_file_path = os.path.join(remote_path, filename).replace('\\', '/')
if hosts_json != 'all':
@@ -895,38 +839,27 @@ def api_upload():
except json.JSONDecodeError:
return jsonify({'error': '无效的主机列表格式'}), 400
# 查找选中的主机信息,为后续记录结果做准备
host_ids = [str(h) for h in hosts]
all_hosts = db.get_hosts()
host_map = {str(h['id']): h for h in all_hosts}
# 调用ansible执行文件上传
result = ansible.copy_file_to_hosts(file_path, remote_file_path, hosts)
else:
# 获取所有主机信息,为后续记录结果做准备
all_hosts = db.get_hosts()
host_map = {str(h['id']): h for h in all_hosts}
host_ids = list(host_map.keys())
# 上传到所有主机
result = ansible.copy_file_to_all(file_path, remote_file_path)
# 删除临时文件
if os.path.exists(file_path):
os.remove(file_path)
# 处理结果,区分完全成功、部分成功和完全失败
successful_hosts = []
failed_hosts = {}
# 处理成功的主机
for host, res in result.get('success', {}).items():
# 从host_map中找到对应的主机ID
host_id = next((id for id, h in host_map.items() if h['address'] == host), None)
if host_id:
successful_hosts.append(host_id)
# 处理失败和不可达的主机
for host, res in result.get('failed', {}).items():
host_id = next((id for id, h in host_map.items() if h['address'] == host), None)
if host_id:
@@ -937,11 +870,9 @@ def api_upload():
if host_id:
failed_hosts[host_id] = '主机不可达'
# 计算成功率和整体状态
total = len(host_ids)
succeeded = len(successful_hosts)
# 确定响应状态
if succeeded == total: # 全部成功
return jsonify({
'success': True,
@@ -972,7 +903,6 @@ def api_upload():
except Exception as e:
app.logger.error(f"文件上传失败: {str(e)}")
# 确保出错时也删除临时文件
if os.path.exists(file_path):
os.remove(file_path)
return jsonify({
@@ -986,7 +916,6 @@ def api_upload():
return jsonify({'error': '不支持的文件类型'}), 400
# 新的JWT相关函数
def generate_token(user_id):
"""生成JWT令牌"""
payload = {
@@ -1006,23 +935,19 @@ def decode_token(token):
except jwt.InvalidTokenError:
return None
# 添加用于WebSocket令牌生成的函数
def generate_ws_token(host_id):
"""生成用于WebSocket连接的令牌"""
timestamp = int(time.time())
message = f"{host_id}:{timestamp}"
# 使用app.secret_key作为密钥生成HMAC签名
signature = hmac.new(
app.secret_key.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
# 返回格式: host_id:timestamp:signature
return f"{host_id}:{timestamp}:{signature}"
# 添加API端点用于获取WebSocket令牌
@app.route('/api/ws-token/<int:host_id>', methods=['GET'])
@auth_required
def get_ws_token(host_id):
@@ -1042,23 +967,17 @@ def execute_playbook():
playbook_content = data.get('playbook')
host_ids = data.get('host_ids', [])
# 验证输入
if not playbook_content:
return jsonify({'error': '未提供Playbook内容'}), 400
# 如果指定了主机ID则获取这些主机的信息
target_hosts = None
if host_ids:
target_hosts = [db.get_host(host_id) for host_id in host_ids]
# 过滤掉不存在的主机
target_hosts = [host for host in target_hosts if host]
# 执行Playbook
try:
result = ansible.execute_custom_playbook(playbook_content, target_hosts)
# 记录执行日志
# 如果有指定主机,则为每个主机记录一条日志
if target_hosts:
for host in target_hosts:
host_status = 'success'
@@ -1074,7 +993,6 @@ def execute_playbook():
host_status
)
else:
# 如果没有指定主机,则记录一个通用日志
db.log_command(
None,
'Custom Playbook Execution',

View File

@@ -5,7 +5,6 @@ from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
import hashlib
# 全局变量,用于存储加密密钥
CRYPTO_KEY = None
CRYPTO_SALT = None
@@ -16,7 +15,6 @@ class CryptoUtils:
def __new__(cls):
if cls._instance is None:
cls._instance = super(CryptoUtils, cls).__new__(cls)
# 初始化
cls._instance._init_crypto()
return cls._instance
@@ -24,20 +22,15 @@ class CryptoUtils:
"""初始化加密密钥"""
global CRYPTO_KEY, CRYPTO_SALT
# 检查全局变量中是否已有密钥
if CRYPTO_KEY and CRYPTO_SALT:
# 如果全局变量中有密钥,直接使用
self.key = CRYPTO_KEY
self.salt = CRYPTO_SALT
return
# 没有设置密钥,因为系统依赖用户登录派生密钥
# 这里设置临时值,将在用户登录时被覆盖
# 注意:这些临时密钥无法解密任何数据,只是为了避免程序错误
# 登录前先放置占位密钥,避免实例初始化失败。
self.salt = b"temporary_salt_will_be_replaced"
self.key = os.urandom(32) # 确保临时密钥也是正确长度32字节 = 256位
self.key = os.urandom(32)
# 将临时密钥存储到全局变量中
CRYPTO_KEY = self.key
CRYPTO_SALT = self.salt
@@ -46,19 +39,10 @@ class CryptoUtils:
if not plain_text:
return None
# 生成随机nonce
nonce = os.urandom(12)
# 创建AESGCM对象
cipher = AESGCM(self.key)
# 加密
encrypted = cipher.encrypt(nonce, plain_text.encode('utf-8'), None)
# 拼接nonce和加密数据并进行Base64编码
result = base64.b64encode(nonce + encrypted).decode('utf-8')
# 添加前缀,便于识别此文本是加密的
return f"ENC:{result}"
def decrypt(self, encrypted_text):
@@ -66,28 +50,17 @@ class CryptoUtils:
if not encrypted_text:
return None
# 检查是否为加密文本
if not encrypted_text.startswith("ENC:"):
return encrypted_text
# 去除前缀
encrypted_text = encrypted_text[4:]
try:
# Base64解码
data = base64.b64decode(encrypted_text)
# 提取nonce和密文
nonce = data[:12]
ciphertext = data[12:]
# 创建AESGCM对象
cipher = AESGCM(self.key)
# 解密
return cipher.decrypt(nonce, ciphertext, None).decode('utf-8')
except Exception as e:
# 解密失败则返回原始文本
print(f"解密失败: {str(e)}")
return encrypted_text
@@ -95,7 +68,6 @@ class CryptoUtils:
"""检查文本是否已加密"""
return text and isinstance(text, str) and text.startswith("ENC:")
# 设置密钥的函数,允许外部代码设置密钥
def set_crypto_keys(key, salt):
"""设置加密密钥
@@ -105,13 +77,11 @@ def set_crypto_keys(key, salt):
"""
global CRYPTO_KEY, CRYPTO_SALT
# 如果输入是字符串尝试base64解码
if isinstance(key, str):
key = base64.b64decode(key)
if isinstance(salt, str):
salt = base64.b64decode(salt)
# 确保密钥长度正确
if len(key) != 32:
raise ValueError("AES-GCM密钥必须是256位(32字节)")
@@ -128,22 +98,15 @@ def derive_key_from_credentials(username, password):
Returns:
tuple: (key, salt) 派生的密钥和盐值
"""
# 组合用户名和密码作为派生基础
combined = f"{username}:{password}".encode('utf-8')
# 从用户名派生盐值,确保相同用户名始终生成相同的盐值
salt = hashlib.sha256(username.encode('utf-8')).digest()[:16]
# 使用PBKDF2派生密钥确保输出长度为32字节(256位)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32, # 明确指定输出长度为32字节(256位)
length=32,
salt=salt,
iterations=100000,
)
key = kdf.derive(combined)
# 确认密钥长度为32字节(256位)
assert len(key) == 32, "派生的密钥长度必须为256位(32字节)"
return key, salt

View File

@@ -8,7 +8,6 @@ class Database:
self.db_path = db_path
self.crypto = CryptoUtils()
# 判断父目录是否存在,不存在则创建
db_dir = os.path.dirname(self.db_path)
if not os.path.exists(db_dir):
os.makedirs(db_dir, exist_ok=True)

View File

@@ -3,37 +3,26 @@ import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-d
import LoginPage from './pages/LoginPage';
import MainPage from './pages/MainPage';
import TerminalPage from './pages/TerminalPage';
import { Toaster } from "@/components/ui/sonner"; // Updated import to sonner
import { Toaster } from "@/components/ui/sonner";
import { AuthProvider } from './contexts/AuthContext';
import { authStorage } from './contexts/auth-storage';
import { useAuth } from './contexts/use-auth';
// Wrapper component to protect routes
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
// 使用authStorage检查认证状态
const isLocalAuth = authStorage.getAuth();
// 如果上下文或本地存储中有有效的认证,则允许访问
if (isAuthenticated || isLocalAuth) {
return <>{children}</>;
}
// 否则重定向到登录页面
return <Navigate to="/login" replace />;
}
// Main App component
function App() {
return (
<AuthProvider>
<Router>
<Routes>
{/* Login page route */}
<Route path="/login" element={<LoginPage />} />
{/* Main page route - protected */}
<Route
path="/"
element={
@@ -42,14 +31,10 @@ function App() {
</ProtectedRoute>
}
/>
{/* Terminal page route - 不需要强制认证改为直接访问内部API调用会处理认证 */}
<Route
path="/terminal/:hostId"
element={<TerminalPage />}
/>
{/* Fallback route: Redirect unauthenticated users to login, authenticated users to main */}
<Route
path="*"
element={
@@ -58,12 +43,11 @@ function App() {
/>
</Routes>
</Router>
<Toaster richColors /> {/* Use sonner Toaster, added richColors prop */}
<Toaster richColors />
</AuthProvider>
);
}
// Helper component for the fallback route
function AuthRedirect() {
const { isAuthenticated } = useAuth();
const isLocalAuth = authStorage.getAuth();

View File

@@ -3,18 +3,17 @@ import { useDropzone } from 'react-dropzone';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress"; // Import Progress component
import { UploadCloudIcon, FileIcon, XIcon, CheckCircleIcon, XCircleIcon } from 'lucide-react'; // Using lucide-react icons
import { Progress } from "@/components/ui/progress";
import { UploadCloudIcon, FileIcon, XIcon, CheckCircleIcon, XCircleIcon } from 'lucide-react';
import { toast } from "sonner";
import api from '@/services/api';
import { getApiErrorMessage } from '@/utils/http';
interface FileUploadProps {
targetHostIds: number[] | 'all';
onClose: () => void; // Callback to close the dialog
onClose: () => void;
}
// 新增上传结果接口
interface UploadResult {
success: boolean;
message: string;
@@ -26,7 +25,7 @@ interface UploadResult {
function FileUpload({ targetHostIds, onClose }: FileUploadProps) {
const [file, setFile] = useState<File | null>(null);
const [remotePath, setRemotePath] = useState('/tmp/'); // Default remote path
const [remotePath, setRemotePath] = useState('/tmp/');
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadResult, setUploadResult] = useState<UploadResult | null>(null);
@@ -34,14 +33,14 @@ function FileUpload({ targetHostIds, onClose }: FileUploadProps) {
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
setFile(acceptedFiles[0]);
setUploadProgress(0); // Reset progress when a new file is selected
setUploadResult(null); // Reset previous results
setUploadProgress(0);
setUploadResult(null);
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
multiple: false, // Allow only single file upload
multiple: false,
});
const handleUpload = async () => {
@@ -61,7 +60,7 @@ function FileUpload({ targetHostIds, onClose }: FileUploadProps) {
const formData = new FormData();
formData.append('file', file);
formData.append('remote_path', remotePath.trim());
formData.append('hosts', JSON.stringify(targetHostIds)); // Send hosts as JSON string
formData.append('hosts', JSON.stringify(targetHostIds));
try {
const response = await api.post<UploadResult>("/api/upload", formData, {
@@ -74,29 +73,22 @@ function FileUpload({ targetHostIds, onClose }: FileUploadProps) {
},
});
// 保存上传结果
setUploadResult(response.data);
// 根据返回结果显示不同的消息
if (response.data.success) {
const details = response.data.details;
const succeededCount = details?.succeeded.length || 0;
const failedCount = Object.keys(details?.failed || {}).length;
if (failedCount === 0) {
// 全部成功
toast.success("文件上传成功", {
description: `文件已成功上传到所有目标主机的 ${remotePath}`
});
} else {
// 部分成功
toast.warning("文件部分上传成功", {
description: `成功: ${succeededCount}台, 失败: ${failedCount}台. 查看详情以了解更多信息。`
});
}
} else {
// 全部失败时的显示
toast.error("文件上传失败", {
description: response.data.message || "所有主机上传失败",
});
@@ -164,7 +156,6 @@ function FileUpload({ targetHostIds, onClose }: FileUploadProps) {
<p className="text-xs text-muted-foreground"></p>
</div>
{/* 上传结果显示区域 */}
{uploadResult && (
<div className="border rounded-md p-3 bg-muted/90">
<h4 className="text-sm font-medium mb-2"></h4>

View File

@@ -13,7 +13,6 @@ interface PlaybookExecutorProps {
onClose: () => void;
}
// 执行结果接口
interface PlaybookResult {
success: boolean;
return_code: number;
@@ -52,44 +51,35 @@ function PlaybookExecutor({ targetHostIds, onClose }: PlaybookExecutorProps) {
}
setIsExecuting(true);
setExecutionProgress(10); // 开始进度
setExecutionProgress(10);
setExecutionResult(null);
try {
// 准备请求数据
const requestData = {
playbook: playbook.trim(),
host_ids: targetHostIds === 'all' ? [] : targetHostIds,
};
// 发送请求执行Playbook
setExecutionProgress(30);
const response = await api.post<PlaybookResult>("/api/playbook/execute", requestData);
setExecutionProgress(100);
// 保存执行结果
setExecutionResult(response.data);
// 根据返回结果显示不同的消息
if (response.data.success) {
const successCount = response.data.summary.success.length;
const failedCount = response.data.summary.failed.length;
const unreachableCount = response.data.summary.unreachable.length;
if (failedCount === 0 && unreachableCount === 0) {
// 全部成功
toast.success("Playbook执行成功", {
description: `成功执行Playbook所有主机任务完成`
});
} else {
// 部分成功
toast.warning("Playbook部分执行成功", {
description: `成功: ${successCount}台, 失败: ${failedCount}台, 不可达: ${unreachableCount}`
});
}
} else {
// 执行失败时的显示
toast.error("Playbook执行失败", {
description: `执行失败,返回代码: ${response.data.return_code}`,
});
@@ -125,7 +115,6 @@ function PlaybookExecutor({ targetHostIds, onClose }: PlaybookExecutorProps) {
<Progress value={executionProgress} className="w-full" />
)}
{/* 执行结果显示区域 */}
{executionResult && (
<div className="border rounded-md p-3 bg-muted/90">
<h4 className="text-sm font-medium mb-2"></h4>

View File

@@ -5,16 +5,13 @@ import { authStorage } from '@/contexts/auth-storage';
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => {
// 初始化时检查本地存储,包括过期时间检查
return authStorage.getAuth();
});
const [token, setToken] = useState<string | null>(() => {
// 初始化时获取存储的令牌
return authStorage.getToken();
});
// 定期检查认证状态是否过期
useEffect(() => {
const checkAuthExpiry = () => {
const currentAuth = authStorage.getAuth();
@@ -26,7 +23,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
};
// 每分钟检查一次
const interval = setInterval(checkAuthExpiry, 60000);
return () => clearInterval(interval);
}, [isAuthenticated]);
@@ -38,7 +34,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const jwtToken = response.data.token;
setIsAuthenticated(true);
setToken(jwtToken);
authStorage.setAuth(true, jwtToken, 5); // 5小时过期
authStorage.setAuth(true, jwtToken, 5);
return true;
} else {
setIsAuthenticated(false);

View File

@@ -13,7 +13,6 @@ function LoginPage() {
const [errorMessage, setErrorMessage] = useState('');
const navigate = useNavigate();
const { login } = useAuth();
// No need for useToast hook anymore
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

View File

@@ -22,7 +22,6 @@ import { prepareHostData } from '@/utils/crypto';
import { Switch } from "@/components/ui/switch";
import { getApiErrorMessage } from '@/utils/http';
// Define Host type based on backend API
interface Host {
id: number;
comment: string;
@@ -35,7 +34,6 @@ interface Host {
auth_method?: 'password' | 'key';
}
// Define Access Log type
interface AccessLog {
id: number;
access_time: string;
@@ -61,8 +59,8 @@ function MainPage() {
const [accessLogPathFilter, setAccessLogPathFilter] = useState('');
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
const [uploadTarget, setUploadTarget] = useState<'selected' | 'all' | null>(null);
const [isBatchAddOpen, setIsBatchAddOpen] = useState(false); // Control batch add dialog
const [isAuthChecking, setIsAuthChecking] = useState(true); // 新增:认证检查状态
const [isBatchAddOpen, setIsBatchAddOpen] = useState(false);
const [isAuthChecking, setIsAuthChecking] = useState(true);
const [isPlaybookDialogOpen, setIsPlaybookDialogOpen] = useState(false);
const [playbookTarget, setPlaybookTarget] = useState<'selected' | 'all' | null>(null);
const [useKeyAuth, setUseKeyAuth] = useState(() => {
@@ -93,13 +91,11 @@ function MainPage() {
}
}, [notifyRequestError]);
// 改进后的认证状态检查逻辑
useEffect(() => {
const checkAuth = () => {
try {
const isLocalAuth = authStorage.getAuth();
// 如果既没有React context认证也没有localStorage认证则跳转到登录页
if (!isAuthenticated && !isLocalAuth) {
navigate('/login');
return false;
@@ -110,19 +106,15 @@ function MainPage() {
}
};
// 立即检查认证状态
const isAuthed = checkAuth();
// 只有通过了认证检查,才执行后续的数据加载
if (isAuthed) {
void fetchHosts();
}
// 完成认证检查
setIsAuthChecking(false);
}, [fetchHosts, isAuthenticated, navigate]);
// 持久化密钥认证开关状态
useEffect(() => {
localStorage.setItem('useKeyAuth', JSON.stringify(useKeyAuth));
}, [useKeyAuth]);
@@ -183,7 +175,6 @@ function MainPage() {
}
setIsAddingHost(true);
try {
// 对每个主机数据进行处理
const processedHostsData = hostsData.map(host => prepareHostData(host, undefined, useKeyAuth));
const response = await api.post('/api/hosts/batch', processedHostsData);
toast.success("成功", { description: response.data.message || `成功添加 ${response.data.count} 台主机` });
@@ -198,7 +189,6 @@ function MainPage() {
};
const handleEditHost = (host: Host) => {
// 确保编辑时有默认的认证方式
const hostWithDefaults = {
...host,
auth_method: host.auth_method || 'password'
@@ -210,7 +200,6 @@ function MainPage() {
if (!editingHost) return;
setIsEditingHost(true);
try {
// 处理主机数据,特别是密码字段
const dataToSend = prepareHostData(editedHost, editingHost, editedHost.auth_method === 'key');
await api.put(`/api/hosts/${editingHost.id}`, dataToSend);
@@ -355,7 +344,6 @@ function MainPage() {
const isAllSelected = hosts.length > 0 && selectedHostIds.length === hosts.length;
const isIndeterminate = selectedHostIds.length > 0 && selectedHostIds.length < hosts.length;
// 添加加载指示器
if (isAuthChecking) {
return (
<div className="flex items-center justify-center h-screen">
@@ -442,7 +430,6 @@ function MainPage() {
</header>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Host Management Panel (Takes 2/3 width on large screens) */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle></CardTitle>
@@ -474,7 +461,7 @@ function MainPage() {
<div className="grid gap-4 py-4">
<Textarea
placeholder="需遵循示例格式输入"
rows={5} // Reduced rows
rows={5}
value={batchInput}
onChange={(e) => setBatchInput(e.target.value)}
className="placeholder:opacity-40 batch-input-textarea"
@@ -536,7 +523,6 @@ function MainPage() {
</div>
</div>
{/* Host List Table - Responsive Container */}
<div className="border rounded-md overflow-x-auto">
<Table>
<TableHeader>
@@ -626,7 +612,6 @@ function MainPage() {
</DialogHeader>
{editingHost && (
<div className="grid gap-4 py-4">
{/* Form fields remain the same */}
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="edit-comment" className="text-right"></Label>
<Input id="edit-comment" value={editingHost.comment} onChange={(e) => setEditingHost({ ...editingHost, comment: e.target.value })} className="col-span-3" />
@@ -692,7 +677,6 @@ function MainPage() {
</CardContent>
</Card>
{/* Command Execution Panel (Takes 1/3 width on large screens) */}
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle></CardTitle>
@@ -719,7 +703,6 @@ function MainPage() {
</Button>
</div>
{/* Command Log Output - Takes remaining space */}
<div className="flex flex-col flex-grow min-h-[200px]">
<h3 className="text-lg font-semibold mb-2"></h3>
<div className="border rounded-md p-3 flex-grow overflow-y-auto bg-muted/90 dark:bg-muted/90 text-sm font-mono whitespace-pre-wrap">
@@ -730,7 +713,6 @@ function MainPage() {
</Card>
</div>
{/* File Upload Dialog */}
<Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
@@ -748,7 +730,6 @@ function MainPage() {
</DialogContent>
</Dialog>
{/* Playbook Execution Dialog */}
<Dialog open={isPlaybookDialogOpen} onOpenChange={setIsPlaybookDialogOpen}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto dialog-content-scroll-hide">
<DialogHeader>
@@ -767,7 +748,6 @@ function MainPage() {
</DialogContent>
</Dialog>
{/* GitHub Link */}
<div className="text-center mt-6 mb-2">
<a
href="https://github.com/sky22333/ansible-ui"

View File

@@ -3,16 +3,14 @@ import { useParams, useNavigate } from 'react-router-dom';
import { isAxiosError } from 'axios';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
// import { WebLinksAddon } from '@xterm/addon-web-links'; // Corrected import if needed
import 'xterm/css/xterm.css';
import { Button } from '@/components/ui/button';
import { ReloadIcon, CheckCircledIcon, CrossCircledIcon } from '@radix-ui/react-icons';
import { toast } from "sonner"; // Updated import for sonner
import { toast } from "sonner";
import { authStorage } from '@/contexts/auth-storage';
import api from '@/services/api';
import { getApiErrorMessage } from '@/utils/http';
// Define the shape of the resize message data
interface ResizeData {
cols: number;
rows: number;
@@ -129,26 +127,19 @@ function TerminalPage() {
}
}, [fetchWsToken, hostId, sendResize, wsToken]);
// 检查认证状态
useEffect(() => {
// 在组件挂载时检查是否已认证
const isAuthenticated = authStorage.getAuth();
const hasToken = !!authStorage.getToken();
if (!isAuthenticated || !hasToken) {
// 如果未认证,显示提示信息
toast.error("需要登录", { description: "请先登录系统才能使用终端功能" });
// 跳转到登录页
navigate('/login', { replace: true });
return;
}
// 获取WebSocket令牌
void fetchWsToken();
return () => {
// 组件卸载时清理
if (socket.current) {
socket.current.close();
}
@@ -168,8 +159,8 @@ function TerminalPage() {
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: '#1e1e1e', // Dark background
foreground: '#d4d4d4', // Light foreground
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#ffffff',
selectionBackground: '#264f78',
black: '#000000',
@@ -190,7 +181,7 @@ function TerminalPage() {
brightWhite: '#e5e5e5',
},
allowTransparency: false,
scrollback: 5000, // Increase scrollback buffer
scrollback: 5000,
});
fitAddon.current = new FitAddon();
@@ -281,7 +272,6 @@ function TerminalPage() {
</Button>
</div>
</header>
{/* Terminal container takes remaining height */}
<div ref={terminalRef} className="flex-grow p-1 w-full h-full overflow-hidden"></div>
</div>
);

View File

@@ -2,11 +2,10 @@ import axios, { InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'ax
import { authStorage } from '@/contexts/auth-storage';
const api = axios.create({
baseURL: '/', // Assuming the Flask backend serves API at the root
withCredentials: true, // Important for getting cookies
baseURL: '/',
withCredentials: true,
});
// 添加请求拦截器,为每个请求添加令牌
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = authStorage.getToken();
@@ -21,21 +20,14 @@ api.interceptors.request.use(
}
);
// 添加响应拦截器来处理潜在的认证错误
api.interceptors.response.use(
(response: AxiosResponse) => response,
(error: AxiosError) => {
if (error.response && error.response.status === 401) {
// 未授权,清除认证状态
authStorage.clearAuth(); // 使用authStorage清除认证状态
// 重定向到登录页面
authStorage.clearAuth();
window.location.href = '/login';
// 返回一个更具描述性的错误
return Promise.reject(new Error('认证失败,请重新登录'));
}
// For other errors, just pass them through
return Promise.reject(error);
}
);

View File

@@ -1,9 +1,3 @@
/**
* 密码处理工具
*
* 前端不进行实际的加密/解密操作,而是通过识别后端传来的标记决定处理方式
*/
type AuthMethod = 'password' | 'key';
interface HostPasswordState {
@@ -12,47 +6,29 @@ interface HostPasswordState {
is_password_encrypted?: boolean;
}
/**
* 检查密码是否需要重新输入
* @param host 主机信息
* @returns 如果密码已加密且未被修改返回true否则返回false
*/
export const isPasswordEncrypted = (host?: HostPasswordState | null): boolean => {
return host?.is_password_encrypted === true;
};
/**
* 准备主机数据用于API提交
* @param hostData 主机表单数据
* @param originalHost 原始主机数据(编辑时有值)
* @param useKeyAuth 是否使用密钥认证(批量添加时传入)
* @returns 处理后的主机数据
*/
export const prepareHostData = <T extends HostPasswordState>(
hostData: T,
originalHost?: HostPasswordState,
useKeyAuth?: boolean,
): T => {
// 复制主机数据
const preparedData = { ...hostData };
// 设置认证方式
if (useKeyAuth !== undefined) {
preparedData.auth_method = useKeyAuth ? 'key' : 'password';
} else if (originalHost && originalHost.auth_method) {
// 如果是编辑模式且未明确指定useKeyAuth则保留原有认证方式
preparedData.auth_method = originalHost.auth_method;
} else {
// 默认密码认证
preparedData.auth_method = 'password';
}
// 如果是编辑模式,且密码为占位符,表示未修改密码
// 编辑时保留占位密码,交给后端继续沿用原密码
if (originalHost && preparedData.password === '********' && isPasswordEncrypted(originalHost)) {
// 不传递密码字段,后端将保留原密码
delete preparedData.password;
} else if (preparedData.auth_method === 'key') {
// 如果是密钥认证,则不发送密码字段
delete preparedData.password;
}