feat(ssh):新增SSH密钥认证支持,支持通过SSH方式访问Git仓库

新增SSH密钥管理功能,包括生成、查看、测试和删除SSH密钥对。在agent_tasks.py中集成SSH私钥解密和SSH克隆逻辑,支持git@格式的SSH URL。在projects.py中为SSH URL添加文件获取支持。新增ssh_keys.py端点提供完整的SSH密钥API管理。前端Account页面新增SSH密钥管理界面,Projects页面支持选择SSH Key认证类型。新增git_ssh_service.py提供SSH密钥生成、验证和Git SSH操作功能。
This commit is contained in:
Image 2025-12-24 16:08:56 +08:00
parent 0253ede1ff
commit a79b27a6d2
11 changed files with 4596 additions and 3407 deletions

View File

@ -1,5 +1,5 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, users, projects, tasks, scan, members, config, database, prompts, rules, agent_tasks, embedding_config
from app.api.v1.endpoints import auth, users, projects, tasks, scan, members, config, database, prompts, rules, agent_tasks, embedding_config, ssh_keys
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@ -14,3 +14,4 @@ api_router.include_router(prompts.router, prefix="/prompts", tags=["prompts"])
api_router.include_router(rules.router, prefix="/rules", tags=["rules"])
api_router.include_router(agent_tasks.router, prefix="/agent-tasks", tags=["agent-tasks"])
api_router.include_router(embedding_config.router, prefix="/embedding", tags=["embedding"])
api_router.include_router(ssh_keys.router, prefix="/ssh-keys", tags=["ssh-keys"])

View File

