✨ feat(SSH):添加known_hosts持久化与清理功能
- 新增SSH配置目录设置,支持持久化存储known_hosts文件 - 实现known_hosts文件清理API端点,解决主机密钥变更导致的连接问题 - 优化SSH连接策略,使用StrictHostKeyChecking=accept-new自动接受新主机密钥 - 前端添加known_hosts清理按钮,提升SSH密钥管理体验 - 改进SSH测试逻辑,正确处理部署密钥的Anonymous响应
This commit is contained in:
parent
597d19dbfe
commit
f1243245a8
|
|
@ -13,7 +13,7 @@ from app.api import deps
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.user_config import UserConfig
|
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
|
from app.core.encryption import encrypt_sensitive_data, decrypt_sensitive_data
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -225,3 +225,30 @@ async def test_ssh_key(
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"测试SSH密钥失败: {str(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)}")
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,9 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
# 向量数据库配置
|
# 向量数据库配置
|
||||||
VECTOR_DB_PATH: str = "./data/vector_db" # 向量数据库持久化目录
|
VECTOR_DB_PATH: str = "./data/vector_db" # 向量数据库持久化目录
|
||||||
|
|
||||||
|
# SSH配置
|
||||||
|
SSH_CONFIG_PATH: str = "./data/ssh" # SSH配置目录(存储known_hosts等)
|
||||||
|
|
||||||
# Agent 配置
|
# Agent 配置
|
||||||
AGENT_MAX_ITERATIONS: int = 50 # Agent 最大迭代次数
|
AGENT_MAX_ITERATIONS: int = 50 # Agent 最大迭代次数
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,66 @@ from cryptography.hazmat.primitives.asymmetric import ed25519, rsa
|
||||||
from cryptography.hazmat.backends import default_backend
|
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):
|
def set_secure_file_permissions(file_path: str):
|
||||||
"""
|
"""
|
||||||
设置文件的安全权限(Unix: 0600, Windows: 只有当前用户可读写)
|
设置文件的安全权限(Unix: 0600, Windows: 只有当前用户可读写)
|
||||||
|
|
@ -253,6 +313,9 @@ class GitSSHOperations:
|
||||||
f.write(private_key)
|
f.write(private_key)
|
||||||
set_secure_file_permissions(key_file)
|
set_secure_file_permissions(key_file)
|
||||||
|
|
||||||
|
# 使用持久化的known_hosts文件
|
||||||
|
known_hosts_file = get_known_hosts_file()
|
||||||
|
|
||||||
# 设置Git SSH命令,只使用DeepAudit生成的SSH密钥
|
# 设置Git SSH命令,只使用DeepAudit生成的SSH密钥
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
|
|
||||||
|
|
@ -260,21 +323,22 @@ class GitSSHOperations:
|
||||||
ssh_cmd_parts = [
|
ssh_cmd_parts = [
|
||||||
'ssh',
|
'ssh',
|
||||||
'-i', key_file,
|
'-i', key_file,
|
||||||
'-o', 'StrictHostKeyChecking=yes',
|
'-o', 'StrictHostKeyChecking=accept-new', # 首次连接时自动接受并保存host key
|
||||||
'-o', 'UserKnownHostsFile=/dev/null',
|
'-o', f'UserKnownHostsFile={known_hosts_file}', # 使用持久化known_hosts文件
|
||||||
'-o', 'PreferredAuthentications=publickey',
|
'-o', 'PreferredAuthentications=publickey',
|
||||||
'-o', 'IdentitiesOnly=yes' # 只使用指定的密钥,不使用系统默认密钥
|
'-o', 'IdentitiesOnly=yes' # 只使用指定的密钥,不使用系统默认密钥
|
||||||
]
|
]
|
||||||
|
|
||||||
env['GIT_SSH_COMMAND'] = ' '.join(ssh_cmd_parts)
|
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
|
# 执行git clone
|
||||||
cmd = ['git', 'clone', '--depth', '1']
|
cmd = ['git', 'clone', '--depth', '1']
|
||||||
if branch: # 只有明确指定分支时才添加
|
if branch: # 只有明确指定分支时才添加
|
||||||
cmd.extend(['--branch', branch])
|
cmd.extend(['--branch', branch])
|
||||||
cmd.extend([repo_url, target_dir])
|
cmd.extend([repo_url, target_dir])
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
env=env,
|
env=env,
|
||||||
|
|
@ -414,12 +478,15 @@ class GitSSHOperations:
|
||||||
|
|
||||||
set_secure_file_permissions(key_file)
|
set_secure_file_permissions(key_file)
|
||||||
|
|
||||||
|
# 使用持久化的known_hosts文件
|
||||||
|
known_hosts_file = get_known_hosts_file()
|
||||||
|
|
||||||
# 构建SSH命令(只使用DeepAudit密钥)
|
# 构建SSH命令(只使用DeepAudit密钥)
|
||||||
cmd = [
|
cmd = [
|
||||||
'ssh',
|
'ssh',
|
||||||
'-i', key_file,
|
'-i', key_file,
|
||||||
'-o', 'StrictHostKeyChecking=yes',
|
'-o', 'StrictHostKeyChecking=accept-new', # 首次连接时自动接受并保存host key
|
||||||
'-o', 'UserKnownHostsFile=/dev/null',
|
'-o', f'UserKnownHostsFile={known_hosts_file}', # 使用持久化known_hosts文件
|
||||||
'-o', 'ConnectTimeout=10',
|
'-o', 'ConnectTimeout=10',
|
||||||
'-o', 'PreferredAuthentications=publickey',
|
'-o', 'PreferredAuthentications=publickey',
|
||||||
'-o', 'IdentitiesOnly=yes', # 只使用指定的密钥,不使用系统默认密钥
|
'-o', 'IdentitiesOnly=yes', # 只使用指定的密钥,不使用系统默认密钥
|
||||||
|
|
@ -427,6 +494,8 @@ class GitSSHOperations:
|
||||||
'-T', f'git@{host_part}'
|
'-T', f'git@{host_part}'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
print(f"[SSH Test] Using known_hosts file: {known_hosts_file}")
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
cmd,
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
|
|
@ -443,9 +512,9 @@ class GitSSHOperations:
|
||||||
# 必须在检查成功之前检查,因为Anonymous表示认证技术上成功但没有关联用户
|
# 必须在检查成功之前检查,因为Anonymous表示认证技术上成功但没有关联用户
|
||||||
if 'anonymous' in output_lower:
|
if 'anonymous' in output_lower:
|
||||||
return {
|
return {
|
||||||
'success': False,
|
'success': True,
|
||||||
'message': 'SSH连接成功,但公钥未关联用户账户',
|
'message': 'SSH连接成功,但公钥未关联用户账户',
|
||||||
'output': f'提示:服务器显示Anonymous表示公钥未添加到Git服务或未关联到您的账户。\n请在Git服务的设置中添加SSH公钥。\n\n原始输出:\n{output}'
|
'output': f'提示:服务器显示Anonymous,在使用部署密钥时是正常现象。\n请在Git服务的设置中添加SSH公钥。\n\n原始输出:\n{output}'
|
||||||
}
|
}
|
||||||
|
|
||||||
# 检查是否认证成功
|
# 检查是否认证成功
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,13 @@ import {
|
||||||
Key,
|
Key,
|
||||||
Copy,
|
Copy,
|
||||||
Trash2,
|
Trash2,
|
||||||
CheckCircle2
|
CheckCircle2,
|
||||||
|
ServerCrash
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiClient } from "@/shared/api/serverClient";
|
import { apiClient } from "@/shared/api/serverClient";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Profile } from "@/shared/types";
|
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() {
|
export default function Account() {
|
||||||
const navigate = useNavigate();
|
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 [sshKey, setSSHKey] = useState<{ has_key: boolean; public_key?: string; fingerprint?: string }>({ has_key: false });
|
||||||
const [generatingKey, setGeneratingKey] = useState(false);
|
const [generatingKey, setGeneratingKey] = useState(false);
|
||||||
const [deletingKey, setDeletingKey] = useState(false);
|
const [deletingKey, setDeletingKey] = useState(false);
|
||||||
|
const [clearingKnownHosts, setClearingKnownHosts] = useState(false);
|
||||||
const [testingKey, setTestingKey] = useState(false);
|
const [testingKey, setTestingKey] = useState(false);
|
||||||
const [testRepoUrl, setTestRepoUrl] = useState("");
|
const [testRepoUrl, setTestRepoUrl] = useState("");
|
||||||
const [showDeleteKeyDialog, setShowDeleteKeyDialog] = useState(false);
|
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 = () => {
|
const handleCopyPublicKey = () => {
|
||||||
if (sshKey.public_key) {
|
if (sshKey.public_key) {
|
||||||
navigator.clipboard.writeText(sshKey.public_key);
|
navigator.clipboard.writeText(sshKey.public_key);
|
||||||
|
|
@ -523,8 +542,26 @@ export default function Account() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Key */}
|
{/* Delete Key and Clear Known Hosts */}
|
||||||
<div className="flex justify-end pt-4 border-t border-border">
|
<div className="flex justify-end gap-2 pt-4 border-t border-border">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClearKnownHosts}
|
||||||
|
disabled={clearingKnownHosts}
|
||||||
|
className="cyber-btn-outline h-10"
|
||||||
|
>
|
||||||
|
{clearingKnownHosts ? (
|
||||||
|
<>
|
||||||
|
<div className="loading-spinner w-4 h-4 mr-2" />
|
||||||
|
清理中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ServerCrash className="w-4 h-4 mr-2" />
|
||||||
|
清理 known_hosts
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => setShowDeleteKeyDialog(true)}
|
onClick={() => setShowDeleteKeyDialog(true)}
|
||||||
|
|
|
||||||
|
|
@ -59,3 +59,11 @@ export const testSSHKey = async (repoUrl: string): Promise<SSHKeyTestResponse> =
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理known_hosts文件
|
||||||
|
*/
|
||||||
|
export const clearKnownHosts = async (): Promise<{ success: boolean; message: string }> => {
|
||||||
|
const response = await apiClient.delete<{ success: boolean; message: string }>('/ssh-keys/known-hosts');
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue