From f1243245a8c74cea1ebb6b43f545488b41179fc4 Mon Sep 17 00:00:00 2001 From: Image Date: Fri, 26 Dec 2025 09:33:55 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(SSH)=EF=BC=9A=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0known=5Fhosts=E6=8C=81=E4=B9=85=E5=8C=96=E4=B8=8E?= =?UTF-8?q?=E6=B8=85=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增SSH配置目录设置,支持持久化存储known_hosts文件 - 实现known_hosts文件清理API端点,解决主机密钥变更导致的连接问题 - 优化SSH连接策略,使用StrictHostKeyChecking=accept-new自动接受新主机密钥 - 前端添加known_hosts清理按钮,提升SSH密钥管理体验 - 改进SSH测试逻辑,正确处理部署密钥的Anonymous响应 --- backend/app/api/v1/endpoints/ssh_keys.py | 29 +++++++- backend/app/core/config.py | 3 + backend/app/services/git_ssh_service.py | 87 +++++++++++++++++++++--- frontend/src/pages/Account.tsx | 45 ++++++++++-- frontend/src/shared/api/sshKeys.ts | 8 +++ 5 files changed, 158 insertions(+), 14 deletions(-) diff --git a/backend/app/api/v1/endpoints/ssh_keys.py b/backend/app/api/v1/endpoints/ssh_keys.py index eb21442..092e2e2 100644 --- a/backend/app/api/v1/endpoints/ssh_keys.py +++ b/backend/app/api/v1/endpoints/ssh_keys.py @@ -13,7 +13,7 @@ from app.api import deps from app.db.session import get_db from app.models.user import User from app.models.user_config import UserConfig -from app.services.git_ssh_service import SSHKeyService, GitSSHOperations +from app.services.git_ssh_service import SSHKeyService, GitSSHOperations, clear_known_hosts from app.core.encryption import encrypt_sensitive_data, decrypt_sensitive_data router = APIRouter() @@ -225,3 +225,30 @@ async def test_ssh_key( raise except Exception as e: raise HTTPException(status_code=500, detail=f"测试SSH密钥失败: {str(e)}") + + +@router.delete("/known-hosts") +async def clear_known_hosts_file( + current_user: User = Depends(deps.get_current_user), +) -> Any: + """ + 清理known_hosts文件 + + 清空SSH known_hosts文件中保存的所有主机密钥。 + 下次连接时会重新接受并保存新的host key。 + """ + try: + success = clear_known_hosts() + + if success: + return { + "success": True, + "message": "known_hosts文件已清理,下次连接时会重新保存主机密钥" + } + else: + raise HTTPException(status_code=500, detail="清理known_hosts文件失败") + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"清理失败: {str(e)}") diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 061bfeb..47cd6d8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -90,6 +90,9 @@ class Settings(BaseSettings): # 向量数据库配置 VECTOR_DB_PATH: str = "./data/vector_db" # 向量数据库持久化目录 + + # SSH配置 + SSH_CONFIG_PATH: str = "./data/ssh" # SSH配置目录(存储known_hosts等) # Agent 配置 AGENT_MAX_ITERATIONS: int = 50 # Agent 最大迭代次数 diff --git a/backend/app/services/git_ssh_service.py b/backend/app/services/git_ssh_service.py index cd26dcc..4d7f9fa 100644 --- a/backend/app/services/git_ssh_service.py +++ b/backend/app/services/git_ssh_service.py @@ -16,6 +16,66 @@ from cryptography.hazmat.primitives.asymmetric import ed25519, rsa from cryptography.hazmat.backends import default_backend +def get_ssh_config_dir() -> str: + """ + 获取SSH配置目录路径,如果不存在则创建 + + Returns: + SSH配置目录的绝对路径 + """ + from app.core.config import settings + + ssh_config_path = Path(settings.SSH_CONFIG_PATH) + + # 确保目录存在 + ssh_config_path.mkdir(parents=True, exist_ok=True) + + # 设置目录权限(仅所有者可访问) + if sys.platform != 'win32': + os.chmod(ssh_config_path, 0o700) + + return str(ssh_config_path.absolute()) + + +def get_known_hosts_file() -> str: + """ + 获取known_hosts文件路径,如果不存在则创建 + + Returns: + known_hosts文件的绝对路径 + """ + ssh_config_dir = get_ssh_config_dir() + known_hosts_file = Path(ssh_config_dir) / 'known_hosts' + + # 如果文件不存在则创建 + if not known_hosts_file.exists(): + known_hosts_file.touch() + # 设置文件权限 + if sys.platform != 'win32': + os.chmod(known_hosts_file, 0o600) + + return str(known_hosts_file.absolute()) + + +def clear_known_hosts() -> bool: + """ + 清理known_hosts文件内容 + + Returns: + 是否清理成功 + """ + try: + known_hosts_file = get_known_hosts_file() + # 清空文件内容 + with open(known_hosts_file, 'w') as f: + f.write('') + print(f"[SSH] Cleared known_hosts file: {known_hosts_file}") + return True + except Exception as e: + print(f"[SSH] Failed to clear known_hosts: {e}") + return False + + def set_secure_file_permissions(file_path: str): """ 设置文件的安全权限(Unix: 0600, Windows: 只有当前用户可读写) @@ -253,6 +313,9 @@ class GitSSHOperations: f.write(private_key) set_secure_file_permissions(key_file) + # 使用持久化的known_hosts文件 + known_hosts_file = get_known_hosts_file() + # 设置Git SSH命令,只使用DeepAudit生成的SSH密钥 env = os.environ.copy() @@ -260,21 +323,22 @@ class GitSSHOperations: ssh_cmd_parts = [ 'ssh', '-i', key_file, - '-o', 'StrictHostKeyChecking=yes', - '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'StrictHostKeyChecking=accept-new', # 首次连接时自动接受并保存host key + '-o', f'UserKnownHostsFile={known_hosts_file}', # 使用持久化known_hosts文件 '-o', 'PreferredAuthentications=publickey', '-o', 'IdentitiesOnly=yes' # 只使用指定的密钥,不使用系统默认密钥 ] env['GIT_SSH_COMMAND'] = ' '.join(ssh_cmd_parts) - print(f"[Git Clone] Using DeepAudit SSH key only: {key_file}") + print(f"[Git Clone] Using DeepAudit SSH key: {key_file}") + print(f"[Git Clone] Using known_hosts file: {known_hosts_file}") # 执行git clone cmd = ['git', 'clone', '--depth', '1'] if branch: # 只有明确指定分支时才添加 cmd.extend(['--branch', branch]) - cmd.extend([repo_url, target_dir]) - + cmd.extend([repo_url, target_dir]) + result = subprocess.run( cmd, env=env, @@ -414,12 +478,15 @@ class GitSSHOperations: set_secure_file_permissions(key_file) + # 使用持久化的known_hosts文件 + known_hosts_file = get_known_hosts_file() + # 构建SSH命令(只使用DeepAudit密钥) cmd = [ 'ssh', '-i', key_file, - '-o', 'StrictHostKeyChecking=yes', - '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'StrictHostKeyChecking=accept-new', # 首次连接时自动接受并保存host key + '-o', f'UserKnownHostsFile={known_hosts_file}', # 使用持久化known_hosts文件 '-o', 'ConnectTimeout=10', '-o', 'PreferredAuthentications=publickey', '-o', 'IdentitiesOnly=yes', # 只使用指定的密钥,不使用系统默认密钥 @@ -427,6 +494,8 @@ class GitSSHOperations: '-T', f'git@{host_part}' ] + print(f"[SSH Test] Using known_hosts file: {known_hosts_file}") + result = subprocess.run( cmd, capture_output=True, @@ -443,9 +512,9 @@ class GitSSHOperations: # 必须在检查成功之前检查,因为Anonymous表示认证技术上成功但没有关联用户 if 'anonymous' in output_lower: return { - 'success': False, + 'success': True, 'message': 'SSH连接成功,但公钥未关联用户账户', - 'output': f'提示:服务器显示Anonymous表示公钥未添加到Git服务或未关联到您的账户。\n请在Git服务的设置中添加SSH公钥。\n\n原始输出:\n{output}' + 'output': f'提示:服务器显示Anonymous,在使用部署密钥时是正常现象。\n请在Git服务的设置中添加SSH公钥。\n\n原始输出:\n{output}' } # 检查是否认证成功 diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx index b223cdc..9cfe24d 100644 --- a/frontend/src/pages/Account.tsx +++ b/frontend/src/pages/Account.tsx @@ -26,12 +26,13 @@ import { Key, Copy, Trash2, - CheckCircle2 + CheckCircle2, + ServerCrash } from "lucide-react"; import { apiClient } from "@/shared/api/serverClient"; import { toast } from "sonner"; import type { Profile } from "@/shared/types"; -import { generateSSHKey, getSSHKey, deleteSSHKey, testSSHKey } from "@/shared/api/sshKeys"; +import { generateSSHKey, getSSHKey, deleteSSHKey, testSSHKey, clearKnownHosts } from "@/shared/api/sshKeys"; export default function Account() { const navigate = useNavigate(); @@ -56,6 +57,7 @@ export default function Account() { const [sshKey, setSSHKey] = useState<{ has_key: boolean; public_key?: string; fingerprint?: string }>({ has_key: false }); const [generatingKey, setGeneratingKey] = useState(false); const [deletingKey, setDeletingKey] = useState(false); + const [clearingKnownHosts, setClearingKnownHosts] = useState(false); const [testingKey, setTestingKey] = useState(false); const [testRepoUrl, setTestRepoUrl] = useState(""); const [showDeleteKeyDialog, setShowDeleteKeyDialog] = useState(false); @@ -155,6 +157,23 @@ export default function Account() { } }; + const handleClearKnownHosts = async () => { + try { + setClearingKnownHosts(true); + const result = await clearKnownHosts(); + if (result.success) { + toast.success(result.message || "known_hosts已清理"); + } else { + toast.error("清理known_hosts失败"); + } + } catch (error: any) { + console.error('Failed to clear known_hosts:', error); + toast.error(error.response?.data?.detail || "清理known_hosts失败"); + } finally { + setClearingKnownHosts(false); + } + }; + const handleCopyPublicKey = () => { if (sshKey.public_key) { navigator.clipboard.writeText(sshKey.public_key); @@ -523,8 +542,26 @@ export default function Account() { - {/* Delete Key */} -
+ {/* Delete Key and Clear Known Hosts */} +
+