@ -32,6 +32,8 @@ from app.models.user import User
from app.models.user_config import UserConfig
from app.services.agent.event_manager import EventManager
from app.services.agent.streaming import StreamHandler, StreamEvent, StreamEventType
from app.services.git_ssh_service import GitSSHOperations
from app.core.encryption import decrypt_sensitive_data
logger = logging.getLogger(__name__)
router = APIRouter()
@ -288,12 +290,22 @@ async def _execute_agent_task(task_id: str):
# 获取用户配置(需要在获取项目根目录之前,以便传递 token
user_config = await _get_user_config(db, task.created_by)
# 从用户配置中提取 token(用于私有仓库克隆)
# 从用户配置中提取 token和SSH密钥(用于私有仓库克隆)
other_config = (user_config or {}).get('otherConfig', {})
github_token = other_config.get('githubToken') or settings.GITHUB_TOKEN
gitlab_token = other_config.get('gitlabToken') or settings.GITLAB_TOKEN
# 获取项目根目录(传递任务指定的分支和认证 token
# 解密SSH私钥
ssh_private_key = None
if 'sshPrivateKey' in other_config:
try:
encrypted_key = other_config['sshPrivateKey']
ssh_private_key = decrypt_sensitive_data(encrypted_key)
logger.info("成功解密SSH私钥")
except Exception as e:
logger.warning(f"解密SSH私钥失败: {e}")
# 获取项目根目录(传递任务指定的分支和认证 token/SSH密钥
# 🔥 传递 event_emitter 以发送克隆进度
project_root = await _get_project_root(
project,
@ -301,6 +313,7 @@ async def _execute_agent_task(task_id: str):
task.branch_name,
github_token=github_token,
gitlab_token=gitlab_token,
ssh_private_key=ssh_private_key, # 🔥 新增SSH密钥
event_emitter=event_emitter, # 🔥 新增
)
@ -2213,6 +2226,7 @@ async def _get_project_root(
branch_name: Optional[str] = None,
github_token: Optional[str] = None,
gitlab_token: Optional[str] = None,
ssh_private_key: Optional[str] = None, # 🔥 新增SSH私钥用于SSH认证
event_emitter: Optional[Any] = None, # 🔥 新增:用于发送实时日志
) -> str:
"""
@ -2228,6 +2242,7 @@ async def _get_project_root(
branch_name: 分支名称仓库项目使用优先于 project.default_branch
github_token: GitHub 访问令牌用于私有仓库
gitlab_token: GitLab 访问令牌用于私有仓库
ssh_private_key: SSH私钥用于SSH认证
event_emitter: 事件发送器用于发送实时日志
Returns:
@ -2303,6 +2318,9 @@ async def _get_project_root(
await emit(f"🔄 正在获取仓库: {repo_url}")
# 检测是否为SSH URLSSH链接不支持ZIP下载
is_ssh_url = repo_url.startswith('git@')
# 解析仓库 URL 获取 owner/repo
parsed = urlparse(repo_url)
path_parts = parsed.path.strip('/').replace('.git', '').split('/')
@ -2325,7 +2343,12 @@ async def _get_project_root(
last_error = ""
# ============ 方案1: 优先使用 ZIP 下载(更快更稳定)============
if owner and repo:
# SSH链接直接跳过ZIP下载使用git clone
if is_ssh_url:
logger.info(f"检测到SSH URL跳过ZIP下载直接使用Git克隆")
await emit(f"🔑 检测到SSH认证使用Git克隆...")
if owner and repo and not is_ssh_url:
import httpx
for branch in branches_to_try:
@ -2433,8 +2456,12 @@ async def _get_project_root(
# ============ 方案2: 回退到 git clone ============
if not download_success:
await emit(f"🔄 ZIP 下载失败,回退到 Git 克隆...")
logger.info("ZIP download failed, falling back to git clone")
if is_ssh_url:
# SSH链接直接使用git clone不是"失败"
pass # 已在上面输出提示
else:
await emit(f"🔄 ZIP 下载失败,回退到 Git 克隆...")
logger.info("ZIP download failed, falling back to git clone")
# 检查 git 是否可用
try:
@ -2476,6 +2503,8 @@ async def _get_project_root(
parsed.fragment
))
await emit(f"🔐 使用 GitLab Token 认证")
elif is_ssh_url and ssh_private_key:
await emit(f"🔐 使用 SSH Key 认证")
for branch in branches_to_try:
check_cancelled()
@ -2488,36 +2517,68 @@ async def _get_project_root(
await emit(f"🔄 尝试克隆分支: {branch}")
try:
async def run_clone():
return await asyncio.to_thread(
subprocess.run,
["git", "clone", "--depth", "1", "--branch", branch, auth_url, base_path],
capture_output=True,
text=True,
timeout=120,
)
# SSH URL使用GitSSHOperations支持SSH密钥认证
if is_ssh_url and ssh_private_key:
async def run_ssh_clone():
return await asyncio.to_thread(
GitSSHOperations.clone_repo_with_ssh,
repo_url, ssh_private_key, base_path, branch
)
clone_task = asyncio.create_task(run_clone())
while not clone_task.done():
check_cancelled()
try:
result = await asyncio.wait_for(asyncio.shield(clone_task), timeout=1.0)
clone_task = asyncio.create_task(run_ssh_clone())
while not clone_task.done():
check_cancelled()
try:
result = await asyncio.wait_for(asyncio.shield(clone_task), timeout=1.0)
break
except asyncio.TimeoutError:
continue
if clone_task.done():
result = clone_task.result()
# GitSSHOperations返回字典格式
if result.get('success'):
logger.info(f"✅ Git 克隆成功 (SSH, 分支: {branch})")
await emit(f"✅ 仓库获取成功 (SSH克隆, 分支: {branch})")
download_success = True
break
except asyncio.TimeoutError:
continue
if clone_task.done():
result = clone_task.result()
if result.returncode == 0:
logger.info(f"✅ Git 克隆成功 (分支: {branch})")
await emit(f"✅ 仓库获取成功 (Git克隆, 分支: {branch})")
download_success = True
break
else:
last_error = result.get('message', '未知错误')
logger.warning(f"SSH克隆失败 (分支 {branch}): {last_error[:200]}")
await emit(f"⚠️ 分支 {branch} SSH克隆失败...", "warning")
else:
last_error = result.stderr
logger.warning(f"克隆失败 (分支 {branch}): {last_error[:200]}")
await emit(f"⚠️ 分支 {branch} 克隆失败...", "warning")
# HTTPS URL使用标准git clone
async def run_clone():
return await asyncio.to_thread(
subprocess.run,
["git", "clone", "--depth", "1", "--branch", branch, auth_url, base_path],
capture_output=True,
text=True,
timeout=120,
)
clone_task = asyncio.create_task(run_clone())
while not clone_task.done():
check_cancelled()
try:
result = await asyncio.wait_for(asyncio.shield(clone_task), timeout=1.0)
break
except asyncio.TimeoutError:
continue
if clone_task.done():
result = clone_task.result()
if result.returncode == 0:
logger.info(f"✅ Git 克隆成功 (分支: {branch})")
await emit(f"✅ 仓库获取成功 (Git克隆, 分支: {branch})")
download_success = True
break
else:
last_error = result.stderr
logger.warning(f"克隆失败 (分支 {branch}): {last_error[:200]}")
await emit(f"⚠️ 分支 {branch} 克隆失败...", "warning")
except subprocess.TimeoutExpired:
last_error = f"克隆分支 {branch} 超时"
logger.warning(last_error)
@ -2536,33 +2597,61 @@ async def _get_project_root(
os.makedirs(base_path, exist_ok=True)
try:
async def run_default_clone():
return await asyncio.to_thread(
subprocess.run,
["git", "clone", "--depth", "1", auth_url, base_path],
capture_output=True,
text=True,
timeout=120,
)
# SSH URL使用GitSSHOperations不指定分支
if is_ssh_url and ssh_private_key:
async def run_default_ssh_clone():
return await asyncio.to_thread(
GitSSHOperations.clone_repo_with_ssh,
repo_url, ssh_private_key, base_path, "" # 空字符串表示使用默认分支
)
clone_task = asyncio.create_task(run_default_clone())
while not clone_task.done():
check_cancelled()
try:
result = await asyncio.wait_for(asyncio.shield(clone_task), timeout=1.0)
break
except asyncio.TimeoutError:
continue
clone_task = asyncio.create_task(run_default_ssh_clone())
while not clone_task.done():
check_cancelled()
try:
result = await asyncio.wait_for(asyncio.shield(clone_task), timeout=1.0)
break
except asyncio.TimeoutError:
continue
if clone_task.done():
result = clone_task.result()
if clone_task.done():
result = clone_task.result()
if result.returncode == 0:
logger.info(f"✅ Git 克隆成功 (默认分支)")
await emit(f"✅ 仓库获取成功 (Git克隆, 默认分支)")
download_success = True
if result.get('success'):
logger.info(f"✅ Git 克隆成功 (SSH, 默认分支)")
await emit(f"✅ 仓库获取成功 (SSH克隆, 默认分支)")
download_success = True
else:
last_error = result.get('message', '未知错误')
else:
last_error = result.stderr
# HTTPS URL使用标准git clone
async def run_default_clone():
return await asyncio.to_thread(
subprocess.run,
["git", "clone", "--depth", "1", auth_url, base_path],
capture_output=True,
text=True,
timeout=120,
)
clone_task = asyncio.create_task(run_default_clone())
while not clone_task.done():
check_cancelled()
try:
result = await asyncio.wait_for(asyncio.shield(clone_task), timeout=1.0)
break
except asyncio.TimeoutError:
continue
if clone_task.done():
result = clone_task.result()
if result.returncode == 0:
logger.info(f"✅ Git 克隆成功 (默认分支)")
await emit(f"✅ 仓库获取成功 (Git克隆, 默认分支)")
download_success = True
else:
last_error = result.stderr
except subprocess.TimeoutExpired:
last_error = "克隆超时"
except asyncio.CancelledError:

View File

@ -378,22 +378,24 @@ async def get_project_files(
# Handle Repository project
if not project.repository_url:
return []
# Get tokens from user config
from sqlalchemy.future import select
from app.core.encryption import decrypt_sensitive_data
from app.core.config import settings
from app.services.git_ssh_service import GitSSHOperations
SENSITIVE_OTHER_FIELDS = ['githubToken', 'gitlabToken', 'sshPrivateKey']
SENSITIVE_OTHER_FIELDS = ['githubToken', 'gitlabToken']
result = await db.execute(
select(UserConfig).where(UserConfig.user_id == current_user.id)
)
config = result.scalar_one_or_none()
github_token = settings.GITHUB_TOKEN
gitlab_token = settings.GITLAB_TOKEN
ssh_private_key = None
if config and config.other_config:
other_config = json.loads(config.other_config)
for field in SENSITIVE_OTHER_FIELDS:
@ -403,20 +405,46 @@ async def get_project_files(
github_token = decrypted_val
elif field == 'gitlabToken':
gitlab_token = decrypted_val
elif field == 'sshPrivateKey':
ssh_private_key = decrypted_val
repo_type = project.repository_type or "other"
# 使用传入的 branch 参数,如果没有则使用项目默认分支
# 检查是否为SSH URL
is_ssh_url = GitSSHOperations.is_ssh_url(project.repository_url)
target_branch = branch or project.default_branch or "main"
try:
if repo_type == "github":
# 传入用户自定义排除模式
repo_files = await get_github_files(project.repository_url, target_branch, github_token, parsed_exclude_patterns)
files = [{"path": f["path"], "size": 0} for f in repo_files]
elif repo_type == "gitlab":
# 传入用户自定义排除模式
repo_files = await get_gitlab_files(project.repository_url, target_branch, gitlab_token, parsed_exclude_patterns)
files = [{"path": f["path"], "size": 0} for f in repo_files]
if is_ssh_url:
# 使用SSH方式获取文件列表
if not ssh_private_key:
raise HTTPException(
status_code=400,
detail="仓库使用SSH URL但未配置SSH密钥。请先在设置中生成SSH密钥。"
)
print(f"🔐 使用SSH方式获取文件列表: {project.repository_url}")
files_with_content = GitSSHOperations.get_repo_files_via_ssh(
project.repository_url,
ssh_private_key,
target_branch,
parsed_exclude_patterns
)
files = [{"path": f["path"], "size": len(f.get("content", ""))} for f in files_with_content]
else:
# 使用API方式获取文件列表
repo_type = project.repository_type or "other"
if repo_type == "github":
# 传入用户自定义排除模式
repo_files = await get_github_files(project.repository_url, target_branch, github_token, parsed_exclude_patterns)
files = [{"path": f["path"], "size": 0} for f in repo_files]
elif repo_type == "gitlab":
# 传入用户自定义排除模式
repo_files = await get_gitlab_files(project.repository_url, target_branch, gitlab_token, parsed_exclude_patterns)
files = [{"path": f["path"], "size": 0} for f in repo_files]
else:
raise HTTPException(status_code=400, detail="不支持的仓库类型")
except HTTPException:
raise
except Exception as e:
print(f"Error fetching repo files: {e}")
raise HTTPException(status_code=500, detail=f"无法获取仓库文件: {str(e)}")

View File

@ -0,0 +1,227 @@
"""
SSH密钥管理API端点
"""
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from pydantic import BaseModel
import json
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.core.encryption import encrypt_sensitive_data, decrypt_sensitive_data
router = APIRouter()
# Schemas
class SSHKeyGenerateResponse(BaseModel):
public_key: str
message: str
class SSHKeyResponse(BaseModel):
has_key: bool
public_key: Optional[str] = None
fingerprint: Optional[str] = None
class SSHKeyTestRequest(BaseModel):
repo_url: str
class SSHKeyTestResponse(BaseModel):
success: bool
message: str
output: Optional[str] = None
@router.post("/generate", response_model=SSHKeyGenerateResponse)
async def generate_ssh_key(
*,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
生成新的SSH密钥对
生成RSA 4096格式的SSH密钥对私钥加密存储在用户配置中公钥返回给用户
"""
try:
# 生成SSH密钥对 (RSA 4096)
private_key, public_key = SSHKeyService.generate_rsa_key(key_size=4096)
# 获取或创建用户配置
result = await db.execute(
select(UserConfig).where(UserConfig.user_id == current_user.id)
)
user_config = result.scalar_one_or_none()
if not user_config:
user_config = UserConfig(
user_id=current_user.id,
llm_config="{}",
other_config="{}"
)
db.add(user_config)
# 解析现有的other_config
other_config = json.loads(user_config.other_config) if user_config.other_config else {}
# 加密并存储私钥
encrypted_private_key = encrypt_sensitive_data(private_key)
other_config['sshPrivateKey'] = encrypted_private_key
other_config['sshPublicKey'] = public_key # 公钥不需要加密
# 更新配置
user_config.other_config = json.dumps(other_config)
await db.commit()
# 计算公钥指纹
fingerprint = SSHKeyService.get_public_key_fingerprint(public_key)
return {
"public_key": public_key,
"fingerprint": fingerprint,
"message": "SSH密钥生成成功请将公钥添加到您的GitHub/GitLab账户"
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"生成SSH密钥失败: {str(e)}")
@router.get("/", response_model=SSHKeyResponse)
async def get_ssh_key(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
获取当前用户的SSH公钥
"""
try:
# 获取用户配置
result = await db.execute(
select(UserConfig).where(UserConfig.user_id == current_user.id)
)
user_config = result.scalar_one_or_none()
if not user_config or not user_config.other_config:
return {"has_key": False}
# 解析配置
other_config = json.loads(user_config.other_config)
if 'sshPublicKey' in other_config:
public_key = other_config['sshPublicKey']
fingerprint = SSHKeyService.get_public_key_fingerprint(public_key)
return {
"has_key": True,
"public_key": public_key,
"fingerprint": fingerprint
}
else:
return {"has_key": False}
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取SSH密钥失败: {str(e)}")
@router.delete("/")
async def delete_ssh_key(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
) -> Any:
"""
删除当前用户的SSH密钥
"""
try:
# 获取用户配置
result = await db.execute(
select(UserConfig).where(UserConfig.user_id == current_user.id)
)
user_config = result.scalar_one_or_none()
if not user_config or not user_config.other_config:
raise HTTPException(status_code=404, detail="未找到SSH密钥")
# 解析配置
other_config = json.loads(user_config.other_config)
# 删除SSH密钥
if 'sshPrivateKey' in other_config:
del other_config['sshPrivateKey']
if 'sshPublicKey' in other_config:
del other_config['sshPublicKey']
# 更新配置
user_config.other_config = json.dumps(other_config)
await db.commit()
return {"message": "SSH密钥已删除"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"删除SSH密钥失败: {str(e)}")
@router.post("/test", response_model=SSHKeyTestResponse)
async def test_ssh_key(
*,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(deps.get_current_user),
test_request: SSHKeyTestRequest,
) -> Any:
"""
测试SSH密钥是否有效
Args:
test_request: 包含repo_url的测试请求
"""
try:
# 获取用户配置
result = await db.execute(
select(UserConfig).where(UserConfig.user_id == current_user.id)
)
user_config = result.scalar_one_or_none()
if not user_config or not user_config.other_config:
raise HTTPException(status_code=404, detail="未找到SSH密钥请先生成SSH密钥")
# 解析配置
other_config = json.loads(user_config.other_config)
if 'sshPrivateKey' not in other_config:
raise HTTPException(status_code=404, detail="未找到SSH密钥请先生成SSH密钥")
# 解密私钥
private_key = decrypt_sensitive_data(other_config['sshPrivateKey'])
public_key = other_config.get('sshPublicKey', '')
# 验证密钥对是否匹配
is_valid = SSHKeyService.verify_key_pair(private_key, public_key)
print(f"[SSH Test API] Key pair valid: {is_valid}")
if not is_valid:
return {
"success": False,
"message": "密钥对验证失败:私钥和公钥不匹配",
"output": "请重新生成SSH密钥"
}
# 测试SSH连接
result = GitSSHOperations.test_ssh_key(test_request.repo_url, private_key)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"测试SSH密钥失败: {str(e)}")

View File

@ -0,0 +1,478 @@
"""
Git SSH服务 - 生成SSH密钥并使用SSH方式访问Git仓库
"""
import os
import sys
import tempfile
import subprocess
import shutil
import hashlib
import base64
from typing import Tuple, Optional, Dict, List
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
from cryptography.hazmat.backends import default_backend
def set_secure_file_permissions(file_path: str):
"""
设置文件的安全权限Unix: 0600, Windows: 只有当前用户可读写
Args:
file_path: 文件路径
"""
if sys.platform == 'win32':
# Windows系统使用icacls命令设置权限
try:
# 移除所有继承的权限
subprocess.run(
['icacls', file_path, '/inheritance:r'],
capture_output=True,
check=True
)
# 只给当前用户完全控制权限
subprocess.run(
['icacls', file_path, '/grant:r', f'{os.environ.get("USERNAME")}:(F)'],
capture_output=True,
check=True
)
except Exception as e:
print(f"Warning: Failed to set Windows file permissions: {e}")
# 尝试使用os.chmod作为后备方案
try:
os.chmod(file_path, 0o600)
except:
pass
else:
# Unix/Linux/Mac系统
os.chmod(file_path, 0o600)
class SSHKeyService:
"""SSH密钥服务"""
@staticmethod
def get_public_key_fingerprint(public_key: str) -> Optional[str]:
"""
计算SSH公钥的SHA256指纹
Args:
public_key: SSH公钥OpenSSH格式
Returns:
SHA256指纹字符串格式如: SHA256:Js1ypfoB+N2IfrCGgSj81vHnK4F/XxUV6Y9KUwKoFx8
"""
try:
# 解析公钥 (格式: ssh-ed25519 AAAAC3Nza...)
parts = public_key.strip().split()
if len(parts) < 2:
return None
# 获取base64编码的公钥数据
key_data = parts[1]
# 解码base64
key_bytes = base64.b64decode(key_data)
# 计算SHA256哈希
sha256_hash = hashlib.sha256(key_bytes).digest()
# 转换为base64无填充
fingerprint = base64.b64encode(sha256_hash).decode('utf-8').rstrip('=')
return f"SHA256:{fingerprint}"
except Exception as e:
print(f"[SSH] Fingerprint calculation error: {e}")
return None
@staticmethod
def verify_key_pair(private_key: str, public_key: str) -> bool:
"""
验证私钥和公钥是否匹配
Args:
private_key: SSH私钥OpenSSH格式
public_key: SSH公钥OpenSSH格式
Returns:
是否匹配
"""
try:
from cryptography.hazmat.primitives.serialization import load_ssh_private_key
from cryptography.hazmat.backends import default_backend
# 加载私钥
private_key_obj = load_ssh_private_key(
private_key.encode('utf-8'),
password=None,
backend=default_backend()
)
# 从私钥导出公钥
derived_public_key = private_key_obj.public_key()
derived_public_bytes = derived_public_key.public_bytes(
encoding=serialization.Encoding.OpenSSH,
format=serialization.PublicFormat.OpenSSH
).decode('utf-8').strip()
# 比较(去除可能的注释部分)
expected_public = public_key.split()[0] + ' ' + public_key.split()[1]
actual_public = derived_public_bytes.split()[0] + ' ' + derived_public_bytes.split()[1]
return expected_public == actual_public
except Exception as e:
print(f"[SSH] Key verification error: {e}")
return False
@staticmethod
def generate_rsa_key(key_size: int = 4096) -> Tuple[str, str]:
"""
生成RSA SSH密钥对
Args:
key_size: RSA密钥大小比特默认4096
Returns:
(private_key, public_key): 私钥和公钥的元组私钥使用传统PEM格式
"""
# 生成RSA私钥
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=key_size,
backend=default_backend()
)
# 序列化私钥为传统PEM格式BEGIN RSA PRIVATE KEY兼容性更好
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
).decode('utf-8')
# 获取公钥并序列化为OpenSSH格式
public_key = private_key.public_key()
public_openssh = public_key.public_bytes(
encoding=serialization.Encoding.OpenSSH,
format=serialization.PublicFormat.OpenSSH
).decode('utf-8')
return private_pem, public_openssh
@staticmethod
def generate_ed25519_key() -> Tuple[str, str]:
"""
生成ED25519 SSH密钥对备用方法默认使用RSA
Returns:
(private_key, public_key): 私钥和公钥的元组都是OpenSSH格式
"""
# 生成ED25519私钥
private_key = ed25519.Ed25519PrivateKey.generate()
# 序列化私钥为OpenSSH格式
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.OpenSSH,
encryption_algorithm=serialization.NoEncryption()
).decode('utf-8')
# 获取公钥并序列化为OpenSSH格式
public_key = private_key.public_key()
public_openssh = public_key.public_bytes(
encoding=serialization.Encoding.OpenSSH,
format=serialization.PublicFormat.OpenSSH
).decode('utf-8')
return private_pem, public_openssh
class GitSSHOperations:
"""Git SSH操作类 - 使用SSH密钥克隆和拉取仓库"""
@staticmethod
def is_ssh_url(url: str) -> bool:
"""
判断URL是否为SSH格式
Args:
url: Git仓库URL
Returns:
是否为SSH URL
"""
return url.startswith('git@') or url.startswith('ssh://')
@staticmethod
def clone_repo_with_ssh(repo_url: str, private_key: str, target_dir: str, branch: str = "main") -> Dict[str, any]:
"""
使用SSH密钥克隆Git仓库
Args:
repo_url: SSH格式的Git URL (例如: git@github.com:user/repo.git)
private_key: SSH私钥内容
target_dir: 目标目录
branch: 分支名称
Returns:
操作结果字典
"""
temp_dir = None
try:
# 创建临时目录存放SSH密钥
temp_dir = tempfile.mkdtemp(prefix='deepaudit_ssh_')
key_file = os.path.join(temp_dir, 'id_rsa')
# 写入私钥
with open(key_file, 'w') as f:
f.write(private_key)
set_secure_file_permissions(key_file)
# 设置Git SSH命令只使用DeepAudit生成的SSH密钥
env = os.environ.copy()
# 构建SSH命令只使用DeepAudit密钥
ssh_cmd_parts = [
'ssh',
'-i', key_file,
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-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}")
# 执行git clone
cmd = ['git', 'clone', '--depth', '1', '--branch', branch, repo_url, target_dir]
result = subprocess.run(
cmd,
env=env,
capture_output=True,
text=True,
timeout=300
)
if result.returncode == 0:
return {
'success': True,
'message': '仓库克隆成功',
'path': target_dir
}
else:
return {
'success': False,
'message': '仓库克隆失败',
'error': result.stderr
}
except subprocess.TimeoutExpired:
return {'success': False, 'message': '克隆超时超过5分钟'}
except Exception as e:
return {'success': False, 'message': f'克隆失败: {str(e)}'}
finally:
# 清理临时文件
if temp_dir and os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)
@staticmethod
def get_repo_files_via_ssh(repo_url: str, private_key: str, branch: str = "main",
exclude_patterns: List[str] = None) -> List[Dict[str, str]]:
"""
通过SSH克隆仓库并获取文件列表
Args:
repo_url: SSH格式的Git URL
private_key: SSH私钥
branch: 分支名称
exclude_patterns: 排除模式列表
Returns:
文件列表每个文件包含path和内容
"""
temp_clone_dir = None
try:
# 创建临时克隆目录
temp_clone_dir = tempfile.mkdtemp(prefix='deepaudit_clone_')
# 克隆仓库
clone_result = GitSSHOperations.clone_repo_with_ssh(
repo_url, private_key, temp_clone_dir, branch
)
if not clone_result['success']:
raise Exception(f"克隆仓库失败: {clone_result.get('error', '')}")
# 扫描目录获取文件列表
from app.services.scanner import is_text_file, should_exclude
files = []
for root, dirs, filenames in os.walk(temp_clone_dir):
# 排除.git目录
if '.git' in dirs:
dirs.remove('.git')
for filename in filenames:
file_path = os.path.join(root, filename)
# 获取相对路径
rel_path = os.path.relpath(file_path, temp_clone_dir)
# 检查是否应该排除
if should_exclude(rel_path, exclude_patterns):
continue
# 只处理文本文件
if not is_text_file(rel_path):
continue
try:
# 读取文件内容
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
files.append({
'path': rel_path.replace('\\', '/'), # 统一使用/作为路径分隔符
'content': content
})
except Exception as e:
print(f"读取文件 {rel_path} 失败: {e}")
continue
return files
except Exception as e:
print(f"获取SSH仓库文件失败: {e}")
raise
finally:
# 清理临时克隆目录
if temp_clone_dir and os.path.exists(temp_clone_dir):
shutil.rmtree(temp_clone_dir, ignore_errors=True)
@staticmethod
def test_ssh_key(repo_url: str, private_key: str) -> Dict[str, any]:
"""
测试SSH密钥是否有效
Args:
repo_url: SSH格式的Git URL
private_key: SSH私钥
Returns:
测试结果字典
"""
temp_dir = None
try:
# 从URL提取主机
if '@' in repo_url:
host_part = repo_url.split('@')[1].split(':')[0]
else:
return {'success': False, 'message': 'URL格式无效'}
# 创建临时目录存放密钥
temp_dir = tempfile.mkdtemp(prefix='deepaudit_ssh_test_')
key_file = os.path.join(temp_dir, 'id_rsa')
# 写入私钥
with open(key_file, 'w') as f:
f.write(private_key)
# 验证文件是否被创建
if not os.path.exists(key_file):
return {'success': False, 'message': f'私钥文件创建失败: {key_file}'}
file_size = os.path.getsize(key_file)
set_secure_file_permissions(key_file)
# 构建SSH命令只使用DeepAudit密钥
cmd = [
'ssh',
'-i', key_file,
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'ConnectTimeout=10',
'-o', 'PreferredAuthentications=publickey',
'-o', 'IdentitiesOnly=yes', # 只使用指定的密钥,不使用系统默认密钥
'-v', # 详细输出
'-T', f'git@{host_part}'
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=15
)
# GitHub/GitLab/CodeUp的SSH测试通常返回非0状态码但会在输出中显示认证成功
output = result.stdout + result.stderr
output_lower = output.lower()
# 特别检查Anonymous表示公钥未添加或未关联用户账户
# 必须在检查成功之前检查因为Anonymous表示认证技术上成功但没有关联用户
if 'anonymous' in output_lower:
return {
'success': False,
'message': 'SSH连接成功但公钥未关联用户账户',
'output': f'提示服务器显示Anonymous表示公钥未添加到Git服务或未关联到您的账户。\n请在Git服务的设置中添加SSH公钥。\n\n原始输出:\n{output}'
}
# 检查是否认证成功必须有用户名不能是Anonymous
success_indicators = [
('successfully authenticated', True), # GitHub
('hi ', True), # GitHub: "Hi username!"
('welcome to gitlab', '@' in output), # GitLab需要有@username
('welcome to codeup', '@' in output), # CodeUp需要有@username
]
is_success = False
for indicator, extra_check in success_indicators:
if indicator in output_lower:
if extra_check is True or extra_check:
is_success = True
break
if is_success:
return {
'success': True,
'message': 'SSH密钥验证成功',
'output': output
}
else:
# 提供更详细的错误信息
error_msg = 'SSH密钥验证失败'
if 'permission denied' in output_lower:
error_msg = 'SSH密钥验证失败权限被拒绝请确认公钥已添加到Git服务'
elif 'connection refused' in output_lower:
error_msg = 'SSH连接被拒绝请检查网络连接'
elif 'no route to host' in output_lower:
error_msg = 'SSH连接失败无法连接到主机'
elif not output.strip():
error_msg = 'SSH连接失败未收到任何响应'
return {
'success': False,
'message': error_msg,
'output': output if output.strip() else '未收到任何响应'
}
except subprocess.TimeoutExpired:
return {
'success': False,
'message': 'SSH连接超时15秒',
'output': '连接超时请检查网络或Git服务可用性'
}
except Exception as e:
return {
'success': False,
'message': f'测试失败: {str(e)}',
'output': str(e)
}
finally:
if temp_dir and os.path.exists(temp_dir):
shutil.rmtree(temp_dir, ignore_errors=True)

View File

@ -313,53 +313,81 @@ async def scan_repo_task(task_id: str, db_session_factory, user_config: dict = N
github_token = user_other_config.get('githubToken') or settings.GITHUB_TOKEN
gitlab_token = user_other_config.get('gitlabToken') or settings.GITLAB_TOKEN
# 获取SSH私钥如果配置了
ssh_private_key = None
if 'sshPrivateKey' in user_other_config:
from app.core.encryption import decrypt_sensitive_data
ssh_private_key = decrypt_sensitive_data(user_other_config['sshPrivateKey'])
files: List[Dict[str, str]] = []
extracted_gitlab_token = None
# 构建分支尝试顺序(分支降级机制)
branches_to_try = [branch]
if project.default_branch and project.default_branch != branch:
branches_to_try.append(project.default_branch)
for common_branch in ["main", "master"]:
if common_branch not in branches_to_try:
branches_to_try.append(common_branch)
# 检查是否为SSH URL
from app.services.git_ssh_service import GitSSHOperations
is_ssh_url = GitSSHOperations.is_ssh_url(repo_url)
actual_branch = branch # 实际使用的分支
last_error = None
if is_ssh_url:
# 使用SSH方式获取文件
if not ssh_private_key:
raise Exception("仓库使用SSH URL但未配置SSH密钥。请先生成并配置SSH密钥。")
for try_branch in branches_to_try:
print(f"🔐 使用SSH方式访问仓库: {repo_url}")
try:
print(f"🔄 尝试获取分支 {try_branch} 的文件列表...")
if repo_type == "github":
files = await get_github_files(repo_url, try_branch, github_token, task_exclude_patterns)
elif repo_type == "gitlab":
files = await get_gitlab_files(repo_url, try_branch, gitlab_token, task_exclude_patterns)
# GitLab文件可能带有token
if files and 'token' in files[0]:
extracted_gitlab_token = files[0].get('token')
else:
raise Exception("不支持的仓库类型,仅支持 GitHub 和 GitLab 仓库")
if files:
actual_branch = try_branch
if try_branch != branch:
print(f"⚠️ 分支 {branch} 不存在或无法访问,已降级到分支 {try_branch}")
break
files_with_content = GitSSHOperations.get_repo_files_via_ssh(
repo_url, ssh_private_key, branch, task_exclude_patterns
)
# 转换为统一格式
files = [{'path': f['path'], 'content': f['content']} for f in files_with_content]
actual_branch = branch
print(f"✅ 通过SSH成功获取 {len(files)} 个文件")
except Exception as e:
last_error = str(e)
print(f"⚠️ 获取分支 {try_branch} 失败: {last_error[:100]}")
continue
raise Exception(f"SSH方式获取仓库文件失败: {str(e)}")
else:
# 使用API方式获取文件原有逻辑
# 构建分支尝试顺序(分支降级机制)
branches_to_try = [branch]
if project.default_branch and project.default_branch != branch:
branches_to_try.append(project.default_branch)
for common_branch in ["main", "master"]:
if common_branch not in branches_to_try:
branches_to_try.append(common_branch)
if not files:
error_msg = f"无法获取仓库文件,所有分支尝试均失败"
if last_error:
if "404" in last_error or "Not Found" in last_error:
error_msg = f"仓库或分支不存在: {branch}"
elif "401" in last_error or "403" in last_error:
error_msg = "无访问权限,请检查 Token 配置"
else:
error_msg = f"获取文件失败: {last_error[:100]}"
raise Exception(error_msg)
actual_branch = branch # 实际使用的分支
last_error = None
for try_branch in branches_to_try:
try:
print(f"🔄 尝试获取分支 {try_branch} 的文件列表...")
if repo_type == "github":
files = await get_github_files(repo_url, try_branch, github_token, task_exclude_patterns)
elif repo_type == "gitlab":
files = await get_gitlab_files(repo_url, try_branch, gitlab_token, task_exclude_patterns)
# GitLab文件可能带有token
if files and 'token' in files[0]:
extracted_gitlab_token = files[0].get('token')
else:
raise Exception("不支持的仓库类型,仅支持 GitHub 和 GitLab 仓库")
if files:
actual_branch = try_branch
if try_branch != branch:
print(f"⚠️ 分支 {branch} 不存在或无法访问,已降级到分支 {try_branch}")
break
except Exception as e:
last_error = str(e)
print(f"⚠️ 获取分支 {try_branch} 失败: {last_error[:100]}")
continue
if not files:
error_msg = f"无法获取仓库文件,所有分支尝试均失败"
if last_error:
if "404" in last_error or "Not Found" in last_error:
error_msg = f"仓库或分支不存在: {branch}"
elif "401" in last_error or "403" in last_error:
error_msg = "无访问权限,请检查 Token 配置"
else:
error_msg = f"获取文件失败: {last_error[:100]}"
raise Exception(error_msg)
print(f"✅ 成功获取分支 {actual_branch} 的文件列表")
@ -409,14 +437,21 @@ async def scan_repo_task(task_id: str, db_session_factory, user_config: dict = N
try:
# 获取文件内容
headers = {}
# 使用提取的 GitLab token 或用户配置的 token
token_to_use = extracted_gitlab_token or gitlab_token
if token_to_use:
headers["PRIVATE-TOKEN"] = token_to_use
print(f"📥 正在获取文件: {file_info['path']}")
content = await fetch_file_content(file_info["url"], headers)
if is_ssh_url:
# SSH方式已经包含了文件内容
content = file_info.get('content', '')
print(f"📥 正在处理SSH文件: {file_info['path']}")
else:
# API方式需要下载文件内容
headers = {}
# 使用提取的 GitLab token 或用户配置的 token
token_to_use = extracted_gitlab_token or gitlab_token
if token_to_use:
headers["PRIVATE-TOKEN"] = token_to_use
print(f"📥 正在获取文件: {file_info['path']}")
content = await fetch_file_content(file_info["url"], headers)
if not content or not content.strip():
print(f"⚠️ 文件内容为空,跳过: {file_info['path']}")
skipped_files += 1

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Textarea } from "@/components/ui/textarea";
import {
User,
Mail,
@ -21,11 +22,16 @@ import {
LogOut,
UserPlus,
GitBranch,
Terminal
Terminal,
Key,
Copy,
Trash2,
CheckCircle2
} 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";
export default function Account() {
const navigate = useNavigate();
@ -46,8 +52,17 @@ export default function Account() {
});
const [changingPassword, setChangingPassword] = useState(false);
// SSH Key states
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 [testingKey, setTestingKey] = useState(false);
const [testRepoUrl, setTestRepoUrl] = useState("");
const [showDeleteKeyDialog, setShowDeleteKeyDialog] = useState(false);
useEffect(() => {
loadProfile();
loadSSHKey();
}, []);
const loadProfile = async () => {
@ -69,6 +84,84 @@ export default function Account() {
}
};
const loadSSHKey = async () => {
try {
const data = await getSSHKey();
setSSHKey(data);
} catch (error) {
console.error('Failed to load SSH key:', error);
}
};
const handleGenerateSSHKey = async () => {
try {
setGeneratingKey(true);
const data = await generateSSHKey();
setSSHKey({ has_key: true, public_key: data.public_key, fingerprint: data.fingerprint });
toast.success(data.message);
} catch (error: any) {
console.error('Failed to generate SSH key:', error);
toast.error(error.response?.data?.detail || "生成SSH密钥失败");
} finally {
setGeneratingKey(false);
}
};
const handleDeleteSSHKey = async () => {
try {
setDeletingKey(true);
await deleteSSHKey();
setSSHKey({ has_key: false });
toast.success("SSH密钥已删除");
setShowDeleteKeyDialog(false);
} catch (error: any) {
console.error('Failed to delete SSH key:', error);
toast.error(error.response?.data?.detail || "删除SSH密钥失败");
} finally {
setDeletingKey(false);
}
};
const handleTestSSHKey = async () => {
if (!testRepoUrl) {
toast.error("请输入仓库URL");
return;
}
try {
setTestingKey(true);
const result = await testSSHKey(testRepoUrl);
if (result.success) {
toast.success("SSH连接测试成功");
// 在控制台输出详细信息
if (result.output) {
console.log("SSH测试输出:", result.output);
}
} else {
// 显示详细的错误信息
toast.error(result.message || "SSH连接测试失败", {
description: result.output ? `详情: ${result.output.substring(0, 100)}...` : undefined,
duration: 5000,
});
// 在控制台输出完整错误信息
if (result.output) {
console.error("SSH测试失败:", result.output);
}
}
} catch (error: any) {
console.error('Failed to test SSH key:', error);
toast.error(error.response?.data?.detail || "测试SSH密钥失败");
} finally {
setTestingKey(false);
}
};
const handleCopyPublicKey = () => {
if (sshKey.public_key) {
navigator.clipboard.writeText(sshKey.public_key);
toast.success("公钥已复制到剪贴板");
}
};
const handleSave = async () => {
try {
setSaving(true);
@ -309,6 +402,143 @@ export default function Account() {
</div>
</div>
{/* SSH Key Management */}
<div className="lg:col-span-3 cyber-card p-0">
<div className="cyber-card-header">
<Key className="w-5 h-5 text-emerald-400" />
<h3 className="text-lg font-bold uppercase tracking-wider text-foreground">SSH </h3>
</div>
<div className="p-6 space-y-4">
<div className="flex items-start gap-3 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
<div className="flex-shrink-0 mt-0.5">
<div className="w-8 h-8 rounded-lg bg-emerald-500/20 flex items-center justify-center">
<Key className="w-4 h-4 text-emerald-400" />
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-foreground font-medium mb-1">
使 SSH 访 Git
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
SSH GitHub/GitLab使 SSH URL 访
</p>
</div>
</div>
{!sshKey.has_key ? (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-muted/50 mb-4">
<Key className="w-8 h-8 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground mb-4"> SSH </p>
<Button
onClick={handleGenerateSSHKey}
disabled={generatingKey}
className="cyber-btn-primary h-10"
>
{generatingKey ? (
<>
<div className="loading-spinner w-4 h-4 mr-2" />
...
</>
) : (
<>
<Key className="w-4 h-4 mr-2" />
SSH
</>
)}
</Button>
</div>
) : (
<div className="space-y-4">
{/* Public Key Display */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-bold text-muted-foreground uppercase flex items-center gap-2">
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
SSH
</Label>
<Button
size="sm"
variant="ghost"
onClick={handleCopyPublicKey}
className="h-8 text-xs"
>
<Copy className="w-3 h-3 mr-1" />
</Button>
</div>
<Textarea
value={sshKey.public_key || ""}
readOnly
className="cyber-input font-mono text-xs h-24 resize-none"
/>
{/* 显示指纹 */}
{sshKey.fingerprint && (
<div className="p-3 bg-muted/50 rounded border border-border">
<Label className="text-xs font-bold text-muted-foreground uppercase mb-1 block">
(SHA256)
</Label>
<code className="text-xs text-emerald-400 font-mono break-all">
{sshKey.fingerprint}
</code>
</div>
)}
<p className="text-xs text-muted-foreground">
<a href="https://github.com/settings/keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">GitHub</a> <a href="https://gitlab.com/-/profile/keys" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">GitLab</a> <a href="https://codeup.aliyun.com/" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">CodeUp</a>
</p>
</div>
{/* Test SSH Connection */}
<div className="space-y-2 pt-4 border-t border-border">
<Label className="text-xs font-bold text-muted-foreground uppercase">
SSH
</Label>
<div className="flex gap-2">
<Input
placeholder="git@github.com:username/repo.git"
value={testRepoUrl}
onChange={(e) => setTestRepoUrl(e.target.value)}
className="cyber-input font-mono text-xs"
/>
<Button
onClick={handleTestSSHKey}
disabled={testingKey}
className="cyber-btn-outline whitespace-nowrap"
>
{testingKey ? (
<>
<div className="loading-spinner w-4 h-4 mr-2" />
...
</>
) : (
<>
<Terminal className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</div>
{/* Delete Key */}
<div className="flex justify-end pt-4 border-t border-border">
<Button
variant="destructive"
onClick={() => setShowDeleteKeyDialog(true)}
className="bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 border border-rose-500/30 h-10"
>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
)}
</div>
</div>
{/* Password Change */}
<div className="lg:col-span-3 cyber-card p-0">
<div className="cyber-card-header">
@ -388,6 +618,40 @@ export default function Account() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete SSH Key Confirmation Dialog */}
<AlertDialog open={showDeleteKeyDialog} onOpenChange={setShowDeleteKeyDialog}>
<AlertDialogContent className="cyber-card border-rose-500/30 cyber-dialog">
<AlertDialogHeader>
<AlertDialogTitle className="text-lg font-bold uppercase text-foreground flex items-center gap-2">
<Trash2 className="w-5 h-5 text-rose-400" />
SSH
</AlertDialogTitle>
<AlertDialogDescription className="text-muted-foreground">
使 SSH 访 Git
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cyber-btn-outline" disabled={deletingKey}>
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteSSHKey}
disabled={deletingKey}
className="bg-rose-500/20 hover:bg-rose-500/30 text-rose-400 border border-rose-500/30"
>
{deletingKey ? (
<>
<div className="loading-spinner w-4 h-4 mr-2" />
...
</>
) : (
"确认删除"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -33,7 +33,8 @@ import {
Terminal,
Github,
Folder,
ArrowUpRight
ArrowUpRight,
Key
} from "lucide-react";
import { api } from "@/shared/config/database";
import { validateZipFile } from "@/features/projects/services";
@ -275,6 +276,7 @@ export default function Projects() {
switch (type) {
case 'github': return <Github className="w-5 h-5" />;
case 'gitlab': return <GitBranch className="w-5 h-5 text-orange-500" />;
case 'other': return <Key className="w-5 h-5 text-cyan-500" />;
default: return <Folder className="w-5 h-5 text-muted-foreground" />;
}
};
@ -475,7 +477,7 @@ export default function Projects() {
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="repository_type" className="font-mono font-bold uppercase text-xs text-muted-foreground"></Label>
<Label htmlFor="repository_type" className="font-mono font-bold uppercase text-xs text-muted-foreground"></Label>
<Select
value={createForm.repository_type}
onValueChange={(value: any) => setCreateForm({ ...createForm, repository_type: value })}
@ -484,9 +486,9 @@ export default function Projects() {
<SelectValue />
</SelectTrigger>
<SelectContent className="cyber-dialog border-border">
<SelectItem value="github">GITHUB</SelectItem>
<SelectItem value="gitlab">GITLAB</SelectItem>
<SelectItem value="other">OTHER</SelectItem>
<SelectItem value="github">GitHub Token</SelectItem>
<SelectItem value="gitlab">GitLab Token</SelectItem>
<SelectItem value="other">SSH Key</SelectItem>
</SelectContent>
</Select>
</div>
@ -511,9 +513,23 @@ export default function Projects() {
id="repository_url"
value={createForm.repository_url}
onChange={(e) => setCreateForm({ ...createForm, repository_url: e.target.value })}
placeholder="https://github.com/user/repo"
placeholder={
createForm.repository_type === 'other'
? "git@github.com:user/repo.git"
: "https://github.com/user/repo"
}
className="cyber-input"
/>
{createForm.repository_type === 'other' && (
<p className="text-xs text-muted-foreground font-mono">
💡 SSH Key认证请使用 git@ SSH URL
</p>
)}
{createForm.repository_type !== 'other' && (
<p className="text-xs text-muted-foreground font-mono">
💡 Token认证请使用 https:// 格式的URL
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="default_branch" className="font-mono font-bold uppercase text-xs text-muted-foreground"></Label>
@ -998,14 +1014,28 @@ export default function Projects() {
id="edit-repo-url"
value={editForm.repository_url}
onChange={(e) => setEditForm({ ...editForm, repository_url: e.target.value })}
placeholder="https://github.com/user/repo"
placeholder={
editForm.repository_type === 'other'
? "git@github.com:user/repo.git"
: "https://github.com/user/repo"
}
className="cyber-input mt-1"
/>
{editForm.repository_type === 'other' && (
<p className="text-xs text-muted-foreground font-mono mt-1">
💡 SSH Key认证请使用 git@ SSH URL
</p>
)}
{editForm.repository_type !== 'other' && (
<p className="text-xs text-muted-foreground font-mono mt-1">
💡 Token认证请使用 https:// 格式的URL
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="edit-repo-type" className="font-mono font-bold uppercase text-xs text-muted-foreground"></Label>
<Label htmlFor="edit-repo-type" className="font-mono font-bold uppercase text-xs text-muted-foreground"></Label>
<Select
value={editForm.repository_type}
onValueChange={(value: any) => setEditForm({ ...editForm, repository_type: value })}
@ -1014,9 +1044,9 @@ export default function Projects() {
<SelectValue />
</SelectTrigger>
<SelectContent className="cyber-dialog border-border">
<SelectItem value="github">GITHUB</SelectItem>
<SelectItem value="gitlab">GITLAB</SelectItem>
<SelectItem value="other">OTHER</SelectItem>
<SelectItem value="github">GitHub Token</SelectItem>
<SelectItem value="gitlab">GitLab Token</SelectItem>
<SelectItem value="other">SSH Key</SelectItem>
</SelectContent>
</Select>
</div>

View File

@ -8,6 +8,8 @@ export const apiClient = axios.create({
headers: {
'Content-Type': 'application/json',
},
// 确保重定向时保留Authorization header
maxRedirects: 5,
});
// Request interceptor to add token

View File

@ -0,0 +1,61 @@
/**
* SSH Keys API Client
*/
import { apiClient } from './serverClient';
export interface SSHKeyResponse {
has_key: boolean;
public_key?: string;
fingerprint?: string;
}
export interface SSHKeyGenerateResponse {
public_key: string;
fingerprint?: string;
message: string;
}
export interface SSHKeyTestRequest {
repo_url: string;
}
export interface SSHKeyTestResponse {
success: boolean;
message: string;
output?: string;
}
/**
* SSH密钥对
*/
export const generateSSHKey = async (): Promise<SSHKeyGenerateResponse> => {
const response = await apiClient.post<SSHKeyGenerateResponse>('/ssh-keys/generate');
return response.data;
};
/**
* SSH公钥
*/
export const getSSHKey = async (): Promise<SSHKeyResponse> => {
const response = await apiClient.get<SSHKeyResponse>('/ssh-keys/');
return response.data;
};
/**
* SSH密钥
*/
export const deleteSSHKey = async (): Promise<{ message: string }> => {
const response = await apiClient.delete<{ message: string }>('/ssh-keys/');
return response.data;
};
/**
* SSH密钥
*/
export const testSSHKey = async (repoUrl: string): Promise<SSHKeyTestResponse> => {
const response = await apiClient.post<SSHKeyTestResponse>('/ssh-keys/test', {
repo_url: repoUrl
});
return response.data;
};