mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/sky22333/ansible-ui
synced 2026-09-20 08:03:57 +08:00
优化注释
This commit is contained in:
@@ -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
94
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/<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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user