From dda2624bc48a4a1e80d7f9a3b6e97397271ded7d Mon Sep 17 00:00:00 2001 From: sky22333 Date: Tue, 21 Apr 2026 22:43:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ansible_manager.py | 57 --------------- app.py | 94 ++----------------------- crypto_utils.py | 43 +---------- database.py | 1 - web/src/App.tsx | 20 +----- web/src/components/FileUpload.tsx | 25 +++---- web/src/components/PlaybookExecutor.tsx | 13 +--- web/src/contexts/AuthContext.tsx | 6 +- web/src/pages/LoginPage.tsx | 1 - web/src/pages/MainPage.tsx | 26 +------ web/src/pages/TerminalPage.tsx | 18 ++--- web/src/services/api.ts | 14 +--- web/src/utils/crypto.ts | 26 +------ 13 files changed, 32 insertions(+), 312 deletions(-) diff --git a/ansible_manager.py b/ansible_manager.py index 15cd8a7..44e1d43 100644 --- a/ansible_manager.py +++ b/ansible_manager.py @@ -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) diff --git a/app.py b/app.py index d21f75e..701b727 100644 --- a/app.py +++ b/app.py @@ -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/', 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', diff --git a/crypto_utils.py b/crypto_utils.py index e469030..a4256d5 100644 --- a/crypto_utils.py +++ b/crypto_utils.py @@ -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 diff --git a/database.py b/database.py index 98c47c9..d852f1e 100644 --- a/database.py +++ b/database.py @@ -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) diff --git a/web/src/App.tsx b/web/src/App.tsx index f31dd91..15990bd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ; } -// Main App component function App() { return ( - {/* Login page route */} } /> - - {/* Main page route - protected */} } /> - - {/* Terminal page route - 不需要强制认证,改为直接访问,内部API调用会处理认证 */} } /> - - {/* Fallback route: Redirect unauthenticated users to login, authenticated users to main */} - {/* Use sonner Toaster, added richColors prop */} + ); } -// Helper component for the fallback route function AuthRedirect() { const { isAuthenticated } = useAuth(); const isLocalAuth = authStorage.getAuth(); diff --git a/web/src/components/FileUpload.tsx b/web/src/components/FileUpload.tsx index 25a5547..3d20127 100644 --- a/web/src/components/FileUpload.tsx +++ b/web/src/components/FileUpload.tsx @@ -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(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(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("/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) {

文件将被上传到目标主机的这个目录下。

- {/* 上传结果显示区域 */} {uploadResult && (

上传结果

diff --git a/web/src/components/PlaybookExecutor.tsx b/web/src/components/PlaybookExecutor.tsx index 92b8e6f..797b59d 100644 --- a/web/src/components/PlaybookExecutor.tsx +++ b/web/src/components/PlaybookExecutor.tsx @@ -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("/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) { )} - {/* 执行结果显示区域 */} {executionResult && (

执行结果

diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 885520b..e02ffef 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -5,16 +5,13 @@ import { authStorage } from '@/contexts/auth-storage'; export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [isAuthenticated, setIsAuthenticated] = useState(() => { - // 初始化时检查本地存储,包括过期时间检查 return authStorage.getAuth(); }); const [token, setToken] = useState(() => { - // 初始化时获取存储的令牌 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); diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 2e4e6e2..685b33a 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -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(); diff --git a/web/src/pages/MainPage.tsx b/web/src/pages/MainPage.tsx index 6bfa59a..8130a2b 100644 --- a/web/src/pages/MainPage.tsx +++ b/web/src/pages/MainPage.tsx @@ -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 (
@@ -442,7 +430,6 @@ function MainPage() {
- {/* Host Management Panel (Takes 2/3 width on large screens) */} 主机管理 @@ -474,7 +461,7 @@ function MainPage() {