feat(connection): 支持已保存登录凭证并重构首页仪表盘
新增登录凭证管理接口、数据表与前端选择器,连接创建、 编辑和测试现已支持复用已保存凭证 重构首页为管理驾驶舱,增加统计卡片、趋势与分布图、 活跃连接排行,并通过 summary 聚合数据统一驱动
This commit is contained in:
@@ -12,6 +12,7 @@ interface ConnectionBase {
|
||||
port: number;
|
||||
username: string;
|
||||
auth_method: 'password' | 'key';
|
||||
login_credential_id?: number | null;
|
||||
proxy_id: number | null;
|
||||
proxy_type?: 'proxy' | 'jump' | null; // 新增连接本身的 proxy_type
|
||||
created_at: number;
|
||||
@@ -50,8 +51,16 @@ notes?: string | null;
|
||||
|
||||
interface FullConnectionDbRow extends Omit<FullConnectionData, 'jump_chain' | 'tag_ids'> { // Omit service layer type, and tag_ids (not directly on connections table)
|
||||
ssh_key_id?: number | null;
|
||||
login_credential_id?: number | null;
|
||||
jump_chain: string | null; // Stored as JSON string in DB
|
||||
proxy_type?: 'proxy' | 'jump' | null; // 连接本身的 proxy_type, from c.proxy_type
|
||||
login_credential_type?: 'SSH' | 'RDP' | 'VNC' | null;
|
||||
login_credential_username?: string | null;
|
||||
login_credential_auth_method?: 'password' | 'key' | null;
|
||||
login_credential_encrypted_password?: string | null;
|
||||
login_credential_encrypted_private_key?: string | null;
|
||||
login_credential_encrypted_passphrase?: string | null;
|
||||
login_credential_ssh_key_id?: number | null;
|
||||
proxy_db_id: number | null;
|
||||
proxy_name: string | null;
|
||||
actual_proxy_server_type: string | null; // p.type AS actual_proxy_server_type
|
||||
@@ -70,10 +79,14 @@ interface FullConnectionDbRow extends Omit<FullConnectionData, 'jump_chain' | 't
|
||||
export const findAllConnectionsWithTags = async (): Promise<ConnectionWithTags[]> => {
|
||||
const sql = `
|
||||
SELECT
|
||||
c.id, c.name, c.type, c.host, c.port, c.username, c.auth_method, c.proxy_id, c.proxy_type, c.ssh_key_id, c.notes, c.jump_chain, -- +++ Select ssh_key_id, notes, jump_chain AND proxy_type +++
|
||||
c.id, c.name, c.type, c.host, c.port,
|
||||
COALESCE(lc.username, c.username) as username,
|
||||
COALESCE(lc.auth_method, c.auth_method) as auth_method,
|
||||
c.login_credential_id, c.proxy_id, c.proxy_type, c.ssh_key_id, c.notes, c.jump_chain,
|
||||
c.created_at, c.updated_at, c.last_connected_at,
|
||||
GROUP_CONCAT(ct.tag_id) as tag_ids_str
|
||||
FROM connections c
|
||||
LEFT JOIN login_credentials lc ON c.login_credential_id = lc.id
|
||||
LEFT JOIN connection_tags ct ON c.id = ct.connection_id
|
||||
GROUP BY c.id
|
||||
ORDER BY c.name ASC`;
|
||||
@@ -100,10 +113,14 @@ export const findAllConnectionsWithTags = async (): Promise<ConnectionWithTags[]
|
||||
export const findConnectionByIdWithTags = async (id: number): Promise<ConnectionWithTags | null> => {
|
||||
const sql = `
|
||||
SELECT
|
||||
c.id, c.name, c.type, c.host, c.port, c.username, c.auth_method, c.proxy_id, c.proxy_type, c.ssh_key_id, c.notes, c.jump_chain, -- +++ Select ssh_key_id, notes, jump_chain AND proxy_type +++
|
||||
c.id, c.name, c.type, c.host, c.port,
|
||||
COALESCE(lc.username, c.username) as username,
|
||||
COALESCE(lc.auth_method, c.auth_method) as auth_method,
|
||||
c.login_credential_id, c.proxy_id, c.proxy_type, c.ssh_key_id, c.notes, c.jump_chain,
|
||||
c.created_at, c.updated_at, c.last_connected_at,
|
||||
GROUP_CONCAT(ct.tag_id) as tag_ids_str
|
||||
FROM connections c
|
||||
LEFT JOIN login_credentials lc ON c.login_credential_id = lc.id
|
||||
LEFT JOIN connection_tags ct ON c.id = ct.connection_id
|
||||
WHERE c.id = ?
|
||||
GROUP BY c.id`;
|
||||
@@ -133,12 +150,20 @@ export const findFullConnectionById = async (id: number): Promise<FullConnection
|
||||
const sql = `
|
||||
SELECT
|
||||
c.*, -- 选择 connections 表所有列 (包括 c.proxy_type)
|
||||
lc.type as login_credential_type,
|
||||
lc.username as login_credential_username,
|
||||
lc.auth_method as login_credential_auth_method,
|
||||
lc.encrypted_password as login_credential_encrypted_password,
|
||||
lc.encrypted_private_key as login_credential_encrypted_private_key,
|
||||
lc.encrypted_passphrase as login_credential_encrypted_passphrase,
|
||||
lc.ssh_key_id as login_credential_ssh_key_id,
|
||||
p.id as proxy_db_id, p.name as proxy_name, p.type as actual_proxy_server_type, -- Renamed p.type to avoid conflict
|
||||
p.host as proxy_host, p.port as proxy_port, p.username as proxy_username,
|
||||
p.encrypted_password as proxy_encrypted_password,
|
||||
p.encrypted_private_key as proxy_encrypted_private_key,
|
||||
p.encrypted_passphrase as proxy_encrypted_passphrase
|
||||
FROM connections c
|
||||
LEFT JOIN login_credentials lc ON c.login_credential_id = lc.id
|
||||
LEFT JOIN proxies p ON c.proxy_id = p.id
|
||||
WHERE c.id = ?`;
|
||||
try {
|
||||
@@ -155,7 +180,7 @@ export const findFullConnectionById = async (id: number): Promise<FullConnection
|
||||
* 根据名称查找连接 (用于检查名称是否重复)
|
||||
*/
|
||||
export const findConnectionByName = async (name: string): Promise<ConnectionBase | null> => {
|
||||
const sql = `SELECT id, name, type, host, port, username, auth_method, proxy_id, proxy_type, ssh_key_id, notes, jump_chain, created_at, updated_at, last_connected_at FROM connections WHERE name = ?`; // Added jump_chain and proxy_type
|
||||
const sql = `SELECT id, name, type, host, port, username, auth_method, login_credential_id, proxy_id, proxy_type, ssh_key_id, notes, jump_chain, created_at, updated_at, last_connected_at FROM connections WHERE name = ?`; // Added jump_chain and proxy_type
|
||||
try {
|
||||
const db = await getDbInstance();
|
||||
// Cast to ConnectionWithTagsRow to read jump_chain as string, then parse. It will now also have proxy_type
|
||||
@@ -190,8 +215,8 @@ export const createConnection = async (data: Omit<FullConnectionData, 'id' | 'cr
|
||||
console.log('[Repository:createConnection] Received data:', JSON.stringify(data, null, 2));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sql = `
|
||||
INSERT INTO connections (name, type, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, proxy_type, ssh_key_id, notes, jump_chain, created_at, updated_at) -- +++ Add ssh_key_id, notes, jump_chain AND proxy_type columns +++
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; // +++ Add placeholders for ssh_key_id, notes, jump_chain AND proxy_type +++
|
||||
INSERT INTO connections (name, type, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, proxy_type, ssh_key_id, login_credential_id, notes, jump_chain, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
const jumpChainStringified = (data.jump_chain && data.jump_chain.length > 0) ? JSON.stringify(data.jump_chain) : null;
|
||||
console.log(`[Repository:createConnection] jump_chain input: ${JSON.stringify(data.jump_chain)}, stringified to: ${jumpChainStringified}`);
|
||||
@@ -204,6 +229,7 @@ export const createConnection = async (data: Omit<FullConnectionData, 'id' | 'cr
|
||||
data.proxy_id ?? null,
|
||||
data.proxy_type ?? null, // Add proxy_type parameter
|
||||
data.ssh_key_id ?? null, // +++ Add ssh_key_id parameter +++
|
||||
data.login_credential_id ?? null,
|
||||
data.notes ?? null, // Add notes parameter
|
||||
jumpChainStringified, // Use the stringified jump_chain
|
||||
now, now
|
||||
@@ -400,7 +426,7 @@ export const bulkInsertConnections = async (
|
||||
connections: Array<Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { tag_ids?: number[] }>
|
||||
): Promise<{ connectionId: number, originalData: any }[]> => {
|
||||
|
||||
const insertConnSql = `INSERT INTO connections (name, type, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, proxy_type, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; // Add type, proxy_type and notes columns and placeholders
|
||||
const insertConnSql = `INSERT INTO connections (name, type, host, port, username, auth_method, encrypted_password, encrypted_private_key, encrypted_passphrase, proxy_id, proxy_type, login_credential_id, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; // Add type, proxy_type and notes columns and placeholders
|
||||
const results: { connectionId: number, originalData: any }[] = [];
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -412,6 +438,7 @@ export const bulkInsertConnections = async (
|
||||
connData.encrypted_passphrase || null,
|
||||
connData.proxy_id || null,
|
||||
connData.proxy_type || null, // Add proxy_type parameter
|
||||
connData.login_credential_id || null,
|
||||
connData.notes || null, // Add notes parameter
|
||||
now, now
|
||||
];
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as ConnectionRepository from './connection.repository';
|
||||
import { encrypt, decrypt } from '../utils/crypto';
|
||||
import { AuditLogService } from '../audit/audit.service';
|
||||
import * as SshKeyService from '../ssh_keys/ssh_key.service';
|
||||
import * as LoginCredentialService from '../login-credentials/login-credential.service';
|
||||
import {
|
||||
ConnectionBase,
|
||||
ConnectionWithTags,
|
||||
@@ -54,6 +55,20 @@ const _validateAndProcessJumpChain = async (
|
||||
|
||||
const auditLogService = new AuditLogService();
|
||||
|
||||
const _getSavedCredentialSnapshot = async (
|
||||
loginCredentialId: number,
|
||||
connectionType: 'SSH' | 'RDP' | 'VNC'
|
||||
) => {
|
||||
const credential = await LoginCredentialService.getLoginCredentialById(loginCredentialId);
|
||||
if (!credential) {
|
||||
throw new Error(`登录凭证 ID ${loginCredentialId} 不存在。`);
|
||||
}
|
||||
if (credential.type !== connectionType) {
|
||||
throw new Error(`登录凭证类型 ${credential.type} 与连接类型 ${connectionType} 不匹配。`);
|
||||
}
|
||||
return credential;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有连接(包含标签)
|
||||
*/
|
||||
@@ -76,159 +91,130 @@ export const getConnectionById = async (id: number): Promise<ConnectionWithTags
|
||||
* 创建新连接
|
||||
*/
|
||||
export const createConnection = async (input: CreateConnectionInput): Promise<ConnectionWithTags> => {
|
||||
// +++ Define a local type alias for clarity, including ssh_key_id +++
|
||||
type ConnectionDataForRepo = Omit<FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at' | 'tag_ids'> & { jump_chain?: number[] | null; proxy_type?: 'proxy' | 'jump' | null };
|
||||
|
||||
console.log('[Service:createConnection] Received input:', JSON.stringify(input, null, 2)); // Log input
|
||||
|
||||
// 0. 处理和验证 jump_chain
|
||||
const processedJumpChain = await _validateAndProcessJumpChain(input.jump_chain, input.proxy_id);
|
||||
|
||||
|
||||
// 1. 验证输入 (包含 type)
|
||||
// Convert type to uppercase for validation and consistency
|
||||
const connectionType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined; // Ensure type safety
|
||||
const connectionType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined;
|
||||
if (!connectionType || !['SSH', 'RDP', 'VNC'].includes(connectionType)) {
|
||||
throw new Error('必须提供有效的连接类型 (SSH, RDP 或 VNC)。');
|
||||
}
|
||||
if (!input.host || !input.username) {
|
||||
throw new Error('缺少必要的连接信息 (host, username)。');
|
||||
}
|
||||
// Type-specific validation using the uppercase version
|
||||
if (connectionType === 'SSH') {
|
||||
if (!input.auth_method || !['password', 'key'].includes(input.auth_method)) {
|
||||
throw new Error('SSH 连接必须提供有效的认证方式 (password 或 key)。');
|
||||
}
|
||||
if (input.auth_method === 'password' && !input.password) {
|
||||
throw new Error('SSH 密码认证方式需要提供 password。');
|
||||
}
|
||||
// If using ssh_key_id, private_key is not required in the input
|
||||
if (input.auth_method === 'key' && !input.ssh_key_id && !input.private_key) {
|
||||
throw new Error('SSH 密钥认证方式需要提供 private_key 或选择一个已保存的密钥 (ssh_key_id)。');
|
||||
}
|
||||
if (input.auth_method === 'key' && input.ssh_key_id && input.private_key) {
|
||||
throw new Error('不能同时提供 private_key 和 ssh_key_id。');
|
||||
}
|
||||
} else if (connectionType === 'RDP') {
|
||||
if (!input.password) {
|
||||
throw new Error('RDP 连接需要提供 password。');
|
||||
}
|
||||
// For RDP, we'll ignore auth_method, private_key, passphrase from input if provided
|
||||
} else if (connectionType === 'VNC') {
|
||||
if (!input.password) {
|
||||
throw new Error('VNC 连接需要提供 password。');
|
||||
}
|
||||
// For VNC, auth_method is implicitly 'password'.
|
||||
// ssh_key_id, private_key, passphrase are not applicable.
|
||||
if (input.auth_method && input.auth_method !== 'password') {
|
||||
throw new Error('VNC 连接的认证方式必须是 password。');
|
||||
}
|
||||
if (input.ssh_key_id || input.private_key) {
|
||||
throw new Error('VNC 连接不支持 SSH 密钥认证。');
|
||||
}
|
||||
if (!input.host) {
|
||||
throw new Error('缺少必要的连接信息 (host)。');
|
||||
}
|
||||
|
||||
// 2. 处理凭证和 ssh_key_id (根据 type)
|
||||
const savedCredential = typeof input.login_credential_id === 'number'
|
||||
? await _getSavedCredentialSnapshot(input.login_credential_id, connectionType)
|
||||
: null;
|
||||
|
||||
let encryptedPassword = null;
|
||||
let encryptedPrivateKey = null;
|
||||
let encryptedPassphrase = null;
|
||||
let sshKeyIdToSave: number | null = null; // +++ Variable for ssh_key_id +++
|
||||
// Default to 'password' for DB compatibility, especially for RDP
|
||||
let sshKeyIdToSave: number | null = null;
|
||||
let authMethodForDb: 'password' | 'key' = 'password';
|
||||
let usernameToSave = input.username ?? savedCredential?.username ?? '';
|
||||
let loginCredentialIdToSave: number | null = savedCredential?.id ?? null;
|
||||
|
||||
if (connectionType === 'SSH') {
|
||||
authMethodForDb = input.auth_method!; // Already validated above
|
||||
if (!usernameToSave) {
|
||||
throw new Error('缺少必要的连接信息 (username)。');
|
||||
}
|
||||
|
||||
if (savedCredential) {
|
||||
usernameToSave = savedCredential.username;
|
||||
authMethodForDb = savedCredential.auth_method;
|
||||
encryptedPassword = savedCredential.encrypted_password ?? null;
|
||||
encryptedPrivateKey = savedCredential.encrypted_private_key ?? null;
|
||||
encryptedPassphrase = savedCredential.encrypted_passphrase ?? null;
|
||||
sshKeyIdToSave = savedCredential.ssh_key_id ?? null;
|
||||
} else if (connectionType === 'SSH') {
|
||||
if (!input.auth_method || !['password', 'key'].includes(input.auth_method)) {
|
||||
throw new Error('SSH 连接必须提供有效的认证方式 (password 或 key)。');
|
||||
}
|
||||
authMethodForDb = input.auth_method;
|
||||
if (input.auth_method === 'password') {
|
||||
if (!input.password) {
|
||||
throw new Error('SSH 密码认证方式需要提供 password。');
|
||||
}
|
||||
encryptedPassword = encrypt(input.password!);
|
||||
sshKeyIdToSave = null; // Password auth cannot use ssh_key_id
|
||||
} else { // auth_method is 'key'
|
||||
sshKeyIdToSave = null;
|
||||
} else {
|
||||
if (input.ssh_key_id) {
|
||||
// Validate the provided ssh_key_id
|
||||
const keyExists = await SshKeyService.getSshKeyDbRowById(input.ssh_key_id);
|
||||
if (!keyExists) {
|
||||
throw new Error(`提供的 SSH 密钥 ID ${input.ssh_key_id} 无效或不存在。`);
|
||||
}
|
||||
sshKeyIdToSave = input.ssh_key_id;
|
||||
// When using ssh_key_id, connection's own key fields should be null
|
||||
encryptedPrivateKey = null;
|
||||
encryptedPassphrase = null;
|
||||
} else if (input.private_key) {
|
||||
// Encrypt the provided private key and passphrase
|
||||
encryptedPrivateKey = encrypt(input.private_key!);
|
||||
if (input.passphrase) {
|
||||
encryptedPassphrase = encrypt(input.passphrase);
|
||||
}
|
||||
sshKeyIdToSave = null; // Ensure ssh_key_id is null if providing key directly
|
||||
sshKeyIdToSave = null;
|
||||
} else {
|
||||
// This case should be caught by validation above, but as a safeguard:
|
||||
throw new Error('SSH 密钥认证方式内部错误:未提供 private_key 或 ssh_key_id。');
|
||||
}
|
||||
}
|
||||
} else if (connectionType === 'RDP') { // RDP
|
||||
} else if (connectionType === 'RDP') {
|
||||
if (!input.password) {
|
||||
throw new Error('RDP 连接需要提供 password。');
|
||||
}
|
||||
encryptedPassword = encrypt(input.password!);
|
||||
// authMethodForDb remains 'password' for RDP
|
||||
encryptedPrivateKey = null;
|
||||
encryptedPassphrase = null;
|
||||
sshKeyIdToSave = null;
|
||||
} else { // VNC
|
||||
} else {
|
||||
if (!input.password) {
|
||||
throw new Error('VNC 连接需要提供 password。');
|
||||
}
|
||||
encryptedPassword = encrypt(input.password!);
|
||||
authMethodForDb = 'password'; // VNC always uses password auth
|
||||
authMethodForDb = 'password';
|
||||
encryptedPrivateKey = null;
|
||||
encryptedPassphrase = null;
|
||||
sshKeyIdToSave = null;
|
||||
}
|
||||
|
||||
// 3. 准备仓库数据
|
||||
let defaultPort = 22; // Default for SSH
|
||||
if (connectionType === 'RDP') {
|
||||
defaultPort = 3389;
|
||||
} else if (connectionType === 'VNC') {
|
||||
defaultPort = 5900; // Default VNC port
|
||||
}
|
||||
// +++ Explicitly type connectionData using the local alias +++
|
||||
const connectionData: ConnectionDataForRepo = {
|
||||
name: input.name || '',
|
||||
type: connectionType,
|
||||
host: input.host,
|
||||
port: input.port ?? defaultPort, // Use type-specific default port
|
||||
username: input.username,
|
||||
auth_method: authMethodForDb, // Use determined auth method
|
||||
port: input.port ?? defaultPort,
|
||||
username: usernameToSave,
|
||||
auth_method: authMethodForDb,
|
||||
encrypted_password: encryptedPassword,
|
||||
encrypted_private_key: encryptedPrivateKey, // Null if using ssh_key_id or RDP
|
||||
encrypted_passphrase: encryptedPassphrase, // Null if using ssh_key_id or RDP
|
||||
ssh_key_id: sshKeyIdToSave, // +++ Add ssh_key_id +++
|
||||
notes: input.notes ?? null, // Add notes field
|
||||
proxy_id: input.proxy_id ?? null, // 直接使用输入的 proxy_id
|
||||
proxy_type: input.proxy_type ?? null, // 新增 proxy_type
|
||||
encrypted_private_key: encryptedPrivateKey,
|
||||
encrypted_passphrase: encryptedPassphrase,
|
||||
ssh_key_id: sshKeyIdToSave,
|
||||
login_credential_id: loginCredentialIdToSave,
|
||||
notes: input.notes ?? null,
|
||||
proxy_id: input.proxy_id ?? null,
|
||||
proxy_type: input.proxy_type ?? null,
|
||||
jump_chain: processedJumpChain,
|
||||
};
|
||||
// Remove ssh_key_id property if it's null before logging/saving if repository expects exact type match without optional nulls
|
||||
const finalConnectionData = { ...connectionData };
|
||||
if (finalConnectionData.ssh_key_id === null) {
|
||||
delete (finalConnectionData as any).ssh_key_id; // Adjust based on repository function signature if needed
|
||||
}
|
||||
console.log('[Service:createConnection] Data being passed to ConnectionRepository.createConnection:', JSON.stringify(finalConnectionData, null, 2)); // Log data before saving
|
||||
console.log('[Service:createConnection] Data being passed to ConnectionRepository.createConnection:', JSON.stringify(connectionData, null, 2));
|
||||
|
||||
// 4. 在仓库中创建连接记录
|
||||
// Pass the potentially modified finalConnectionData
|
||||
const newConnectionId = await ConnectionRepository.createConnection(finalConnectionData as Omit<ConnectionRepository.FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at' | 'tag_ids'>);
|
||||
const newConnectionId = await ConnectionRepository.createConnection(connectionData as Omit<ConnectionRepository.FullConnectionData, 'id' | 'created_at' | 'updated_at' | 'last_connected_at' | 'tag_ids'>);
|
||||
|
||||
// 5. 处理标签
|
||||
const tagIds = input.tag_ids?.filter(id => typeof id === 'number' && id > 0) ?? [];
|
||||
if (tagIds.length > 0) {
|
||||
await ConnectionRepository.updateConnectionTags(newConnectionId, tagIds);
|
||||
}
|
||||
|
||||
// 6. 记录审计操作
|
||||
const newConnection = await getConnectionById(newConnectionId);
|
||||
if (!newConnection) {
|
||||
// 如果创建成功,这理论上不应该发生
|
||||
console.error(`[Audit Log Error] Failed to retrieve connection ${newConnectionId} after creation.`);
|
||||
throw new Error('创建连接后无法检索到该连接。');
|
||||
}
|
||||
auditLogService.logAction('CONNECTION_CREATED', { connectionId: newConnection.id, type: newConnection.type, name: newConnection.name, host: newConnection.host }); // Add type to audit log
|
||||
auditLogService.logAction('CONNECTION_CREATED', { connectionId: newConnection.id, type: newConnection.type, name: newConnection.name, host: newConnection.host });
|
||||
|
||||
// 7. 返回新创建的带标签的连接
|
||||
return newConnection;
|
||||
};
|
||||
|
||||
@@ -236,20 +222,18 @@ notes: input.notes ?? null, // Add notes field
|
||||
* 更新连接信息
|
||||
*/
|
||||
export const updateConnection = async (id: number, input: UpdateConnectionInput): Promise<ConnectionWithTags | null> => {
|
||||
// 1. 获取当前连接数据(包括加密字段)以进行比较
|
||||
const currentFullConnection = await ConnectionRepository.findFullConnectionById(id);
|
||||
if (!currentFullConnection) {
|
||||
return null; // 未找到连接
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 准备更新数据
|
||||
// Explicitly type dataToUpdate to match the repository's expected input, including ssh_key_id, jump_chain and proxy_type
|
||||
const dataToUpdate: Partial<Omit<ConnectionRepository.FullConnectionData & { ssh_key_id?: number | null; jump_chain?: number[] | null; proxy_type?: 'proxy' | 'jump' | null }, 'id' | 'created_at' | 'last_connected_at' | 'tag_ids'>> = {};
|
||||
const dataToUpdate: Partial<Omit<ConnectionRepository.FullConnectionData & { ssh_key_id?: number | null; jump_chain?: number[] | null; proxy_type?: 'proxy' | 'jump' | null; login_credential_id?: number | null }, 'id' | 'created_at' | 'last_connected_at' | 'tag_ids'>> = {};
|
||||
let needsCredentialUpdate = false;
|
||||
// Determine the final type, converting input type to uppercase if provided
|
||||
const targetType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined || currentFullConnection.type;
|
||||
if (currentFullConnection.login_credential_id && input.login_credential_id === undefined && targetType !== currentFullConnection.type) {
|
||||
throw new Error('当前连接正在使用已保存登录凭证,切换连接类型前请先改为直填或重新选择匹配类型的登录凭证。');
|
||||
}
|
||||
|
||||
// 处理 jump_chain 和 proxy_id
|
||||
if (input.jump_chain !== undefined || input.proxy_id !== undefined) {
|
||||
const currentProxyId = input.proxy_id !== undefined ? input.proxy_id : currentFullConnection.proxy_id;
|
||||
|
||||
@@ -268,75 +252,72 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
|
||||
const processedJumpChain = await _validateAndProcessJumpChain(currentJumpChainForValidation, currentProxyId, id);
|
||||
|
||||
dataToUpdate.jump_chain = processedJumpChain;
|
||||
// 直接使用 currentProxyId,不再因为 jump_chain 存在而将其设为 null
|
||||
dataToUpdate.proxy_id = currentProxyId;
|
||||
}
|
||||
|
||||
|
||||
// 更新非凭证字段
|
||||
if (input.name !== undefined) dataToUpdate.name = input.name || '';
|
||||
// Update type if changed, using the uppercase version
|
||||
if (input.type !== undefined && targetType !== currentFullConnection.type) dataToUpdate.type = targetType;
|
||||
if (input.host !== undefined) dataToUpdate.host = input.host;
|
||||
if (input.port !== undefined) dataToUpdate.port = input.port;
|
||||
if (input.username !== undefined) dataToUpdate.username = input.username;
|
||||
if (input.notes !== undefined) dataToUpdate.notes = input.notes; // Add notes update
|
||||
// proxy_id 的处理已移至 jump_chain 逻辑块中
|
||||
// if (input.proxy_id !== undefined) dataToUpdate.proxy_id = input.proxy_id;
|
||||
if (input.proxy_type !== undefined) dataToUpdate.proxy_type = input.proxy_type; // 新增 proxy_type 更新
|
||||
// Handle ssh_key_id update (can be set to null or a new ID)
|
||||
if (input.ssh_key_id !== undefined) dataToUpdate.ssh_key_id = input.ssh_key_id;
|
||||
if (input.notes !== undefined) dataToUpdate.notes = input.notes;
|
||||
if (input.proxy_type !== undefined) dataToUpdate.proxy_type = input.proxy_type;
|
||||
|
||||
// 处理认证方法更改或凭证更新 (根据 targetType)
|
||||
// Use the validated targetType for logic
|
||||
if (targetType === 'SSH') {
|
||||
if (input.login_credential_id !== undefined) {
|
||||
if (input.login_credential_id === null) {
|
||||
dataToUpdate.login_credential_id = null;
|
||||
} else {
|
||||
const savedCredential = await _getSavedCredentialSnapshot(input.login_credential_id, targetType);
|
||||
dataToUpdate.login_credential_id = savedCredential.id;
|
||||
dataToUpdate.username = savedCredential.username;
|
||||
dataToUpdate.auth_method = savedCredential.auth_method;
|
||||
dataToUpdate.encrypted_password = savedCredential.encrypted_password ?? null;
|
||||
dataToUpdate.encrypted_private_key = savedCredential.encrypted_private_key ?? null;
|
||||
dataToUpdate.encrypted_passphrase = savedCredential.encrypted_passphrase ?? null;
|
||||
dataToUpdate.ssh_key_id = savedCredential.ssh_key_id ?? null;
|
||||
needsCredentialUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
const allowDirectCredentialEdit =
|
||||
input.login_credential_id === null ||
|
||||
(!currentFullConnection.login_credential_id && input.login_credential_id === undefined);
|
||||
|
||||
if (allowDirectCredentialEdit && targetType === 'SSH') {
|
||||
const currentAuthMethod = currentFullConnection.auth_method;
|
||||
const inputAuthMethod = input.auth_method;
|
||||
|
||||
// Determine the final auth method for SSH
|
||||
const finalAuthMethod = inputAuthMethod || currentAuthMethod;
|
||||
if (finalAuthMethod !== currentAuthMethod) {
|
||||
dataToUpdate.auth_method = finalAuthMethod; // Update auth_method if it changed
|
||||
dataToUpdate.auth_method = finalAuthMethod;
|
||||
}
|
||||
|
||||
if (finalAuthMethod === 'password') {
|
||||
// If switching to password or updating password
|
||||
if (input.password !== undefined) { // Check if password was provided in input
|
||||
if (input.password !== undefined) {
|
||||
if (!input.password && finalAuthMethod !== currentAuthMethod) {
|
||||
// Switching to password requires a password
|
||||
throw new Error('切换到密码认证时需要提供 password。');
|
||||
}
|
||||
// Encrypt if password is not empty, otherwise set to null (to clear)
|
||||
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
|
||||
needsCredentialUpdate = true;
|
||||
}
|
||||
// When switching to password, clear key fields and ssh_key_id
|
||||
if (finalAuthMethod !== currentAuthMethod) {
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
dataToUpdate.ssh_key_id = null; // Clear ssh_key_id when switching to password
|
||||
dataToUpdate.ssh_key_id = null;
|
||||
}
|
||||
} else { // finalAuthMethod is 'key'
|
||||
// Handle ssh_key_id selection or direct key input
|
||||
} else {
|
||||
if (input.ssh_key_id !== undefined) {
|
||||
// User selected a stored key
|
||||
if (input.ssh_key_id === null) {
|
||||
// User explicitly wants to clear the stored key association
|
||||
dataToUpdate.ssh_key_id = null;
|
||||
// If clearing ssh_key_id, we might need a direct key, but validation should handle this?
|
||||
// Or assume clearing means switching back to direct key input (which might be empty)
|
||||
// Let's assume clearing ssh_key_id means we expect a direct key or nothing
|
||||
if (input.private_key === undefined) {
|
||||
// If no direct key provided when clearing ssh_key_id, clear connection's key fields
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
} else {
|
||||
// Encrypt the direct key provided alongside clearing ssh_key_id
|
||||
dataToUpdate.encrypted_private_key = input.private_key ? encrypt(input.private_key) : null;
|
||||
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
|
||||
}
|
||||
} else {
|
||||
// Validate the provided ssh_key_id
|
||||
const keyExists = await SshKeyService.getSshKeyDbRowById(input.ssh_key_id);
|
||||
if (!keyExists) {
|
||||
throw new Error(`提供的 SSH 密钥 ID ${input.ssh_key_id} 无效或不存在。`);
|
||||
@@ -346,90 +327,73 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
}
|
||||
needsCredentialUpdate = true; // Changing key source is a credential update
|
||||
needsCredentialUpdate = true;
|
||||
} else if (input.private_key !== undefined) {
|
||||
// User provided a direct key
|
||||
if (!input.private_key && finalAuthMethod !== currentAuthMethod) {
|
||||
// Switching to key requires a private key if not using ssh_key_id
|
||||
throw new Error('切换到密钥认证时需要提供 private_key 或选择一个已保存的密钥。');
|
||||
}
|
||||
// Encrypt if key is not empty, otherwise set to null (to clear)
|
||||
dataToUpdate.encrypted_private_key = input.private_key ? encrypt(input.private_key) : null;
|
||||
// Update passphrase only if direct key was provided OR passphrase itself was provided
|
||||
if (input.passphrase !== undefined) {
|
||||
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
|
||||
} else if (input.private_key) {
|
||||
// If only private_key is provided, clear passphrase
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
}
|
||||
dataToUpdate.ssh_key_id = null; // Clear ssh_key_id when providing direct key
|
||||
dataToUpdate.ssh_key_id = null;
|
||||
needsCredentialUpdate = true;
|
||||
} else if (input.passphrase !== undefined && !input.ssh_key_id && currentFullConnection.encrypted_private_key) {
|
||||
// Only passphrase provided, and not using ssh_key_id, and a direct key already exists
|
||||
dataToUpdate.encrypted_passphrase = input.passphrase ? encrypt(input.passphrase) : null;
|
||||
needsCredentialUpdate = true;
|
||||
}
|
||||
|
||||
// When switching to key, clear password field
|
||||
if (finalAuthMethod !== currentAuthMethod) {
|
||||
dataToUpdate.encrypted_password = null;
|
||||
}
|
||||
}
|
||||
} else if (targetType === 'RDP') { // targetType is 'RDP'
|
||||
// RDP only uses password
|
||||
if (input.password !== undefined) { // Check if password was provided
|
||||
} else if (allowDirectCredentialEdit && targetType === 'RDP') {
|
||||
if (input.password !== undefined) {
|
||||
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
|
||||
needsCredentialUpdate = true;
|
||||
}
|
||||
// Ensure SSH specific fields are nullified if switching to RDP or updating RDP
|
||||
if (targetType !== currentFullConnection.type || needsCredentialUpdate || Object.keys(dataToUpdate).includes('type')) {
|
||||
dataToUpdate.auth_method = 'password'; // RDP uses password auth method in DB
|
||||
dataToUpdate.auth_method = 'password';
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
dataToUpdate.ssh_key_id = null; // RDP cannot use ssh_key_id
|
||||
dataToUpdate.ssh_key_id = null;
|
||||
}
|
||||
} else { // targetType is 'VNC'
|
||||
// VNC only uses password
|
||||
if (input.password !== undefined) { // Check if password was provided
|
||||
} else if (allowDirectCredentialEdit) {
|
||||
if (input.password !== undefined) {
|
||||
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
|
||||
needsCredentialUpdate = true;
|
||||
}
|
||||
// Ensure SSH specific fields are nullified if switching to VNC or updating VNC
|
||||
if (targetType !== currentFullConnection.type || needsCredentialUpdate || Object.keys(dataToUpdate).includes('type')) {
|
||||
dataToUpdate.auth_method = 'password'; // VNC uses password auth method in DB
|
||||
dataToUpdate.auth_method = 'password';
|
||||
dataToUpdate.encrypted_private_key = null;
|
||||
dataToUpdate.encrypted_passphrase = null;
|
||||
dataToUpdate.ssh_key_id = null; // VNC cannot use ssh_key_id
|
||||
dataToUpdate.ssh_key_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 如果有更改,则更新连接记录
|
||||
const hasNonTagChanges = Object.keys(dataToUpdate).length > 0;
|
||||
let updatedFieldsForAudit: string[] = []; // 跟踪审计日志的字段
|
||||
let updatedFieldsForAudit: string[] = [];
|
||||
if (hasNonTagChanges) {
|
||||
updatedFieldsForAudit = Object.keys(dataToUpdate); // 在更新调用之前获取字段
|
||||
console.log(`[Service:updateConnection] Data being passed to ConnectionRepository.updateConnection for ID ${id}:`, JSON.stringify(dataToUpdate, null, 2)); // ADD THIS LOG
|
||||
updatedFieldsForAudit = Object.keys(dataToUpdate);
|
||||
console.log(`[Service:updateConnection] Data being passed to ConnectionRepository.updateConnection for ID ${id}:`, JSON.stringify(dataToUpdate, null, 2));
|
||||
const updated = await ConnectionRepository.updateConnection(id, dataToUpdate);
|
||||
if (!updated) {
|
||||
// 如果 findFullConnectionById 成功,则不应发生这种情况,但这是良好的实践
|
||||
throw new Error('更新连接记录失败。');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 如果提供了 tag_ids,则处理标签更新
|
||||
if (input.tag_ids !== undefined) {
|
||||
const validTagIds = input.tag_ids.filter(tagId => typeof tagId === 'number' && tagId > 0);
|
||||
await ConnectionRepository.updateConnectionTags(id, validTagIds);
|
||||
}
|
||||
// 如果 tag_ids 已更新,则将其添加到审计日志
|
||||
if (input.tag_ids !== undefined) {
|
||||
updatedFieldsForAudit.push('tag_ids');
|
||||
}
|
||||
|
||||
|
||||
// 5. 如果进行了任何更改,则记录审计操作
|
||||
if (hasNonTagChanges || input.tag_ids !== undefined) {
|
||||
// Add type to audit log if it was updated
|
||||
const auditDetails: any = { connectionId: id, updatedFields: updatedFieldsForAudit };
|
||||
if (dataToUpdate.type) {
|
||||
auditDetails.newType = dataToUpdate.type;
|
||||
@@ -437,7 +401,6 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
|
||||
auditLogService.logAction('CONNECTION_UPDATED', auditDetails);
|
||||
}
|
||||
|
||||
// 6. 获取并返回更新后的连接
|
||||
return getConnectionById(id);
|
||||
};
|
||||
|
||||
@@ -472,11 +435,13 @@ export const getConnectionWithDecryptedCredentials = async (
|
||||
// Handle potential undefined by defaulting to null
|
||||
const fullConnection: FullConnectionData = {
|
||||
...fullConnectionDbRow,
|
||||
encrypted_password: fullConnectionDbRow.encrypted_password ?? null,
|
||||
encrypted_private_key: fullConnectionDbRow.encrypted_private_key ?? null, // May be null if using ssh_key_id
|
||||
encrypted_passphrase: fullConnectionDbRow.encrypted_passphrase ?? null, // May be null if using ssh_key_id
|
||||
ssh_key_id: fullConnectionDbRow.ssh_key_id ?? null, // +++ Include ssh_key_id +++
|
||||
// Ensure other fields match FullConnectionData if necessary
|
||||
username: fullConnectionDbRow.login_credential_username ?? fullConnectionDbRow.username,
|
||||
auth_method: fullConnectionDbRow.login_credential_auth_method ?? fullConnectionDbRow.auth_method,
|
||||
encrypted_password: fullConnectionDbRow.login_credential_encrypted_password ?? fullConnectionDbRow.encrypted_password ?? null,
|
||||
encrypted_private_key: fullConnectionDbRow.login_credential_encrypted_private_key ?? fullConnectionDbRow.encrypted_private_key ?? null,
|
||||
encrypted_passphrase: fullConnectionDbRow.login_credential_encrypted_passphrase ?? fullConnectionDbRow.encrypted_passphrase ?? null,
|
||||
ssh_key_id: fullConnectionDbRow.login_credential_ssh_key_id ?? fullConnectionDbRow.ssh_key_id ?? null,
|
||||
login_credential_id: fullConnectionDbRow.login_credential_id ?? null,
|
||||
} as FullConnectionData & { ssh_key_id: number | null }; // Type assertion
|
||||
|
||||
// 2. 获取带标签的连接数据(用于返回给调用者)
|
||||
@@ -576,6 +541,7 @@ export const cloneConnection = async (originalId: number, newName: string): Prom
|
||||
encrypted_private_key: originalFullConnection.encrypted_private_key ?? null,
|
||||
encrypted_passphrase: originalFullConnection.encrypted_passphrase ?? null,
|
||||
ssh_key_id: originalFullConnection.ssh_key_id ?? null, // 保留原始的 ssh_key_id
|
||||
login_credential_id: originalFullConnection.login_credential_id ?? null,
|
||||
proxy_id: originalFullConnection.proxy_id ?? null,
|
||||
proxy_type: originalFullConnection.proxy_type ?? null, // 新增 proxy_type 复制
|
||||
notes: originalFullConnection.notes ?? null, // 确保 notes 被复制
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as SshService from '../services/ssh.service';
|
||||
import * as GuacamoleService from '../services/guacamole.service';
|
||||
import * as ImportExportService from '../services/import-export.service';
|
||||
import * as ConnectionRepository from './connection.repository';
|
||||
import * as LoginCredentialService from '../login-credentials/login-credential.service';
|
||||
|
||||
|
||||
|
||||
@@ -144,42 +145,65 @@ export const testConnection = async (req: Request, res: Response): Promise<void>
|
||||
*/
|
||||
export const testUnsavedConnection = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// 从请求体中提取连接信息 (添加 ssh_key_id)
|
||||
const { host, port, username, auth_method, password, private_key, passphrase, proxy_id, ssh_key_id } = req.body;
|
||||
const { host, port, username, auth_method, password, private_key, passphrase, proxy_id, ssh_key_id, login_credential_id } = req.body;
|
||||
|
||||
// 基本验证
|
||||
if (!host || !port || !username || !auth_method) {
|
||||
res.status(400).json({ success: false, message: '缺少必要的连接信息 (host, port, username, auth_method)。' });
|
||||
if (!host || !port) {
|
||||
res.status(400).json({ success: false, message: '缺少必要的连接信息 (host, port)。' });
|
||||
return;
|
||||
}
|
||||
// 密码认证时,password 字段必须存在,但可以为空字符串
|
||||
if (auth_method === 'password' && password === undefined) {
|
||||
res.status(400).json({ success: false, message: '密码认证方式需要提供 password 字段 (可以为空字符串)。' });
|
||||
return;
|
||||
}
|
||||
// 密钥认证时,必须提供 ssh_key_id 或 private_key
|
||||
if (auth_method === 'key' && !ssh_key_id && !private_key) {
|
||||
res.status(400).json({ success: false, message: '密钥认证方式需要提供 ssh_key_id 或 private_key。' });
|
||||
return;
|
||||
}
|
||||
// 如果同时提供了 ssh_key_id 和 private_key,优先使用 ssh_key_id (或者可以报错,这里选择优先)
|
||||
if (auth_method === 'key' && ssh_key_id && private_key) {
|
||||
console.warn('[testUnsavedConnection] 同时提供了 ssh_key_id 和 private_key,将优先使用 ssh_key_id。');
|
||||
// 不需要额外操作,后续逻辑会处理
|
||||
}
|
||||
|
||||
// 构建传递给服务层的连接配置对象
|
||||
// 注意:这里传递的是未经验证和加密处理的原始数据
|
||||
let resolvedUsername = username;
|
||||
let resolvedAuthMethod = auth_method;
|
||||
let resolvedPassword = password;
|
||||
let resolvedPrivateKey = private_key;
|
||||
let resolvedPassphrase = passphrase;
|
||||
let resolvedSshKeyId = ssh_key_id ? parseInt(ssh_key_id, 10) : null;
|
||||
|
||||
if (login_credential_id !== undefined && login_credential_id !== null) {
|
||||
const credentialId = parseInt(login_credential_id, 10);
|
||||
if (isNaN(credentialId)) {
|
||||
res.status(400).json({ success: false, message: '登录凭证 ID 必须是有效的数字。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const credential = await LoginCredentialService.getDecryptedLoginCredentialById(credentialId);
|
||||
if (!credential) {
|
||||
res.status(400).json({ success: false, message: `登录凭证 ID ${credentialId} 未找到。` });
|
||||
return;
|
||||
}
|
||||
|
||||
resolvedUsername = credential.username;
|
||||
resolvedAuthMethod = credential.auth_method;
|
||||
resolvedPassword = credential.password;
|
||||
resolvedPrivateKey = credential.privateKey;
|
||||
resolvedPassphrase = credential.passphrase;
|
||||
resolvedSshKeyId = credential.ssh_key_id ?? null;
|
||||
} else {
|
||||
if (!resolvedUsername || !resolvedAuthMethod) {
|
||||
res.status(400).json({ success: false, message: '缺少必要的连接信息 (username, auth_method)。' });
|
||||
return;
|
||||
}
|
||||
if (resolvedAuthMethod === 'password' && resolvedPassword === undefined) {
|
||||
res.status(400).json({ success: false, message: '密码认证方式需要提供 password 字段 (可以为空字符串)。' });
|
||||
return;
|
||||
}
|
||||
if (resolvedAuthMethod === 'key' && !resolvedSshKeyId && !resolvedPrivateKey) {
|
||||
res.status(400).json({ success: false, message: '密钥认证方式需要提供 ssh_key_id 或 private_key。' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const connectionConfig = {
|
||||
host,
|
||||
port: parseInt(port, 10), // 确保 port 是数字
|
||||
username,
|
||||
auth_method,
|
||||
password, // 传递原始密码
|
||||
private_key: ssh_key_id ? undefined : private_key, // 如果有 ssh_key_id,则不传递 private_key
|
||||
passphrase: ssh_key_id ? undefined : passphrase, // 如果有 ssh_key_id,则不传递 passphrase
|
||||
ssh_key_id: ssh_key_id ? parseInt(ssh_key_id, 10) : null, // 传递 ssh_key_id (确保是数字或 null)
|
||||
proxy_id: proxy_id ? parseInt(proxy_id, 10) : null // 确保 proxy_id 是数字或 null
|
||||
port: parseInt(port, 10),
|
||||
username: resolvedUsername,
|
||||
auth_method: resolvedAuthMethod,
|
||||
password: resolvedPassword,
|
||||
private_key: resolvedSshKeyId ? undefined : resolvedPrivateKey,
|
||||
passphrase: resolvedSshKeyId ? undefined : resolvedPassphrase,
|
||||
ssh_key_id: resolvedSshKeyId,
|
||||
login_credential_id: login_credential_id ? parseInt(login_credential_id, 10) : null,
|
||||
proxy_id: proxy_id ? parseInt(proxy_id, 10) : null
|
||||
};
|
||||
|
||||
// 验证 port 和 proxy_id 是否为有效数字
|
||||
@@ -192,7 +216,7 @@ export const testUnsavedConnection = async (req: Request, res: Response): Promis
|
||||
return;
|
||||
}
|
||||
// 验证 ssh_key_id (如果提供了)
|
||||
if (ssh_key_id && isNaN(connectionConfig.ssh_key_id as number)) {
|
||||
if (resolvedSshKeyId && isNaN(connectionConfig.ssh_key_id as number)) {
|
||||
res.status(400).json({ success: false, message: 'SSH 密钥 ID 必须是有效的数字。' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -306,6 +306,34 @@ const definedMigrations: Migration[] = [
|
||||
sql: `
|
||||
ALTER TABLE quick_commands ADD COLUMN variables TEXT NULL;
|
||||
`
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: 'Add login_credentials table and login_credential_id to connections',
|
||||
check: async (db: Database): Promise<boolean> => {
|
||||
const credentialsTableExists = await tableExists(db, 'login_credentials');
|
||||
const loginCredentialColumnExists = await columnExists(db, 'connections', 'login_credential_id');
|
||||
return !credentialsTableExists || !loginCredentialColumnExists;
|
||||
},
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS login_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
type TEXT NOT NULL CHECK(type IN ('SSH', 'RDP', 'VNC')),
|
||||
username TEXT NOT NULL,
|
||||
auth_method TEXT NOT NULL CHECK(auth_method IN ('password', 'key')),
|
||||
encrypted_password TEXT NULL,
|
||||
encrypted_private_key TEXT NULL,
|
||||
encrypted_passphrase TEXT NULL,
|
||||
ssh_key_id INTEGER NULL,
|
||||
notes TEXT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
ALTER TABLE connections ADD COLUMN login_credential_id INTEGER NULL REFERENCES login_credentials(id) ON DELETE SET NULL;
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ export const tableDefinitions: TableDefinition[] = [
|
||||
// Features like proxies, connections, tags
|
||||
{ name: 'proxies', sql: schemaSql.createProxiesTableSQL },
|
||||
{ name: 'ssh_keys', sql: schemaSql.createSshKeysTableSQL }, // Added SSH Keys table
|
||||
{ name: 'login_credentials', sql: schemaSql.createLoginCredentialsTableSQL },
|
||||
{ name: 'connections', sql: schemaSql.createConnectionsTableSQL }, // Depends on proxies, ssh_keys
|
||||
{ name: 'tags', sql: schemaSql.createTagsTableSQL },
|
||||
{ name: 'connection_tags', sql: schemaSql.createConnectionTagsTableSQL }, // Depends on connections, tags
|
||||
@@ -87,4 +88,4 @@ export const tableDefinitions: TableDefinition[] = [
|
||||
sql: schemaSql.createAppearanceSettingsTableSQL,
|
||||
init: initAppearanceSettingsTable
|
||||
}, // Depends on terminal_themes
|
||||
];
|
||||
];
|
||||
|
||||
@@ -94,6 +94,7 @@ CREATE TABLE IF NOT EXISTS connections (
|
||||
encrypted_passphrase TEXT NULL,
|
||||
proxy_id INTEGER NULL,
|
||||
ssh_key_id INTEGER NULL,
|
||||
login_credential_id INTEGER NULL,
|
||||
notes TEXT NULL,
|
||||
jump_chain TEXT NULL,
|
||||
proxy_type TEXT NULL,
|
||||
@@ -101,7 +102,26 @@ created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
last_connected_at INTEGER NULL,
|
||||
FOREIGN KEY (proxy_id) REFERENCES proxies(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE SET NULL
|
||||
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (login_credential_id) REFERENCES login_credentials(id) ON DELETE SET NULL
|
||||
);
|
||||
`;
|
||||
|
||||
export const createLoginCredentialsTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS login_credentials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
type TEXT NOT NULL CHECK(type IN ('SSH', 'RDP', 'VNC')),
|
||||
username TEXT NOT NULL,
|
||||
auth_method TEXT NOT NULL CHECK(auth_method IN ('password', 'key')),
|
||||
encrypted_password TEXT NULL,
|
||||
encrypted_private_key TEXT NULL,
|
||||
encrypted_passphrase TEXT NULL,
|
||||
ssh_key_id INTEGER NULL,
|
||||
notes TEXT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE SET NULL
|
||||
);
|
||||
`;
|
||||
|
||||
@@ -244,4 +264,4 @@ CREATE TABLE IF NOT EXISTS favorite_paths (
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
`;
|
||||
`;
|
||||
|
||||
@@ -51,6 +51,7 @@ import quickCommandsRoutes from './quick-commands/quick-commands.routes';
|
||||
import terminalThemeRoutes from './terminal-themes/terminal-theme.routes';
|
||||
import appearanceRoutes from './appearance/appearance.routes';
|
||||
import sshKeysRouter from './ssh_keys/ssh_keys.routes';
|
||||
import loginCredentialsRouter from './login-credentials/login-credentials.routes';
|
||||
import quickCommandTagRoutes from './quick-command-tags/quick-command-tag.routes';
|
||||
import sshSuspendRouter from './ssh-suspend/ssh-suspend.routes';
|
||||
import { transfersRoutes } from './transfers/transfers.routes';
|
||||
@@ -259,6 +260,7 @@ const startServer = () => {
|
||||
app.use('/api/v1/terminal-themes', terminalThemeRoutes);
|
||||
app.use('/api/v1/appearance', appearanceRoutes);
|
||||
app.use('/api/v1/ssh-keys', sshKeysRouter);
|
||||
app.use('/api/v1/login-credentials', loginCredentialsRouter);
|
||||
app.use('/api/v1/quick-command-tags', quickCommandTagRoutes);
|
||||
app.use('/api/v1/ssh-suspend', sshSuspendRouter);
|
||||
app.use('/api/v1/transfers', transfersRoutes());
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
|
||||
import {
|
||||
LoginCredentialBase,
|
||||
FullLoginCredentialData,
|
||||
} from '../types/login-credential.types';
|
||||
|
||||
export interface LoginCredentialDbRow extends FullLoginCredentialData {}
|
||||
|
||||
export const findAllLoginCredentials = async (): Promise<LoginCredentialBase[]> => {
|
||||
const sql = `
|
||||
SELECT
|
||||
id, name, type, username, auth_method, ssh_key_id, notes, created_at, updated_at
|
||||
FROM login_credentials
|
||||
ORDER BY updated_at DESC, name ASC
|
||||
`;
|
||||
|
||||
const db = await getDbInstance();
|
||||
return allDb<LoginCredentialBase>(db, sql);
|
||||
};
|
||||
|
||||
export const findLoginCredentialById = async (id: number): Promise<LoginCredentialDbRow | null> => {
|
||||
const sql = `
|
||||
SELECT
|
||||
id, name, type, username, auth_method,
|
||||
encrypted_password, encrypted_private_key, encrypted_passphrase,
|
||||
ssh_key_id, notes, created_at, updated_at
|
||||
FROM login_credentials
|
||||
WHERE id = ?
|
||||
`;
|
||||
|
||||
const db = await getDbInstance();
|
||||
const row = await getDbRow<LoginCredentialDbRow>(db, sql, [id]);
|
||||
return row || null;
|
||||
};
|
||||
|
||||
export const createLoginCredential = async (
|
||||
data: Omit<FullLoginCredentialData, 'id' | 'created_at' | 'updated_at'>
|
||||
): Promise<number> => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const sql = `
|
||||
INSERT INTO login_credentials (
|
||||
name, type, username, auth_method,
|
||||
encrypted_password, encrypted_private_key, encrypted_passphrase,
|
||||
ssh_key_id, notes, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
const db = await getDbInstance();
|
||||
const result = await runDb(db, sql, [
|
||||
data.name,
|
||||
data.type,
|
||||
data.username,
|
||||
data.auth_method,
|
||||
data.encrypted_password ?? null,
|
||||
data.encrypted_private_key ?? null,
|
||||
data.encrypted_passphrase ?? null,
|
||||
data.ssh_key_id ?? null,
|
||||
data.notes ?? null,
|
||||
now,
|
||||
now,
|
||||
]);
|
||||
|
||||
if (typeof result.lastID !== 'number' || result.lastID <= 0) {
|
||||
throw new Error('创建登录凭证后未能获取有效 ID。');
|
||||
}
|
||||
|
||||
return result.lastID;
|
||||
};
|
||||
|
||||
export const updateLoginCredential = async (
|
||||
id: number,
|
||||
data: Partial<Omit<FullLoginCredentialData, 'id' | 'created_at' | 'updated_at'>>
|
||||
): Promise<boolean> => {
|
||||
const fieldsToUpdate: Record<string, any> = { ...data };
|
||||
delete fieldsToUpdate.id;
|
||||
delete fieldsToUpdate.created_at;
|
||||
delete fieldsToUpdate.updated_at;
|
||||
|
||||
fieldsToUpdate.updated_at = Math.floor(Date.now() / 1000);
|
||||
|
||||
const setClauses = Object.keys(fieldsToUpdate).map((key) => `${key} = ?`).join(', ');
|
||||
if (!setClauses) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params = Object.keys(fieldsToUpdate).map((key) => fieldsToUpdate[key] ?? null);
|
||||
params.push(id);
|
||||
|
||||
const sql = `UPDATE login_credentials SET ${setClauses} WHERE id = ?`;
|
||||
const db = await getDbInstance();
|
||||
const result = await runDb(db, sql, params);
|
||||
return result.changes > 0;
|
||||
};
|
||||
|
||||
export const deleteLoginCredential = async (id: number): Promise<boolean> => {
|
||||
const sql = `DELETE FROM login_credentials WHERE id = ?`;
|
||||
const db = await getDbInstance();
|
||||
const result = await runDb(db, sql, [id]);
|
||||
return result.changes > 0;
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
import { encrypt, decrypt } from '../utils/crypto';
|
||||
import * as LoginCredentialRepository from './login-credential.repository';
|
||||
import * as SshKeyService from '../ssh_keys/ssh_key.service';
|
||||
import {
|
||||
CreateLoginCredentialInput,
|
||||
UpdateLoginCredentialInput,
|
||||
LoginCredentialBase,
|
||||
FullLoginCredentialData,
|
||||
DecryptedLoginCredentialDetails,
|
||||
} from '../types/login-credential.types';
|
||||
|
||||
export type {
|
||||
CreateLoginCredentialInput,
|
||||
UpdateLoginCredentialInput,
|
||||
LoginCredentialBase,
|
||||
FullLoginCredentialData,
|
||||
DecryptedLoginCredentialDetails,
|
||||
};
|
||||
|
||||
const buildCredentialPayload = async (
|
||||
input: CreateLoginCredentialInput | UpdateLoginCredentialInput,
|
||||
existing?: LoginCredentialRepository.LoginCredentialDbRow
|
||||
): Promise<Omit<FullLoginCredentialData, 'id' | 'created_at' | 'updated_at'>> => {
|
||||
const credentialType = (input.type ?? existing?.type)?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined;
|
||||
if (!credentialType || !['SSH', 'RDP', 'VNC'].includes(credentialType)) {
|
||||
throw new Error('必须提供有效的登录凭证类型 (SSH, RDP 或 VNC)。');
|
||||
}
|
||||
|
||||
const username = input.username ?? existing?.username;
|
||||
if (!username) {
|
||||
throw new Error('登录凭证必须提供用户名。');
|
||||
}
|
||||
|
||||
let authMethod: 'password' | 'key' = credentialType === 'SSH'
|
||||
? ((input.auth_method ?? existing?.auth_method) as 'password' | 'key' | undefined) || 'password'
|
||||
: 'password';
|
||||
|
||||
if (credentialType === 'SSH' && !['password', 'key'].includes(authMethod)) {
|
||||
throw new Error('SSH 登录凭证必须提供有效的认证方式 (password 或 key)。');
|
||||
}
|
||||
|
||||
let encryptedPassword = existing?.encrypted_password ?? null;
|
||||
let encryptedPrivateKey = existing?.encrypted_private_key ?? null;
|
||||
let encryptedPassphrase = existing?.encrypted_passphrase ?? null;
|
||||
let sshKeyId = existing?.ssh_key_id ?? null;
|
||||
|
||||
if (credentialType === 'SSH') {
|
||||
if (authMethod === 'password') {
|
||||
if (input.password !== undefined) {
|
||||
encryptedPassword = input.password ? encrypt(input.password) : null;
|
||||
}
|
||||
if (!encryptedPassword) {
|
||||
throw new Error('SSH 密码认证方式需要提供 password。');
|
||||
}
|
||||
encryptedPrivateKey = null;
|
||||
encryptedPassphrase = null;
|
||||
sshKeyId = null;
|
||||
} else {
|
||||
if (input.ssh_key_id !== undefined) {
|
||||
sshKeyId = input.ssh_key_id;
|
||||
}
|
||||
|
||||
if (sshKeyId) {
|
||||
const keyExists = await SshKeyService.getSshKeyDbRowById(sshKeyId);
|
||||
if (!keyExists) {
|
||||
throw new Error(`提供的 SSH 密钥 ID ${sshKeyId} 无效或不存在。`);
|
||||
}
|
||||
encryptedPassword = null;
|
||||
encryptedPrivateKey = null;
|
||||
encryptedPassphrase = null;
|
||||
} else if (input.private_key !== undefined) {
|
||||
encryptedPrivateKey = input.private_key ? encrypt(input.private_key) : null;
|
||||
encryptedPassphrase = input.passphrase ? encrypt(input.passphrase) : null;
|
||||
encryptedPassword = null;
|
||||
}
|
||||
|
||||
if (!sshKeyId && !encryptedPrivateKey) {
|
||||
throw new Error('SSH 密钥认证方式需要提供 private_key 或 ssh_key_id。');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
authMethod = 'password';
|
||||
if (input.password !== undefined) {
|
||||
encryptedPassword = input.password ? encrypt(input.password) : null;
|
||||
}
|
||||
if (!encryptedPassword) {
|
||||
throw new Error(`${credentialType} 登录凭证需要提供 password。`);
|
||||
}
|
||||
encryptedPrivateKey = null;
|
||||
encryptedPassphrase = null;
|
||||
sshKeyId = null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: input.name ?? existing?.name ?? '',
|
||||
type: credentialType,
|
||||
username,
|
||||
auth_method: authMethod,
|
||||
encrypted_password: encryptedPassword,
|
||||
encrypted_private_key: encryptedPrivateKey,
|
||||
encrypted_passphrase: encryptedPassphrase,
|
||||
ssh_key_id: sshKeyId,
|
||||
notes: input.notes ?? existing?.notes ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
export const getAllLoginCredentials = async (): Promise<LoginCredentialBase[]> => {
|
||||
return LoginCredentialRepository.findAllLoginCredentials();
|
||||
};
|
||||
|
||||
export const getLoginCredentialById = async (id: number): Promise<LoginCredentialRepository.LoginCredentialDbRow | null> => {
|
||||
return LoginCredentialRepository.findLoginCredentialById(id);
|
||||
};
|
||||
|
||||
export const getDecryptedLoginCredentialById = async (id: number): Promise<DecryptedLoginCredentialDetails | null> => {
|
||||
const credential = await LoginCredentialRepository.findLoginCredentialById(id);
|
||||
if (!credential) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
type: credential.type,
|
||||
username: credential.username,
|
||||
auth_method: credential.auth_method,
|
||||
ssh_key_id: credential.ssh_key_id ?? null,
|
||||
notes: credential.notes ?? null,
|
||||
created_at: credential.created_at,
|
||||
updated_at: credential.updated_at,
|
||||
password: credential.encrypted_password ? decrypt(credential.encrypted_password) : undefined,
|
||||
privateKey: credential.encrypted_private_key ? decrypt(credential.encrypted_private_key) : undefined,
|
||||
passphrase: credential.encrypted_passphrase ? decrypt(credential.encrypted_passphrase) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const createLoginCredential = async (input: CreateLoginCredentialInput): Promise<LoginCredentialBase> => {
|
||||
if (!input.name) {
|
||||
throw new Error('必须提供登录凭证名称。');
|
||||
}
|
||||
|
||||
const payload = await buildCredentialPayload(input);
|
||||
const credentialId = await LoginCredentialRepository.createLoginCredential(payload);
|
||||
return {
|
||||
id: credentialId,
|
||||
name: payload.name,
|
||||
type: payload.type,
|
||||
username: payload.username,
|
||||
auth_method: payload.auth_method,
|
||||
ssh_key_id: payload.ssh_key_id ?? null,
|
||||
notes: payload.notes ?? null,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const updateLoginCredential = async (
|
||||
id: number,
|
||||
input: UpdateLoginCredentialInput
|
||||
): Promise<LoginCredentialBase | null> => {
|
||||
const existing = await LoginCredentialRepository.findLoginCredentialById(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await buildCredentialPayload(input, existing);
|
||||
const updated = await LoginCredentialRepository.updateLoginCredential(id, payload);
|
||||
if (!updated) {
|
||||
throw new Error('更新登录凭证失败。');
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name: payload.name,
|
||||
type: payload.type,
|
||||
username: payload.username,
|
||||
auth_method: payload.auth_method,
|
||||
ssh_key_id: payload.ssh_key_id ?? null,
|
||||
notes: payload.notes ?? null,
|
||||
created_at: existing.created_at,
|
||||
updated_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteLoginCredential = async (id: number): Promise<boolean> => {
|
||||
return LoginCredentialRepository.deleteLoginCredential(id);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as LoginCredentialService from './login-credential.service';
|
||||
import { CreateLoginCredentialInput, UpdateLoginCredentialInput } from './login-credential.service';
|
||||
|
||||
export const getLoginCredentials = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const credentials = await LoginCredentialService.getAllLoginCredentials();
|
||||
res.status(200).json(credentials);
|
||||
} catch (error: any) {
|
||||
console.error('Controller: 获取登录凭证列表失败:', error);
|
||||
res.status(500).json({ message: error.message || '获取登录凭证列表时发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createLoginCredential = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const input: CreateLoginCredentialInput = req.body;
|
||||
if (!input.name || !input.type || !input.username) {
|
||||
res.status(400).json({ message: '请求体必须包含 name、type 和 username。' });
|
||||
return;
|
||||
}
|
||||
const credential = await LoginCredentialService.createLoginCredential(input);
|
||||
res.status(201).json({ message: '登录凭证创建成功。', credential });
|
||||
} catch (error: any) {
|
||||
console.error('Controller: 创建登录凭证失败:', error);
|
||||
if (error.message.includes('必须提供') || error.message.includes('需要提供') || error.message.includes('无效')) {
|
||||
res.status(400).json({ message: error.message });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ message: error.message || '创建登录凭证时发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getDecryptedLoginCredential = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const credentialId = parseInt(req.params.id, 10);
|
||||
if (isNaN(credentialId)) {
|
||||
res.status(400).json({ message: '无效的登录凭证 ID。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const credential = await LoginCredentialService.getDecryptedLoginCredentialById(credentialId);
|
||||
if (!credential) {
|
||||
res.status(404).json({ message: '登录凭证未找到。' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json(credential);
|
||||
} catch (error: any) {
|
||||
console.error(`Controller: 获取登录凭证 ${req.params.id} 详情失败:`, error);
|
||||
res.status(500).json({ message: error.message || '获取登录凭证详情时发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateLoginCredential = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const credentialId = parseInt(req.params.id, 10);
|
||||
if (isNaN(credentialId)) {
|
||||
res.status(400).json({ message: '无效的登录凭证 ID。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const input: UpdateLoginCredentialInput = req.body;
|
||||
if (Object.keys(input).length === 0) {
|
||||
res.status(400).json({ message: '请求体不能为空。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const credential = await LoginCredentialService.updateLoginCredential(credentialId, input);
|
||||
if (!credential) {
|
||||
res.status(404).json({ message: '登录凭证未找到。' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ message: '登录凭证更新成功。', credential });
|
||||
} catch (error: any) {
|
||||
console.error(`Controller: 更新登录凭证 ${req.params.id} 失败:`, error);
|
||||
if (error.message.includes('必须提供') || error.message.includes('需要提供') || error.message.includes('无效')) {
|
||||
res.status(400).json({ message: error.message });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ message: error.message || '更新登录凭证时发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteLoginCredential = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const credentialId = parseInt(req.params.id, 10);
|
||||
if (isNaN(credentialId)) {
|
||||
res.status(400).json({ message: '无效的登录凭证 ID。' });
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await LoginCredentialService.deleteLoginCredential(credentialId);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ message: '登录凭证未找到。' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({ message: '登录凭证删除成功。' });
|
||||
} catch (error: any) {
|
||||
console.error(`Controller: 删除登录凭证 ${req.params.id} 失败:`, error);
|
||||
res.status(500).json({ message: error.message || '删除登录凭证时发生内部服务器错误。' });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Router } from 'express';
|
||||
import { isAuthenticated } from '../auth/auth.middleware';
|
||||
import {
|
||||
getLoginCredentials,
|
||||
createLoginCredential,
|
||||
getDecryptedLoginCredential,
|
||||
updateLoginCredential,
|
||||
deleteLoginCredential,
|
||||
} from './login-credentials.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(isAuthenticated);
|
||||
|
||||
router.get('/', getLoginCredentials);
|
||||
router.post('/', createLoginCredential);
|
||||
router.get('/:id/details', getDecryptedLoginCredential);
|
||||
router.put('/:id', updateLoginCredential);
|
||||
router.delete('/:id', deleteLoginCredential);
|
||||
|
||||
export default router;
|
||||
@@ -57,6 +57,7 @@ export interface DecryptedConnectionDetails {
|
||||
} | null;
|
||||
jump_chain?: JumpHostDetail[];
|
||||
connection_proxy_setting?: 'proxy' | 'jump' | null;
|
||||
login_credential_id?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,32 +83,38 @@ export const getConnectionDetails = async (connectionId: number): Promise<Decryp
|
||||
name: typedRawConnInfo.name ?? (() => { throw new Error(`Connection ID ${connectionId} has null name.`); })(),
|
||||
host: typedRawConnInfo.host ?? (() => { throw new Error(`Connection ID ${connectionId} has null host.`); })(),
|
||||
port: typedRawConnInfo.port ?? (() => { throw new Error(`Connection ID ${connectionId} has null port.`); })(),
|
||||
username: typedRawConnInfo.username ?? (() => { throw new Error(`Connection ID ${connectionId} has null username.`); })(),
|
||||
auth_method: typedRawConnInfo.auth_method ?? (() => { throw new Error(`Connection ID ${connectionId} has null auth_method.`); })(),
|
||||
username: typedRawConnInfo.login_credential_username ?? typedRawConnInfo.username ?? (() => { throw new Error(`Connection ID ${connectionId} has null username.`); })(),
|
||||
auth_method: typedRawConnInfo.login_credential_auth_method ?? typedRawConnInfo.auth_method ?? (() => { throw new Error(`Connection ID ${connectionId} has null auth_method.`); })(),
|
||||
password: undefined,
|
||||
privateKey: undefined,
|
||||
passphrase: undefined,
|
||||
proxy: null,
|
||||
jump_chain: undefined,
|
||||
connection_proxy_setting: typedRawConnInfo.proxy_type ?? null,
|
||||
login_credential_id: typedRawConnInfo.login_credential_id ?? null,
|
||||
};
|
||||
|
||||
if (fullConnInfo.auth_method === 'password' && rawConnInfo.encrypted_password) {
|
||||
fullConnInfo.password = decrypt(rawConnInfo.encrypted_password);
|
||||
const encryptedPassword = typedRawConnInfo.login_credential_encrypted_password ?? rawConnInfo.encrypted_password;
|
||||
const encryptedPrivateKey = typedRawConnInfo.login_credential_encrypted_private_key ?? typedRawConnInfo.encrypted_private_key;
|
||||
const encryptedPassphrase = typedRawConnInfo.login_credential_encrypted_passphrase ?? typedRawConnInfo.encrypted_passphrase;
|
||||
const sshKeyId = typedRawConnInfo.login_credential_ssh_key_id ?? typedRawConnInfo.ssh_key_id;
|
||||
|
||||
if (fullConnInfo.auth_method === 'password' && encryptedPassword) {
|
||||
fullConnInfo.password = decrypt(encryptedPassword);
|
||||
}
|
||||
else if (fullConnInfo.auth_method === 'key') {
|
||||
if (typedRawConnInfo.ssh_key_id) {
|
||||
const storedKeyDetails = await SshKeyService.getDecryptedSshKeyById(typedRawConnInfo.ssh_key_id);
|
||||
if (sshKeyId) {
|
||||
const storedKeyDetails = await SshKeyService.getDecryptedSshKeyById(sshKeyId);
|
||||
if (!storedKeyDetails) {
|
||||
console.error(`SshService: Error: Connection ${connectionId} references non-existent SSH key ID ${typedRawConnInfo.ssh_key_id}`);
|
||||
throw new Error(`关联的 SSH 密钥 (ID: ${typedRawConnInfo.ssh_key_id}) 未找到。`);
|
||||
console.error(`SshService: Error: Connection ${connectionId} references non-existent SSH key ID ${sshKeyId}`);
|
||||
throw new Error(`关联的 SSH 密钥 (ID: ${sshKeyId}) 未找到。`);
|
||||
}
|
||||
fullConnInfo.privateKey = storedKeyDetails.privateKey;
|
||||
fullConnInfo.passphrase = storedKeyDetails.passphrase;
|
||||
} else if (typedRawConnInfo.encrypted_private_key) {
|
||||
fullConnInfo.privateKey = decrypt(typedRawConnInfo.encrypted_private_key);
|
||||
if (typedRawConnInfo.encrypted_passphrase) {
|
||||
fullConnInfo.passphrase = decrypt(typedRawConnInfo.encrypted_passphrase);
|
||||
} else if (encryptedPrivateKey) {
|
||||
fullConnInfo.privateKey = decrypt(encryptedPrivateKey);
|
||||
if (encryptedPassphrase) {
|
||||
fullConnInfo.passphrase = decrypt(encryptedPassphrase);
|
||||
}
|
||||
} else {
|
||||
console.warn(`SshService: Connection ${connectionId} uses key auth but has neither ssh_key_id nor encrypted_private_key.`);
|
||||
@@ -670,6 +677,7 @@ export const testUnsavedConnection = async (connectionConfig: {
|
||||
private_key?: string;
|
||||
passphrase?: string;
|
||||
ssh_key_id?: number | null;
|
||||
login_credential_id?: number | null;
|
||||
proxy_id?: number | null;
|
||||
}): Promise<{ latency: number }> => {
|
||||
console.log(`SshService: 测试未保存的连接到 ${connectionConfig.host}:${connectionConfig.port}...`);
|
||||
@@ -757,4 +765,4 @@ export const testUnsavedConnection = async (connectionConfig: {
|
||||
console.log(`SshService: 测试未保存连接的客户端已关闭。`);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface ConnectionBase {
|
||||
port: number;
|
||||
username: string;
|
||||
auth_method: 'password' | 'key';
|
||||
login_credential_id?: number | null;
|
||||
proxy_id: number | null;
|
||||
proxy_type?: 'proxy' | 'jump' | null;
|
||||
created_at: number;
|
||||
@@ -25,12 +26,13 @@ export interface CreateConnectionInput {
|
||||
type: 'SSH' | 'RDP' | 'VNC';
|
||||
host: string;
|
||||
port?: number;
|
||||
username: string;
|
||||
auth_method: 'password' | 'key';
|
||||
username?: string;
|
||||
auth_method?: 'password' | 'key';
|
||||
password?: string;
|
||||
private_key?: string;
|
||||
passphrase?: string;
|
||||
ssh_key_id?: number | null;
|
||||
login_credential_id?: number | null;
|
||||
proxy_id?: number | null;
|
||||
proxy_type?: 'proxy' | 'jump' | null;
|
||||
tag_ids?: number[];
|
||||
@@ -50,6 +52,7 @@ export interface UpdateConnectionInput {
|
||||
private_key?: string;
|
||||
passphrase?: string;
|
||||
ssh_key_id?: number | null;
|
||||
login_credential_id?: number | null;
|
||||
proxy_id?: number | null;
|
||||
proxy_type?: 'proxy' | 'jump' | null;
|
||||
notes?: string | null;
|
||||
@@ -70,6 +73,7 @@ export interface FullConnectionData {
|
||||
encrypted_private_key: string | null;
|
||||
encrypted_passphrase: string | null;
|
||||
ssh_key_id?: number | null;
|
||||
login_credential_id?: number | null;
|
||||
proxy_id: number | null;
|
||||
proxy_type?: 'proxy' | 'jump' | null;
|
||||
created_at: number;
|
||||
@@ -83,4 +87,4 @@ export interface DecryptedConnectionCredentials {
|
||||
decryptedPassword?: string;
|
||||
decryptedPrivateKey?: string;
|
||||
decryptedPassphrase?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface LoginCredentialBase {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'SSH' | 'RDP' | 'VNC';
|
||||
username: string;
|
||||
auth_method: 'password' | 'key';
|
||||
ssh_key_id?: number | null;
|
||||
notes?: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface CreateLoginCredentialInput {
|
||||
name: string;
|
||||
type: 'SSH' | 'RDP' | 'VNC';
|
||||
username: string;
|
||||
auth_method?: 'password' | 'key';
|
||||
password?: string;
|
||||
private_key?: string;
|
||||
passphrase?: string;
|
||||
ssh_key_id?: number | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateLoginCredentialInput {
|
||||
name?: string;
|
||||
type?: 'SSH' | 'RDP' | 'VNC';
|
||||
username?: string;
|
||||
auth_method?: 'password' | 'key';
|
||||
password?: string;
|
||||
private_key?: string;
|
||||
passphrase?: string;
|
||||
ssh_key_id?: number | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface FullLoginCredentialData extends LoginCredentialBase {
|
||||
encrypted_password?: string | null;
|
||||
encrypted_private_key?: string | null;
|
||||
encrypted_passphrase?: string | null;
|
||||
}
|
||||
|
||||
export interface DecryptedLoginCredentialDetails extends LoginCredentialBase {
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
passphrase?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user