mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-20 21:43:56 +08:00
✨ feat(connection-import): 支持导入 Navicat NCX 连接与密码
- 新增 Navicat NCX XML 解析与 v1/v2 密码解密能力 - 接入后端连接导入链路并补充导入失败提示 - 前端补充 NCX 格式识别、缺失密码提示与定向测试
This commit is contained in:
@@ -2058,14 +2058,14 @@ function App() {
|
||||
const importKind = detectConnectionImportKind(raw);
|
||||
|
||||
if (importKind === 'invalid') {
|
||||
void message.error('文件格式错误:仅支持 GoNavi 恢复包、历史 JSON 连接数组或 MySQL Workbench XML');
|
||||
void message.error('文件格式错误:仅支持 GoNavi 恢复包、历史 JSON 连接数组、MySQL Workbench XML 或 Navicat NCX');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPendingConnectionImportPayload(null);
|
||||
const importedViews = await importConnectionsPayload(raw, '');
|
||||
if (importKind === 'mysql-workbench-xml' && importedViews.some(v => !v.hasPrimaryPassword)) {
|
||||
if ((importKind === 'mysql-workbench-xml' || importKind === 'navicat-ncx') && importedViews.some(v => !v.hasPrimaryPassword)) {
|
||||
void message.warning(`成功导入 ${importedViews.length} 个连接,部分连接未包含密码,请编辑对应连接并输入密码后保存`);
|
||||
} else {
|
||||
void message.success(`成功导入 ${importedViews.length} 个连接`);
|
||||
|
||||
@@ -88,6 +88,13 @@ describe('connectionExport', () => {
|
||||
]))).toBe('legacy-json');
|
||||
});
|
||||
|
||||
it('detects Navicat NCX xml exports', () => {
|
||||
expect(detectConnectionImportKind(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Connections>
|
||||
<Connection ConnType="MYSQL" ConnectionName="Local MySQL" Host="127.0.0.1" Port="3306" UserName="root" Password="ABCD" SavePassword="true" />
|
||||
</Connections>`)).toBe('navicat-ncx');
|
||||
});
|
||||
|
||||
it('returns invalid for malformed or unsupported content', () => {
|
||||
expect(detectConnectionImportKind('{not-json}')).toBe('invalid');
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ConnectionConfig, SavedConnection } from '../types';
|
||||
|
||||
export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'mysql-workbench-xml' | 'invalid';
|
||||
export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'mysql-workbench-xml' | 'navicat-ncx' | 'invalid';
|
||||
export type ConnectionPackageDialogSnapshot = {
|
||||
open: boolean;
|
||||
mode: 'export' | 'import';
|
||||
@@ -109,10 +109,19 @@ const isMySQLWorkbenchXML = (raw: string): boolean => (
|
||||
raw.includes('<data') && raw.includes('grt_format') && raw.includes('db.mgmt.Connection')
|
||||
);
|
||||
|
||||
const isNavicatNCX = (raw: string): boolean => (
|
||||
raw.includes('<Connection')
|
||||
&& raw.includes('ConnType=')
|
||||
&& raw.includes('ConnectionName=')
|
||||
);
|
||||
|
||||
export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind => {
|
||||
if (typeof raw === 'string' && isMySQLWorkbenchXML(raw)) {
|
||||
return 'mysql-workbench-xml';
|
||||
}
|
||||
if (typeof raw === 'string' && isNavicatNCX(raw)) {
|
||||
return 'navicat-ncx';
|
||||
}
|
||||
|
||||
const parsed = parseConnectionImportRaw(raw);
|
||||
|
||||
|
||||
@@ -285,6 +285,21 @@ func (a *App) ImportConnectionsPayload(raw string, password string) ([]connectio
|
||||
return sanitizeSavedConnectionViews(views), nil
|
||||
}
|
||||
|
||||
if isNavicatNCX(trimmed) {
|
||||
inputs, err := parseNavicatNCX(trimmed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析 Navicat NCX 失败: %w", err)
|
||||
}
|
||||
if len(inputs) == 0 {
|
||||
return nil, fmt.Errorf("未在 Navicat NCX 中找到 GoNavi 支持的有效连接配置")
|
||||
}
|
||||
views, err := a.importSavedConnectionsAtomically(inputs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sanitizeSavedConnectionViews(views), nil
|
||||
}
|
||||
|
||||
var legacy []connection.LegacySavedConnection
|
||||
if err := json.Unmarshal([]byte(trimmed), &legacy); err != nil {
|
||||
return nil, errConnectionPackageUnsupported
|
||||
|
||||
470
internal/app/navicat_ncx_import.go
Normal file
470
internal/app/navicat_ncx_import.go
Normal file
@@ -0,0 +1,470 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/blowfish"
|
||||
)
|
||||
|
||||
var errNavicatSecretDecryptFailed = errors.New("解密 Navicat 密码失败")
|
||||
|
||||
type navicatNCXDocument struct {
|
||||
Connections []navicatNCXConnection `xml:"Connection"`
|
||||
}
|
||||
|
||||
type navicatNCXConnection struct {
|
||||
ConnType string `xml:"ConnType,attr"`
|
||||
ConnectionName string `xml:"ConnectionName,attr"`
|
||||
Host string `xml:"Host,attr"`
|
||||
Port string `xml:"Port,attr"`
|
||||
Database string `xml:"Database,attr"`
|
||||
DatabaseFileName string `xml:"DatabaseFileName,attr"`
|
||||
UserName string `xml:"UserName,attr"`
|
||||
Password string `xml:"Password,attr"`
|
||||
SavePassword string `xml:"SavePassword,attr"`
|
||||
OraServiceNameType string `xml:"OraServiceNameType,attr"`
|
||||
TNS string `xml:"TNS,attr"`
|
||||
SSL string `xml:"SSL,attr"`
|
||||
SSLPGSSLMode string `xml:"SSL_PGSSLMode,attr"`
|
||||
SSLClientKey string `xml:"SSL_ClientKey,attr"`
|
||||
SSLClientCert string `xml:"SSL_ClientCert,attr"`
|
||||
SSLCACert string `xml:"SSL_CACert,attr"`
|
||||
SSLWeakCertValidation string `xml:"SSL_WeakCertValidation,attr"`
|
||||
SSLAllowInvalidHostName string `xml:"SSL_AllowInvalidHostName,attr"`
|
||||
SSH string `xml:"SSH,attr"`
|
||||
SSHHost string `xml:"SSH_Host,attr"`
|
||||
SSHPort string `xml:"SSH_Port,attr"`
|
||||
SSHUserName string `xml:"SSH_UserName,attr"`
|
||||
SSHAuthenMethod string `xml:"SSH_AuthenMethod,attr"`
|
||||
SSHPassword string `xml:"SSH_Password,attr"`
|
||||
SSHSavePassword string `xml:"SSH_SavePassword,attr"`
|
||||
SSHPrivateKey string `xml:"SSH_PrivateKey,attr"`
|
||||
HTTPProxy string `xml:"HTTP_Proxy,attr"`
|
||||
HTTPProxyHost string `xml:"HTTP_Proxy_Host,attr"`
|
||||
HTTPProxyPort string `xml:"HTTP_Proxy_Port,attr"`
|
||||
HTTPProxyUserName string `xml:"HTTP_Proxy_UserName,attr"`
|
||||
HTTPProxyPassword string `xml:"HTTP_Proxy_Password,attr"`
|
||||
HTTPProxySavePassword string `xml:"HTTP_Proxy_SavePassword,attr"`
|
||||
}
|
||||
|
||||
type navicatCryptoV1 struct {
|
||||
block *blowfish.Cipher
|
||||
initialVector []byte
|
||||
}
|
||||
|
||||
func isNavicatNCX(content string) bool {
|
||||
text := strings.TrimSpace(content)
|
||||
return strings.Contains(text, "<Connection") &&
|
||||
strings.Contains(text, "ConnType=") &&
|
||||
strings.Contains(text, "ConnectionName=")
|
||||
}
|
||||
|
||||
func parseNavicatNCX(content string) ([]connection.SavedConnectionInput, error) {
|
||||
var doc navicatNCXDocument
|
||||
if err := xml.Unmarshal([]byte(content), &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
inputs := make([]connection.SavedConnectionInput, 0, len(doc.Connections))
|
||||
for _, item := range doc.Connections {
|
||||
input, ok, err := parseNavicatNCXConnection(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
inputs = append(inputs, input)
|
||||
}
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
|
||||
func parseNavicatNCXConnection(item navicatNCXConnection) (connection.SavedConnectionInput, bool, error) {
|
||||
configType, defaultPort, supported := resolveNavicatConnectionType(item.ConnType)
|
||||
if !supported {
|
||||
return connection.SavedConnectionInput{}, false, nil
|
||||
}
|
||||
|
||||
connectionID := "conn-" + uuid.New().String()[:8]
|
||||
config := connection.ConnectionConfig{
|
||||
ID: connectionID,
|
||||
Type: configType,
|
||||
Port: parseNavicatInt(item.Port, defaultPort),
|
||||
User: strings.TrimSpace(item.UserName),
|
||||
}
|
||||
|
||||
if configType == "sqlite" {
|
||||
filePath := firstNonEmpty(item.DatabaseFileName, item.Database)
|
||||
filePath = strings.TrimSpace(filePath)
|
||||
if filePath == "" {
|
||||
return connection.SavedConnectionInput{}, false, nil
|
||||
}
|
||||
config.Host = filePath
|
||||
config.Database = filePath
|
||||
} else {
|
||||
config.Host = strings.TrimSpace(item.Host)
|
||||
config.Database = strings.TrimSpace(item.Database)
|
||||
if configType == "oracle" && strings.TrimSpace(config.Database) == "" {
|
||||
config.Database = strings.TrimSpace(item.TNS)
|
||||
}
|
||||
if configType == "oracle" && strings.EqualFold(strings.TrimSpace(item.OraServiceNameType), "SID") && strings.TrimSpace(config.Database) != "" {
|
||||
config.ConnectionParams = "SID=" + strings.TrimSpace(config.Database)
|
||||
}
|
||||
}
|
||||
|
||||
if configType == "redis" {
|
||||
if dbIndex, ok := parseNavicatRedisDatabase(item.Database); ok {
|
||||
config.RedisDB = dbIndex
|
||||
}
|
||||
config.Database = ""
|
||||
}
|
||||
|
||||
password, err := decodeNavicatSecret(item.Password)
|
||||
if err != nil {
|
||||
return connection.SavedConnectionInput{}, false, fmt.Errorf("连接 %s 的密码解析失败: %w", resolveNavicatConnectionName(item, config), err)
|
||||
}
|
||||
config.Password = password
|
||||
|
||||
applyNavicatSSLConfig(&config, item)
|
||||
|
||||
sshPassword, err := decodeNavicatSecret(item.SSHPassword)
|
||||
if err != nil {
|
||||
return connection.SavedConnectionInput{}, false, fmt.Errorf("连接 %s 的 SSH 密码解析失败: %w", resolveNavicatConnectionName(item, config), err)
|
||||
}
|
||||
applyNavicatSSHConfig(&config, item, sshPassword)
|
||||
|
||||
proxyPassword, err := decodeNavicatSecret(item.HTTPProxyPassword)
|
||||
if err != nil {
|
||||
return connection.SavedConnectionInput{}, false, fmt.Errorf("连接 %s 的代理密码解析失败: %w", resolveNavicatConnectionName(item, config), err)
|
||||
}
|
||||
applyNavicatHTTPProxyConfig(&config, item, proxyPassword)
|
||||
|
||||
name := resolveNavicatConnectionName(item, config)
|
||||
return connection.SavedConnectionInput{
|
||||
ID: connectionID,
|
||||
Name: name,
|
||||
Config: config,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func resolveNavicatConnectionType(raw string) (string, int, bool) {
|
||||
switch strings.ToUpper(strings.TrimSpace(raw)) {
|
||||
case "MYSQL":
|
||||
return "mysql", 3306, true
|
||||
case "MARIADB":
|
||||
return "mariadb", 3306, true
|
||||
case "POSTGRES", "POSTGRESQL":
|
||||
return "postgres", 5432, true
|
||||
case "SQLSERVER", "MSSQL":
|
||||
return "sqlserver", 1433, true
|
||||
case "SQLITE", "SQLITE3":
|
||||
return "sqlite", 0, true
|
||||
case "ORACLE":
|
||||
return "oracle", 1521, true
|
||||
case "REDIS":
|
||||
return "redis", 6379, true
|
||||
case "MONGODB", "MONGO":
|
||||
return "mongodb", 27017, true
|
||||
case "CLICKHOUSE":
|
||||
return "clickhouse", 9000, true
|
||||
case "DAMENG", "DM":
|
||||
return "dameng", 5236, true
|
||||
default:
|
||||
return "", 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveNavicatConnectionName(item navicatNCXConnection, config connection.ConnectionConfig) string {
|
||||
name := strings.TrimSpace(item.ConnectionName)
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
if config.Type == "sqlite" {
|
||||
return strings.TrimSpace(config.Database)
|
||||
}
|
||||
if strings.TrimSpace(config.User) != "" && strings.TrimSpace(config.Host) != "" {
|
||||
return fmt.Sprintf("%s@%s:%d", strings.TrimSpace(config.User), strings.TrimSpace(config.Host), config.Port)
|
||||
}
|
||||
return strings.TrimSpace(config.Host)
|
||||
}
|
||||
|
||||
func parseNavicatInt(raw string, fallback int) int {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseNavicatBool(raw string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseNavicatRedisDatabase(raw string) (int, bool) {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || value < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func applyNavicatSSLConfig(config *connection.ConnectionConfig, item navicatNCXConnection) {
|
||||
useSSL := parseNavicatBool(item.SSL)
|
||||
if !useSSL {
|
||||
return
|
||||
}
|
||||
config.UseSSL = true
|
||||
config.SSLCAPath = strings.TrimSpace(item.SSLCACert)
|
||||
config.SSLCertPath = strings.TrimSpace(item.SSLClientCert)
|
||||
config.SSLKeyPath = strings.TrimSpace(item.SSLClientKey)
|
||||
|
||||
if parseNavicatBool(item.SSLWeakCertValidation) || parseNavicatBool(item.SSLAllowInvalidHostName) {
|
||||
config.SSLMode = "skip-verify"
|
||||
return
|
||||
}
|
||||
|
||||
if config.Type == "postgres" {
|
||||
config.SSLMode = resolveNavicatPostgresSSLMode(item.SSLPGSSLMode)
|
||||
if strings.TrimSpace(config.SSLMode) == "" {
|
||||
config.SSLMode = "required"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
config.SSLMode = "required"
|
||||
}
|
||||
|
||||
func resolveNavicatPostgresSSLMode(raw string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(raw)) {
|
||||
case "ALLOW", "PREFER":
|
||||
return "preferred"
|
||||
case "REQUIRE", "VERIFY-CA", "VERIFY_CA", "VERIFY-FULL", "VERIFY_FULL":
|
||||
return "required"
|
||||
case "DISABLE":
|
||||
return "disable"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func applyNavicatSSHConfig(config *connection.ConnectionConfig, item navicatNCXConnection, password string) {
|
||||
if !parseNavicatBool(item.SSH) {
|
||||
return
|
||||
}
|
||||
config.UseSSH = true
|
||||
config.SSH = connection.SSHConfig{
|
||||
Host: strings.TrimSpace(item.SSHHost),
|
||||
Port: parseNavicatInt(item.SSHPort, 22),
|
||||
User: strings.TrimSpace(item.SSHUserName),
|
||||
Password: password,
|
||||
KeyPath: strings.TrimSpace(item.SSHPrivateKey),
|
||||
}
|
||||
}
|
||||
|
||||
func applyNavicatHTTPProxyConfig(config *connection.ConnectionConfig, item navicatNCXConnection, password string) {
|
||||
if !parseNavicatBool(item.HTTPProxy) {
|
||||
return
|
||||
}
|
||||
config.UseProxy = true
|
||||
config.Proxy = connection.ProxyConfig{
|
||||
Type: "http",
|
||||
Host: strings.TrimSpace(item.HTTPProxyHost),
|
||||
Port: parseNavicatInt(item.HTTPProxyPort, 8080),
|
||||
User: strings.TrimSpace(item.HTTPProxyUserName),
|
||||
Password: password,
|
||||
}
|
||||
}
|
||||
|
||||
func decodeNavicatSecret(raw string) (string, error) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !looksLikeNavicatHex(text) {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
plain, err := decryptNavicatHexSecret(text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
func looksLikeNavicatHex(raw string) bool {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" || len(text)%2 != 0 {
|
||||
return false
|
||||
}
|
||||
for _, ch := range text {
|
||||
if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decryptNavicatHexSecret(raw string) (string, error) {
|
||||
ciphertext, err := hex.DecodeString(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if plaintext, err := decryptNavicatHexSecretV1(ciphertext); err == nil {
|
||||
return plaintext, nil
|
||||
}
|
||||
if plaintext, err := decryptNavicatHexSecretV2(ciphertext); err == nil {
|
||||
return plaintext, nil
|
||||
}
|
||||
return "", errNavicatSecretDecryptFailed
|
||||
}
|
||||
|
||||
func newNavicatCryptoV1() (*navicatCryptoV1, error) {
|
||||
sum := sha1.Sum([]byte("3DC5CA39"))
|
||||
block, err := blowfish.NewCipher(sum[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
initialVector := bytesRepeat(0xFF, block.BlockSize())
|
||||
block.Encrypt(initialVector, initialVector)
|
||||
return &navicatCryptoV1{
|
||||
block: block,
|
||||
initialVector: initialVector,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decryptNavicatHexSecretV1(ciphertext []byte) (string, error) {
|
||||
cryptoV1, err := newNavicatCryptoV1()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cryptoV1.decrypt(ciphertext)
|
||||
}
|
||||
|
||||
func (c *navicatCryptoV1) decrypt(ciphertext []byte) (string, error) {
|
||||
blockSize := c.block.BlockSize()
|
||||
currentVector := append([]byte(nil), c.initialVector...)
|
||||
roundBytes := len(ciphertext) / blockSize * blockSize
|
||||
plaintext := make([]byte, 0, len(ciphertext))
|
||||
decryptedBlock := make([]byte, blockSize)
|
||||
|
||||
for offset := 0; offset < roundBytes; offset += blockSize {
|
||||
c.block.Decrypt(decryptedBlock, ciphertext[offset:offset+blockSize])
|
||||
plaintext = append(plaintext, xorBytes(decryptedBlock, currentVector)...)
|
||||
currentVector = xorBytes(currentVector, ciphertext[offset:offset+blockSize])
|
||||
}
|
||||
|
||||
if leftover := len(ciphertext) - roundBytes; leftover > 0 {
|
||||
stream := make([]byte, blockSize)
|
||||
c.block.Encrypt(stream, currentVector)
|
||||
plaintext = append(plaintext, xorBytes(ciphertext[roundBytes:], stream[:leftover])...)
|
||||
}
|
||||
|
||||
if !isLikelyNavicatPlaintext(plaintext) {
|
||||
return "", errNavicatSecretDecryptFailed
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func decryptNavicatHexSecretV2(ciphertext []byte) (string, error) {
|
||||
block, err := aes.NewCipher([]byte("libcckeylibcckey"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ciphertext) == 0 || len(ciphertext)%block.BlockSize() != 0 {
|
||||
return "", errNavicatSecretDecryptFailed
|
||||
}
|
||||
|
||||
plaintext := make([]byte, len(ciphertext))
|
||||
currentVector := []byte("libcciv libcciv ")
|
||||
for offset := 0; offset < len(ciphertext); offset += block.BlockSize() {
|
||||
block.Decrypt(plaintext[offset:offset+block.BlockSize()], ciphertext[offset:offset+block.BlockSize()])
|
||||
for i := 0; i < block.BlockSize(); i++ {
|
||||
plaintext[offset+i] ^= currentVector[i]
|
||||
}
|
||||
currentVector = ciphertext[offset : offset+block.BlockSize()]
|
||||
}
|
||||
|
||||
plaintext, err = stripPKCS7Padding(plaintext, block.BlockSize())
|
||||
if err != nil || !isLikelyNavicatPlaintext(plaintext) {
|
||||
return "", errNavicatSecretDecryptFailed
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func stripPKCS7Padding(data []byte, blockSize int) ([]byte, error) {
|
||||
if len(data) == 0 || len(data)%blockSize != 0 {
|
||||
return nil, errNavicatSecretDecryptFailed
|
||||
}
|
||||
paddingLength := int(data[len(data)-1])
|
||||
if paddingLength <= 0 || paddingLength > blockSize || paddingLength > len(data) {
|
||||
return nil, errNavicatSecretDecryptFailed
|
||||
}
|
||||
for _, value := range data[len(data)-paddingLength:] {
|
||||
if int(value) != paddingLength {
|
||||
return nil, errNavicatSecretDecryptFailed
|
||||
}
|
||||
}
|
||||
return data[:len(data)-paddingLength], nil
|
||||
}
|
||||
|
||||
func isLikelyNavicatPlaintext(data []byte) bool {
|
||||
if len(data) == 0 {
|
||||
return true
|
||||
}
|
||||
if !utf8.Valid(data) {
|
||||
return false
|
||||
}
|
||||
for _, r := range string(data) {
|
||||
if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func xorBytes(left, right []byte) []byte {
|
||||
limit := len(left)
|
||||
if len(right) < limit {
|
||||
limit = len(right)
|
||||
}
|
||||
result := make([]byte, limit)
|
||||
for index := 0; index < limit; index++ {
|
||||
result[index] = left[index] ^ right[index]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func bytesRepeat(value byte, count int) []byte {
|
||||
data := make([]byte, count)
|
||||
for index := range data {
|
||||
data[index] = value
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
219
internal/app/navicat_ncx_import_test.go
Normal file
219
internal/app/navicat_ncx_import_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestImportConnectionsPayloadNavicatNCXImportsConfigsAndSecrets(t *testing.T) {
|
||||
app := NewAppWithSecretStore(newFakeAppSecretStore())
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
mysqlPassword := mustEncryptNavicatV1(t, "mysql-secret")
|
||||
postgresPassword := mustEncryptNavicatV2(t, "pg-secret")
|
||||
sshPassword := mustEncryptNavicatV1(t, "ssh-secret")
|
||||
proxyPassword := mustEncryptNavicatV2(t, "proxy-secret")
|
||||
|
||||
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Connections>
|
||||
<Connection ConnType="MYSQL" ConnectionName="Primary MySQL" Host="127.0.0.1" Port="3307" Database="demo" UserName="root" Password="%s" SavePassword="true" />
|
||||
<Connection ConnType="POSTGRESQL" ConnectionName="Reporting PG" Host="pg.local" Port="5433" Database="reporting" UserName="analyst" Password="%s" SavePassword="true" SSL="true" SSL_PGSSLMode="REQUIRE" SSL_CACert="/etc/ssl/ca.pem" SSH="true" SSH_Host="jump.local" SSH_Port="2222" SSH_UserName="ops" SSH_Password="%s" SSH_SavePassword="true" HTTP_Proxy="true" HTTP_Proxy_Host="proxy.local" HTTP_Proxy_Port="8088" HTTP_Proxy_UserName="proxy-user" HTTP_Proxy_Password="%s" HTTP_Proxy_SavePassword="true" />
|
||||
<Connection ConnType="SQLITE" ConnectionName="History DB" DatabaseFileName="C:\navicat\history.db" />
|
||||
</Connections>`, mysqlPassword, postgresPassword, sshPassword, proxyPassword)
|
||||
|
||||
imported, err := app.ImportConnectionsPayload(raw, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
|
||||
}
|
||||
if len(imported) != 3 {
|
||||
t.Fatalf("expected 3 imported connections, got %d", len(imported))
|
||||
}
|
||||
|
||||
mysqlConn := imported[0]
|
||||
if mysqlConn.Name != "Primary MySQL" {
|
||||
t.Fatalf("expected mysql connection name, got %q", mysqlConn.Name)
|
||||
}
|
||||
if mysqlConn.Config.Type != "mysql" {
|
||||
t.Fatalf("expected mysql config type, got %q", mysqlConn.Config.Type)
|
||||
}
|
||||
if !mysqlConn.HasPrimaryPassword {
|
||||
t.Fatal("expected mysql connection password flag")
|
||||
}
|
||||
resolvedMySQL, err := app.resolveConnectionSecrets(mysqlConn.Config)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveConnectionSecrets for mysql returned error: %v", err)
|
||||
}
|
||||
if resolvedMySQL.Password != "mysql-secret" {
|
||||
t.Fatalf("expected mysql password, got %q", resolvedMySQL.Password)
|
||||
}
|
||||
if resolvedMySQL.Host != "127.0.0.1" || resolvedMySQL.Port != 3307 || resolvedMySQL.Database != "demo" {
|
||||
t.Fatalf("expected mysql host/port/database to survive import, got %#v", resolvedMySQL)
|
||||
}
|
||||
|
||||
postgresConn := imported[1]
|
||||
if postgresConn.Config.Type != "postgres" {
|
||||
t.Fatalf("expected postgres config type, got %q", postgresConn.Config.Type)
|
||||
}
|
||||
if !postgresConn.HasPrimaryPassword || !postgresConn.HasSSHPassword || !postgresConn.HasProxyPassword {
|
||||
t.Fatalf("expected postgres connection to keep primary/ssh/proxy secrets, got %#v", postgresConn)
|
||||
}
|
||||
if !postgresConn.Config.UseSSL || postgresConn.Config.SSLMode != "required" || postgresConn.Config.SSLCAPath != "/etc/ssl/ca.pem" {
|
||||
t.Fatalf("expected postgres SSL settings to survive import, got %#v", postgresConn.Config)
|
||||
}
|
||||
if !postgresConn.Config.UseSSH || postgresConn.Config.SSH.Host != "jump.local" || postgresConn.Config.SSH.Port != 2222 || postgresConn.Config.SSH.User != "ops" {
|
||||
t.Fatalf("expected postgres SSH settings to survive import, got %#v", postgresConn.Config.SSH)
|
||||
}
|
||||
if !postgresConn.Config.UseProxy || postgresConn.Config.Proxy.Type != "http" || postgresConn.Config.Proxy.Host != "proxy.local" || postgresConn.Config.Proxy.Port != 8088 || postgresConn.Config.Proxy.User != "proxy-user" {
|
||||
t.Fatalf("expected postgres proxy settings to survive import, got %#v", postgresConn.Config.Proxy)
|
||||
}
|
||||
resolvedPostgres, err := app.resolveConnectionSecrets(postgresConn.Config)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveConnectionSecrets for postgres returned error: %v", err)
|
||||
}
|
||||
if resolvedPostgres.Password != "pg-secret" {
|
||||
t.Fatalf("expected postgres password, got %q", resolvedPostgres.Password)
|
||||
}
|
||||
if resolvedPostgres.SSH.Password != "ssh-secret" {
|
||||
t.Fatalf("expected ssh password, got %q", resolvedPostgres.SSH.Password)
|
||||
}
|
||||
if resolvedPostgres.Proxy.Password != "proxy-secret" {
|
||||
t.Fatalf("expected proxy password, got %q", resolvedPostgres.Proxy.Password)
|
||||
}
|
||||
|
||||
sqliteConn := imported[2]
|
||||
if sqliteConn.Config.Type != "sqlite" {
|
||||
t.Fatalf("expected sqlite config type, got %q", sqliteConn.Config.Type)
|
||||
}
|
||||
if sqliteConn.Config.Host != `C:\navicat\history.db` || sqliteConn.Config.Database != `C:\navicat\history.db` {
|
||||
t.Fatalf("expected sqlite file path to survive import, got %#v", sqliteConn.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportConnectionsPayloadNavicatNCXRejectsUndecryptableSavedPassword(t *testing.T) {
|
||||
app := NewAppWithSecretStore(newFakeAppSecretStore())
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
raw := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Connections>
|
||||
<Connection ConnType="MYSQL" ConnectionName="Broken" Host="127.0.0.1" Port="3306" UserName="root" Password="FFFF" SavePassword="true" />
|
||||
</Connections>`
|
||||
|
||||
_, err := app.ImportConnectionsPayload(raw, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid Navicat password import to fail")
|
||||
}
|
||||
if got := err.Error(); got == "" || !strings.Contains(got, "Navicat") && !strings.Contains(got, "密码") {
|
||||
t.Fatalf("expected navicat password error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportConnectionsPayloadNavicatNCXMapsOracleSIDAndRedisDB(t *testing.T) {
|
||||
app := NewAppWithSecretStore(newFakeAppSecretStore())
|
||||
app.configDir = t.TempDir()
|
||||
|
||||
oraclePassword := mustEncryptNavicatV2(t, "oracle-secret")
|
||||
redisPassword := mustEncryptNavicatV1(t, "redis-secret")
|
||||
|
||||
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Connections>
|
||||
<Connection ConnType="ORACLE" ConnectionName="Oracle SID" Host="oracle.local" Port="1521" Database="ORCL" OraServiceNameType="SID" UserName="system" Password="%s" SavePassword="true" />
|
||||
<Connection ConnType="REDIS" ConnectionName="Redis Cache" Host="redis.local" Port="6379" Database="5" UserName="default" Password="%s" SavePassword="true" />
|
||||
</Connections>`, oraclePassword, redisPassword)
|
||||
|
||||
imported, err := app.ImportConnectionsPayload(raw, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ImportConnectionsPayload returned error: %v", err)
|
||||
}
|
||||
if len(imported) != 2 {
|
||||
t.Fatalf("expected 2 imported connections, got %d", len(imported))
|
||||
}
|
||||
|
||||
oracleConn := imported[0]
|
||||
if oracleConn.Config.Type != "oracle" || oracleConn.Config.ConnectionParams != "SID=ORCL" {
|
||||
t.Fatalf("expected oracle SID connection params, got %#v", oracleConn.Config)
|
||||
}
|
||||
resolvedOracle, err := app.resolveConnectionSecrets(oracleConn.Config)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveConnectionSecrets for oracle returned error: %v", err)
|
||||
}
|
||||
if resolvedOracle.Password != "oracle-secret" {
|
||||
t.Fatalf("expected oracle password, got %q", resolvedOracle.Password)
|
||||
}
|
||||
|
||||
redisConn := imported[1]
|
||||
if redisConn.Config.Type != "redis" || redisConn.Config.RedisDB != 5 {
|
||||
t.Fatalf("expected redis db index to survive import, got %#v", redisConn.Config)
|
||||
}
|
||||
resolvedRedis, err := app.resolveConnectionSecrets(redisConn.Config)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveConnectionSecrets for redis returned error: %v", err)
|
||||
}
|
||||
if resolvedRedis.Password != "redis-secret" {
|
||||
t.Fatalf("expected redis password, got %q", resolvedRedis.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func mustEncryptNavicatV1(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
cryptoV1, err := newNavicatCryptoV1()
|
||||
if err != nil {
|
||||
t.Fatalf("newNavicatCryptoV1 returned error: %v", err)
|
||||
}
|
||||
return cryptoV1.encrypt([]byte(value))
|
||||
}
|
||||
|
||||
func mustEncryptNavicatV2(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
block, err := aes.NewCipher([]byte("libcckeylibcckey"))
|
||||
if err != nil {
|
||||
t.Fatalf("aes.NewCipher returned error: %v", err)
|
||||
}
|
||||
plaintext := applyPKCS7Padding([]byte(value), block.BlockSize())
|
||||
currentVector := []byte("libcciv libcciv ")
|
||||
ciphertext := make([]byte, len(plaintext))
|
||||
for offset := 0; offset < len(plaintext); offset += block.BlockSize() {
|
||||
blockInput := xorBytes(plaintext[offset:offset+block.BlockSize()], currentVector)
|
||||
block.Encrypt(ciphertext[offset:offset+block.BlockSize()], blockInput)
|
||||
currentVector = ciphertext[offset : offset+block.BlockSize()]
|
||||
}
|
||||
return strings.ToUpper(hex.EncodeToString(ciphertext))
|
||||
}
|
||||
|
||||
func (c *navicatCryptoV1) encrypt(plaintext []byte) string {
|
||||
blockSize := c.block.BlockSize()
|
||||
currentVector := append([]byte(nil), c.initialVector...)
|
||||
roundBytes := len(plaintext) / blockSize * blockSize
|
||||
ciphertext := make([]byte, 0, len(plaintext))
|
||||
encryptedBlock := make([]byte, blockSize)
|
||||
|
||||
for offset := 0; offset < roundBytes; offset += blockSize {
|
||||
blockInput := xorBytes(plaintext[offset:offset+blockSize], currentVector)
|
||||
c.block.Encrypt(encryptedBlock, blockInput)
|
||||
ciphertext = append(ciphertext, encryptedBlock...)
|
||||
currentVector = xorBytes(currentVector, encryptedBlock)
|
||||
}
|
||||
|
||||
if leftover := len(plaintext) - roundBytes; leftover > 0 {
|
||||
stream := make([]byte, blockSize)
|
||||
c.block.Encrypt(stream, currentVector)
|
||||
ciphertext = append(ciphertext, xorBytes(plaintext[roundBytes:], stream[:leftover])...)
|
||||
}
|
||||
|
||||
return strings.ToUpper(hex.EncodeToString(ciphertext))
|
||||
}
|
||||
|
||||
func applyPKCS7Padding(data []byte, blockSize int) []byte {
|
||||
paddingLength := blockSize - (len(data) % blockSize)
|
||||
if paddingLength == 0 {
|
||||
paddingLength = blockSize
|
||||
}
|
||||
padded := make([]byte, 0, len(data)+paddingLength)
|
||||
padded = append(padded, data...)
|
||||
for i := 0; i < paddingLength; i++ {
|
||||
padded = append(padded, byte(paddingLength))
|
||||
}
|
||||
return padded
|
||||
}
|
||||
Reference in New Issue
Block a user