diff --git a/internal/app/app.go b/internal/app/app.go index 861d175..3f17b86 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -18,6 +18,7 @@ import ( "GoNavi-Wails/internal/db" "GoNavi-Wails/internal/logger" proxytunnel "GoNavi-Wails/internal/proxy" + "GoNavi-Wails/internal/secretstore" "github.com/google/uuid" ) @@ -53,14 +54,25 @@ type App struct { updateMu sync.Mutex updateState updateState queryMu sync.RWMutex + configDir string + secretStore secretstore.SecretStore runningQueries map[string]queryContext // queryID -> cancelFunc and start time } // NewApp creates a new App application struct func NewApp() *App { + return NewAppWithSecretStore(secretstore.NewKeyringStore()) +} + +func NewAppWithSecretStore(store secretstore.SecretStore) *App { + if store == nil { + store = secretstore.NewUnavailableStore("secret store unavailable") + } return &App{ dbCache: make(map[string]cachedDatabase), runningQueries: make(map[string]queryContext), + configDir: resolveAppConfigDir(), + secretStore: store, } } @@ -74,7 +86,11 @@ func InitializeLifecycle(a *App, ctx context.Context) { func (a *App) startup(ctx context.Context) { a.ctx = ctx a.startedAt = time.Now() + if strings.TrimSpace(a.configDir) == "" { + a.configDir = resolveAppConfigDir() + } logger.Init() + a.loadPersistedGlobalProxy() applyMacWindowTranslucencyFix() logger.Infof("应用启动完成(首次连接保护窗口=%s,最多重试=%d 次)", startupConnectRetryWindow, startupConnectRetryAttempts) } @@ -111,6 +127,7 @@ func (a *App) Shutdown(ctx context.Context) { func normalizeCacheKeyConfig(config connection.ConnectionConfig) connection.ConnectionConfig { normalized := config + normalized.ID = "" normalized.Type = strings.ToLower(strings.TrimSpace(normalized.Type)) // timeout 仅用于 Query/Ping 控制,不应作为物理连接复用键的一部分。 normalized.Timeout = 0 @@ -216,6 +233,9 @@ func shouldRefreshCachedConnection(err error) bool { } func (a *App) invalidateCachedDatabase(config connection.ConnectionConfig, reason error) bool { + if resolvedConfig, err := a.resolveConnectionSecrets(config); err == nil { + config = resolvedConfig + } effectiveConfig := applyGlobalProxyToConnection(config) key := getCacheKey(effectiveConfig) shortKey := shortCacheKey(key) @@ -439,7 +459,11 @@ func (a *App) getDatabase(config connection.ConnectionConfig) (db.Database, erro } func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Database, error) { - effectiveConfig := applyGlobalProxyToConnection(config) + resolvedConfig, err := a.resolveConnectionSecrets(config) + if err != nil { + return nil, wrapConnectError(config, err) + } + effectiveConfig := applyGlobalProxyToConnection(resolvedConfig) if supported, reason := db.DriverRuntimeSupportStatus(effectiveConfig.Type); !supported { if strings.TrimSpace(reason) == "" { reason = fmt.Sprintf("%s 驱动未启用,请先在驱动管理中安装启用", strings.TrimSpace(effectiveConfig.Type)) @@ -465,7 +489,11 @@ func (a *App) openDatabaseIsolated(config connection.ConnectionConfig) (db.Datab } func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing bool) (db.Database, error) { - effectiveConfig := applyGlobalProxyToConnection(config) + resolvedConfig, err := a.resolveConnectionSecrets(config) + if err != nil { + return nil, wrapConnectError(config, err) + } + effectiveConfig := applyGlobalProxyToConnection(resolvedConfig) isFileDB := isFileDatabaseType(effectiveConfig.Type) key := getCacheKey(effectiveConfig) @@ -546,7 +574,7 @@ func (a *App) getDatabaseWithPing(config connection.ConnectionConfig, forcePing logger.Infof("未命中文件库连接缓存,开始创建连接:类型=%s 缓存Key=%s", strings.TrimSpace(effectiveConfig.Type), shortKey) } - dbInst, connectedConfig, err := a.connectDatabaseWithStartupRetry(config) + dbInst, connectedConfig, err := a.connectDatabaseWithStartupRetry(resolvedConfig) if err != nil { return nil, err } @@ -581,6 +609,12 @@ func shortenCacheKey(key string) string { } func (a *App) connectDatabaseWithStartupRetry(rawConfig connection.ConnectionConfig) (db.Database, connection.ConnectionConfig, error) { + resolvedConfig, err := a.resolveConnectionSecrets(rawConfig) + if err != nil { + return nil, rawConfig, wrapConnectError(rawConfig, err) + } + rawConfig = resolvedConfig + var lastErr error var lastEffectiveConfig connection.ConnectionConfig diff --git a/internal/app/app_cache_key_test.go b/internal/app/app_cache_key_test.go index ef7714f..26bd175 100644 --- a/internal/app/app_cache_key_test.go +++ b/internal/app/app_cache_key_test.go @@ -24,6 +24,25 @@ func TestGetCacheKey_IgnoreTimeout(t *testing.T) { } } +func TestGetCacheKey_IgnoreConnectionID(t *testing.T) { + base := connection.ConnectionConfig{ + ID: "conn-1", + Type: "mysql", + Host: "127.0.0.1", + Port: 3306, + User: "root", + Password: "root", + } + modified := base + modified.ID = "conn-2" + + left := getCacheKey(base) + right := getCacheKey(modified) + if left != right { + t.Fatalf("expected same cache key when only connection id differs, got %s vs %s", left, right) + } +} + func TestGetCacheKey_DuckDBHostAndDatabaseEquivalent(t *testing.T) { withHost := connection.ConnectionConfig{ Type: "duckdb", diff --git a/internal/app/connection_secret_resolution.go b/internal/app/connection_secret_resolution.go new file mode 100644 index 0000000..14842c1 --- /dev/null +++ b/internal/app/connection_secret_resolution.go @@ -0,0 +1,71 @@ +package app + +import ( + "strings" + + "GoNavi-Wails/internal/connection" +) + +func (a *App) resolveConnectionSecrets(config connection.ConnectionConfig) (connection.ConnectionConfig, error) { + if strings.TrimSpace(config.ID) == "" { + return config, nil + } + + repo := newSavedConnectionRepository(a.configDir, a.secretStore) + view, err := repo.Find(config.ID) + if err != nil { + return config, err + } + + base := config + if connectionMetadataLooksEmpty(base) { + base = view.Config + } + bundle, err := repo.loadSecretBundle(view) + if err != nil { + return base, err + } + resolved := mergeConnectionSecretBundleIntoConfig(base, bundle) + resolved.ID = view.ID + return resolved, nil +} + +func connectionMetadataLooksEmpty(config connection.ConnectionConfig) bool { + return strings.TrimSpace(config.Type) == "" && + strings.TrimSpace(config.Host) == "" && + config.Port == 0 && + strings.TrimSpace(config.User) == "" && + strings.TrimSpace(config.Database) == "" && + strings.TrimSpace(config.DSN) == "" && + strings.TrimSpace(config.URI) == "" && + len(config.Hosts) == 0 +} + +func mergeConnectionSecretBundleIntoConfig(config connection.ConnectionConfig, bundle connectionSecretBundle) connection.ConnectionConfig { + merged := config + if strings.TrimSpace(merged.Password) == "" { + merged.Password = bundle.Password + } + if strings.TrimSpace(merged.SSH.Password) == "" { + merged.SSH.Password = bundle.SSHPassword + } + if strings.TrimSpace(merged.Proxy.Password) == "" { + merged.Proxy.Password = bundle.ProxyPassword + } + if strings.TrimSpace(merged.HTTPTunnel.Password) == "" { + merged.HTTPTunnel.Password = bundle.HTTPTunnelPassword + } + if strings.TrimSpace(merged.MySQLReplicaPassword) == "" { + merged.MySQLReplicaPassword = bundle.MySQLReplicaPassword + } + if strings.TrimSpace(merged.MongoReplicaPassword) == "" { + merged.MongoReplicaPassword = bundle.MongoReplicaPassword + } + if strings.TrimSpace(merged.URI) == "" { + merged.URI = bundle.OpaqueURI + } + if strings.TrimSpace(merged.DSN) == "" { + merged.DSN = bundle.OpaqueDSN + } + return merged +} diff --git a/internal/app/connection_secret_resolution_test.go b/internal/app/connection_secret_resolution_test.go new file mode 100644 index 0000000..a6336ca --- /dev/null +++ b/internal/app/connection_secret_resolution_test.go @@ -0,0 +1,42 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +func TestResolveConnectionConfigByIDLoadsSecretsFromStore(t *testing.T) { + store := newFakeAppSecretStore() + app := NewAppWithSecretStore(store) + app.configDir = t.TempDir() + + repo := newSavedConnectionRepository(app.configDir, store) + view, err := repo.Save(connection.SavedConnectionInput{ + ID: "conn-1", + Name: "Primary", + Config: connection.ConnectionConfig{ + ID: "conn-1", + Type: "postgres", + Host: "db.local", + Port: 5432, + User: "postgres", + Password: "postgres-secret", + DSN: "postgres://user:pass@db.local/app", + }, + }) + if err != nil { + t.Fatalf("Save returned error: %v", err) + } + + resolved, err := app.resolveConnectionSecrets(view.Config) + if err != nil { + t.Fatalf("resolveConnectionSecrets returned error: %v", err) + } + if resolved.Password != "postgres-secret" { + t.Fatalf("expected restored password, got %q", resolved.Password) + } + if resolved.DSN != "postgres://user:pass@db.local/app" { + t.Fatalf("expected restored DSN, got %q", resolved.DSN) + } +} diff --git a/internal/app/global_proxy_persistence.go b/internal/app/global_proxy_persistence.go new file mode 100644 index 0000000..a10a3b3 --- /dev/null +++ b/internal/app/global_proxy_persistence.go @@ -0,0 +1,208 @@ +package app + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/logger" + "GoNavi-Wails/internal/secretstore" +) + +const ( + globalProxyFileName = "global_proxy.json" + globalProxySecretKind = "global-proxy" + globalProxySecretID = "default" +) + +type globalProxySecretBundle struct { + Password string `json:"password,omitempty"` +} + +func globalProxyMetadataPath(configDir string) string { + return filepath.Join(configDir, globalProxyFileName) +} + +func (a *App) saveGlobalProxy(input connection.SaveGlobalProxyInput) (connection.GlobalProxyView, error) { + if strings.TrimSpace(a.configDir) == "" { + a.configDir = resolveAppConfigDir() + } + + existing, err := a.loadStoredGlobalProxyView() + if err != nil && !os.IsNotExist(err) { + return connection.GlobalProxyView{}, err + } + + view := connection.GlobalProxyView{ + Enabled: input.Enabled, + Type: strings.TrimSpace(input.Type), + Host: strings.TrimSpace(input.Host), + Port: input.Port, + User: strings.TrimSpace(input.User), + } + + bundle := globalProxySecretBundle{} + if strings.TrimSpace(input.Password) != "" { + bundle.Password = input.Password + } else if existing.HasPassword { + existingBundle, loadErr := a.loadGlobalProxySecretBundle(existing) + if loadErr != nil { + return connection.GlobalProxyView{}, loadErr + } + bundle = existingBundle + view.SecretRef = existing.SecretRef + } + + if !view.Enabled { + if strings.TrimSpace(existing.SecretRef) != "" && a.secretStore != nil { + if deleteErr := a.secretStore.Delete(existing.SecretRef); deleteErr != nil { + return connection.GlobalProxyView{}, deleteErr + } + } + view = connection.GlobalProxyView{Enabled: false} + if err := a.persistGlobalProxyView(view); err != nil { + return connection.GlobalProxyView{}, err + } + if _, err := setGlobalProxyConfig(false, connection.ProxyConfig{}); err != nil { + return connection.GlobalProxyView{}, err + } + return view, nil + } + + if strings.TrimSpace(bundle.Password) != "" { + ref, storeErr := a.storeGlobalProxySecret(view.SecretRef, bundle) + if storeErr != nil { + return connection.GlobalProxyView{}, storeErr + } + view.SecretRef = ref + view.HasPassword = true + } else { + if strings.TrimSpace(existing.SecretRef) != "" && a.secretStore != nil { + if deleteErr := a.secretStore.Delete(existing.SecretRef); deleteErr != nil { + return connection.GlobalProxyView{}, deleteErr + } + } + view.SecretRef = "" + view.HasPassword = false + } + + if err := a.persistGlobalProxyView(view); err != nil { + return connection.GlobalProxyView{}, err + } + if _, err := setGlobalProxyConfig(true, connection.ProxyConfig{ + Type: view.Type, + Host: view.Host, + Port: view.Port, + User: view.User, + Password: bundle.Password, + }); err != nil { + return connection.GlobalProxyView{}, err + } + view.Password = "" + return view, nil +} + +func (a *App) persistGlobalProxyView(view connection.GlobalProxyView) error { + if err := os.MkdirAll(a.configDir, 0o755); err != nil { + return err + } + payload, err := json.MarshalIndent(view, "", " ") + if err != nil { + return err + } + return os.WriteFile(globalProxyMetadataPath(a.configDir), payload, 0o644) +} + +func (a *App) loadStoredGlobalProxyView() (connection.GlobalProxyView, error) { + data, err := os.ReadFile(globalProxyMetadataPath(a.configDir)) + if err != nil { + return connection.GlobalProxyView{}, err + } + var view connection.GlobalProxyView + if err := json.Unmarshal(data, &view); err != nil { + return connection.GlobalProxyView{}, err + } + return view, nil +} + +func (a *App) loadGlobalProxySecretBundle(view connection.GlobalProxyView) (globalProxySecretBundle, error) { + if !view.HasPassword { + return globalProxySecretBundle{}, nil + } + if a.secretStore == nil { + return globalProxySecretBundle{}, fmt.Errorf("secret store unavailable") + } + ref := strings.TrimSpace(view.SecretRef) + if ref == "" { + var err error + ref, err = secretstore.BuildRef(globalProxySecretKind, globalProxySecretID) + if err != nil { + return globalProxySecretBundle{}, err + } + } + payload, err := a.secretStore.Get(ref) + if err != nil { + return globalProxySecretBundle{}, err + } + var bundle globalProxySecretBundle + if err := json.Unmarshal(payload, &bundle); err != nil { + return globalProxySecretBundle{}, err + } + return bundle, nil +} + +func (a *App) storeGlobalProxySecret(existingRef string, bundle globalProxySecretBundle) (string, error) { + if a.secretStore == nil { + return "", fmt.Errorf("secret store unavailable") + } + if err := a.secretStore.HealthCheck(); err != nil { + return "", err + } + ref := strings.TrimSpace(existingRef) + if ref == "" { + var err error + ref, err = secretstore.BuildRef(globalProxySecretKind, globalProxySecretID) + if err != nil { + return "", err + } + } + payload, err := json.Marshal(bundle) + if err != nil { + return "", err + } + if err := a.secretStore.Put(ref, payload); err != nil { + return "", err + } + return ref, nil +} + +func (a *App) loadPersistedGlobalProxy() { + view, err := a.loadStoredGlobalProxyView() + if err != nil { + if !os.IsNotExist(err) { + logger.Error(err, "加载全局代理元数据失败") + } + return + } + + proxyConfig := connection.ProxyConfig{ + Type: view.Type, + Host: view.Host, + Port: view.Port, + User: view.User, + } + if view.HasPassword { + bundle, loadErr := a.loadGlobalProxySecretBundle(view) + if loadErr != nil { + logger.Error(loadErr, "加载全局代理密码失败") + return + } + proxyConfig.Password = bundle.Password + } + if _, err := setGlobalProxyConfig(view.Enabled, proxyConfig); err != nil { + logger.Error(err, "恢复全局代理配置失败") + } +} diff --git a/internal/app/global_proxy_secret_test.go b/internal/app/global_proxy_secret_test.go new file mode 100644 index 0000000..177e949 --- /dev/null +++ b/internal/app/global_proxy_secret_test.go @@ -0,0 +1,66 @@ +package app + +import ( + "testing" + + "GoNavi-Wails/internal/connection" +) + +func TestSaveGlobalProxyStripsPasswordFromView(t *testing.T) { + store := newFakeAppSecretStore() + app := NewAppWithSecretStore(store) + app.configDir = t.TempDir() + + view, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{ + Enabled: true, + Type: "http", + Host: "127.0.0.1", + Port: 8080, + User: "ops", + Password: "proxy-secret", + }) + if err != nil { + t.Fatalf("saveGlobalProxy returned error: %v", err) + } + if view.Password != "" { + t.Fatal("global proxy view must not expose plaintext password") + } + if !view.HasPassword { + t.Fatal("expected hasPassword=true") + } + + snapshot := currentGlobalProxyConfig() + if snapshot.Proxy.Password != "proxy-secret" { + t.Fatalf("expected runtime proxy password to be preserved, got %q", snapshot.Proxy.Password) + } +} + +func TestGetGlobalProxyConfigReturnsSecretlessView(t *testing.T) { + store := newFakeAppSecretStore() + app := NewAppWithSecretStore(store) + app.configDir = t.TempDir() + + if _, err := app.saveGlobalProxy(connection.SaveGlobalProxyInput{ + Enabled: true, + Type: "http", + Host: "127.0.0.1", + Port: 8080, + User: "ops", + Password: "proxy-secret", + }); err != nil { + t.Fatalf("saveGlobalProxy returned error: %v", err) + } + + result := app.GetGlobalProxyConfig() + view, ok := result.Data.(connection.GlobalProxyView) + if !ok { + t.Fatalf("expected GlobalProxyView, got %T", result.Data) + } + if view.Password != "" { + t.Fatal("GetGlobalProxyConfig must not expose plaintext password") + } + if !view.HasPassword { + t.Fatal("expected hasPassword=true") + } +} + diff --git a/internal/app/saved_connections.go b/internal/app/saved_connections.go new file mode 100644 index 0000000..19eb76d --- /dev/null +++ b/internal/app/saved_connections.go @@ -0,0 +1,395 @@ +package app + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/secretstore" + "github.com/google/uuid" +) + +const ( + savedConnectionsFileName = "connections.json" + savedConnectionSecretKind = "connection" +) + +type connectionSecretBundle struct { + Password string `json:"password,omitempty"` + SSHPassword string `json:"sshPassword,omitempty"` + ProxyPassword string `json:"proxyPassword,omitempty"` + HTTPTunnelPassword string `json:"httpTunnelPassword,omitempty"` + MySQLReplicaPassword string `json:"mysqlReplicaPassword,omitempty"` + MongoReplicaPassword string `json:"mongoReplicaPassword,omitempty"` + OpaqueURI string `json:"opaqueURI,omitempty"` + OpaqueDSN string `json:"opaqueDSN,omitempty"` +} + +type savedConnectionsFile struct { + Connections []connection.SavedConnectionView `json:"connections"` +} + +type savedConnectionRepository struct { + configDir string + secretStore secretstore.SecretStore +} + +func resolveAppConfigDir() string { + homeDir, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(homeDir) == "" { + return "." + } + return filepath.Join(homeDir, ".gonavi") +} + +func newSavedConnectionRepository(configDir string, store secretstore.SecretStore) *savedConnectionRepository { + if strings.TrimSpace(configDir) == "" { + configDir = resolveAppConfigDir() + } + if store == nil { + store = secretstore.NewUnavailableStore("secret store unavailable") + } + return &savedConnectionRepository{configDir: configDir, secretStore: store} +} + +func (b connectionSecretBundle) hasAny() bool { + return strings.TrimSpace(b.Password) != "" || + strings.TrimSpace(b.SSHPassword) != "" || + strings.TrimSpace(b.ProxyPassword) != "" || + strings.TrimSpace(b.HTTPTunnelPassword) != "" || + strings.TrimSpace(b.MySQLReplicaPassword) != "" || + strings.TrimSpace(b.MongoReplicaPassword) != "" || + strings.TrimSpace(b.OpaqueURI) != "" || + strings.TrimSpace(b.OpaqueDSN) != "" +} + +func mergeConnectionSecretBundles(base, overlay connectionSecretBundle) connectionSecretBundle { + merged := base + if strings.TrimSpace(overlay.Password) != "" { + merged.Password = overlay.Password + } + if strings.TrimSpace(overlay.SSHPassword) != "" { + merged.SSHPassword = overlay.SSHPassword + } + if strings.TrimSpace(overlay.ProxyPassword) != "" { + merged.ProxyPassword = overlay.ProxyPassword + } + if strings.TrimSpace(overlay.HTTPTunnelPassword) != "" { + merged.HTTPTunnelPassword = overlay.HTTPTunnelPassword + } + if strings.TrimSpace(overlay.MySQLReplicaPassword) != "" { + merged.MySQLReplicaPassword = overlay.MySQLReplicaPassword + } + if strings.TrimSpace(overlay.MongoReplicaPassword) != "" { + merged.MongoReplicaPassword = overlay.MongoReplicaPassword + } + if strings.TrimSpace(overlay.OpaqueURI) != "" { + merged.OpaqueURI = overlay.OpaqueURI + } + if strings.TrimSpace(overlay.OpaqueDSN) != "" { + merged.OpaqueDSN = overlay.OpaqueDSN + } + return merged +} + +func splitConnectionSecrets(input connection.SavedConnectionInput) (connection.SavedConnectionView, connectionSecretBundle) { + id := strings.TrimSpace(input.ID) + if id == "" { + id = strings.TrimSpace(input.Config.ID) + } + + meta := input.Config + meta.ID = id + meta.SavePassword = false + + bundle := connectionSecretBundle{} + if strings.TrimSpace(meta.Password) != "" { + bundle.Password = meta.Password + meta.Password = "" + } + if strings.TrimSpace(meta.SSH.Password) != "" { + bundle.SSHPassword = meta.SSH.Password + meta.SSH.Password = "" + } + if strings.TrimSpace(meta.Proxy.Password) != "" { + bundle.ProxyPassword = meta.Proxy.Password + meta.Proxy.Password = "" + } + if strings.TrimSpace(meta.HTTPTunnel.Password) != "" { + bundle.HTTPTunnelPassword = meta.HTTPTunnel.Password + meta.HTTPTunnel.Password = "" + } + if strings.TrimSpace(meta.MySQLReplicaPassword) != "" { + bundle.MySQLReplicaPassword = meta.MySQLReplicaPassword + meta.MySQLReplicaPassword = "" + } + if strings.TrimSpace(meta.MongoReplicaPassword) != "" { + bundle.MongoReplicaPassword = meta.MongoReplicaPassword + meta.MongoReplicaPassword = "" + } + if strings.TrimSpace(meta.URI) != "" { + bundle.OpaqueURI = meta.URI + meta.URI = "" + } + if strings.TrimSpace(meta.DSN) != "" { + bundle.OpaqueDSN = meta.DSN + meta.DSN = "" + } + + view := connection.SavedConnectionView{ + ID: id, + Name: strings.TrimSpace(input.Name), + Config: meta, + HasPrimaryPassword: strings.TrimSpace(bundle.Password) != "", + HasSSHPassword: strings.TrimSpace(bundle.SSHPassword) != "", + HasProxyPassword: strings.TrimSpace(bundle.ProxyPassword) != "", + HasHTTPTunnelPassword: strings.TrimSpace(bundle.HTTPTunnelPassword) != "", + HasMySQLReplicaPassword: strings.TrimSpace(bundle.MySQLReplicaPassword) != "", + HasMongoReplicaPassword: strings.TrimSpace(bundle.MongoReplicaPassword) != "", + HasOpaqueURI: strings.TrimSpace(bundle.OpaqueURI) != "", + HasOpaqueDSN: strings.TrimSpace(bundle.OpaqueDSN) != "", + } + return view, bundle +} + +func (r *savedConnectionRepository) connectionsPath() string { + return filepath.Join(r.configDir, savedConnectionsFileName) +} + +func (r *savedConnectionRepository) load() ([]connection.SavedConnectionView, error) { + data, err := os.ReadFile(r.connectionsPath()) + if err != nil { + if os.IsNotExist(err) { + return []connection.SavedConnectionView{}, nil + } + return nil, err + } + + var file savedConnectionsFile + if err := json.Unmarshal(data, &file); err != nil { + return nil, err + } + if file.Connections == nil { + return []connection.SavedConnectionView{}, nil + } + return file.Connections, nil +} + +func (r *savedConnectionRepository) saveAll(connections []connection.SavedConnectionView) error { + if err := os.MkdirAll(r.configDir, 0o755); err != nil { + return err + } + payload, err := json.MarshalIndent(savedConnectionsFile{Connections: connections}, "", " ") + if err != nil { + return err + } + return os.WriteFile(r.connectionsPath(), payload, 0o644) +} + +func (r *savedConnectionRepository) Save(input connection.SavedConnectionInput) (connection.SavedConnectionView, error) { + if strings.TrimSpace(input.ID) == "" && strings.TrimSpace(input.Config.ID) == "" { + input.ID = "conn-" + uuid.New().String()[:8] + } + if strings.TrimSpace(input.ID) == "" { + input.ID = strings.TrimSpace(input.Config.ID) + } + input.Config.ID = input.ID + + connections, err := r.load() + if err != nil { + return connection.SavedConnectionView{}, err + } + + view, bundle := splitConnectionSecrets(input) + index := -1 + var existing connection.SavedConnectionView + for i, item := range connections { + if item.ID == view.ID { + index = i + existing = item + break + } + } + + mergedBundle := bundle + if index >= 0 && savedConnectionViewHasSecrets(existing) { + existingBundle, bundleErr := r.loadSecretBundle(existing) + if bundleErr != nil { + return connection.SavedConnectionView{}, bundleErr + } + mergedBundle = mergeConnectionSecretBundles(existingBundle, bundle) + view.SecretRef = existing.SecretRef + } + + if mergedBundle.hasAny() { + ref, storeErr := r.storeSecretBundle(view.ID, view.SecretRef, mergedBundle) + if storeErr != nil { + return connection.SavedConnectionView{}, storeErr + } + view.SecretRef = ref + applyConnectionBundleFlags(&view, mergedBundle) + } else { + if index >= 0 && strings.TrimSpace(existing.SecretRef) != "" { + if deleteErr := r.secretStore.Delete(existing.SecretRef); deleteErr != nil { + return connection.SavedConnectionView{}, deleteErr + } + } + view.SecretRef = "" + applyConnectionBundleFlags(&view, connectionSecretBundle{}) + } + + if index >= 0 { + connections[index] = view + } else { + connections = append(connections, view) + } + if err := r.saveAll(connections); err != nil { + return connection.SavedConnectionView{}, err + } + return view, nil +} + +func (r *savedConnectionRepository) Find(id string) (connection.SavedConnectionView, error) { + connections, err := r.load() + if err != nil { + return connection.SavedConnectionView{}, err + } + for _, item := range connections { + if item.ID == strings.TrimSpace(id) { + return item, nil + } + } + return connection.SavedConnectionView{}, fmt.Errorf("saved connection not found: %s", id) +} + +func (r *savedConnectionRepository) storeSecretBundle(id string, existingRef string, bundle connectionSecretBundle) (string, error) { + if r.secretStore == nil { + return "", fmt.Errorf("secret store unavailable") + } + if err := r.secretStore.HealthCheck(); err != nil { + return "", err + } + ref := strings.TrimSpace(existingRef) + if ref == "" { + var err error + ref, err = secretstore.BuildRef(savedConnectionSecretKind, id) + if err != nil { + return "", err + } + } + payload, err := json.Marshal(bundle) + if err != nil { + return "", err + } + if err := r.secretStore.Put(ref, payload); err != nil { + return "", err + } + return ref, nil +} + +func (r *savedConnectionRepository) loadSecretBundle(view connection.SavedConnectionView) (connectionSecretBundle, error) { + if !savedConnectionViewHasSecrets(view) { + return connectionSecretBundle{}, nil + } + if r.secretStore == nil { + return connectionSecretBundle{}, fmt.Errorf("secret store unavailable") + } + ref := strings.TrimSpace(view.SecretRef) + if ref == "" { + var err error + ref, err = secretstore.BuildRef(savedConnectionSecretKind, view.ID) + if err != nil { + return connectionSecretBundle{}, err + } + } + payload, err := r.secretStore.Get(ref) + if err != nil { + return connectionSecretBundle{}, err + } + var bundle connectionSecretBundle + if err := json.Unmarshal(payload, &bundle); err != nil { + return connectionSecretBundle{}, err + } + return bundle, nil +} + +func savedConnectionViewHasSecrets(view connection.SavedConnectionView) bool { + return view.HasPrimaryPassword || view.HasSSHPassword || view.HasProxyPassword || view.HasHTTPTunnelPassword || + view.HasMySQLReplicaPassword || view.HasMongoReplicaPassword || view.HasOpaqueURI || view.HasOpaqueDSN +} + +func applyConnectionBundleFlags(view *connection.SavedConnectionView, bundle connectionSecretBundle) { + view.HasPrimaryPassword = strings.TrimSpace(bundle.Password) != "" + view.HasSSHPassword = strings.TrimSpace(bundle.SSHPassword) != "" + view.HasProxyPassword = strings.TrimSpace(bundle.ProxyPassword) != "" + view.HasHTTPTunnelPassword = strings.TrimSpace(bundle.HTTPTunnelPassword) != "" + view.HasMySQLReplicaPassword = strings.TrimSpace(bundle.MySQLReplicaPassword) != "" + view.HasMongoReplicaPassword = strings.TrimSpace(bundle.MongoReplicaPassword) != "" + view.HasOpaqueURI = strings.TrimSpace(bundle.OpaqueURI) != "" + view.HasOpaqueDSN = strings.TrimSpace(bundle.OpaqueDSN) != "" +} + +func (r *savedConnectionRepository) List() ([]connection.SavedConnectionView, error) { + return r.load() +} + +func (r *savedConnectionRepository) Delete(id string) error { + connections, err := r.load() + if err != nil { + return err + } + filtered := make([]connection.SavedConnectionView, 0, len(connections)) + for _, item := range connections { + if item.ID == strings.TrimSpace(id) { + if strings.TrimSpace(item.SecretRef) != "" && r.secretStore != nil { + if deleteErr := r.secretStore.Delete(item.SecretRef); deleteErr != nil { + return deleteErr + } + } + continue + } + filtered = append(filtered, item) + } + return r.saveAll(filtered) +} + +func (r *savedConnectionRepository) Duplicate(id string) (connection.SavedConnectionView, error) { + original, err := r.Find(id) + if err != nil { + return connection.SavedConnectionView{}, err + } + + duplicate := original + duplicate.ID = "conn-" + uuid.New().String()[:8] + duplicate.Config.ID = duplicate.ID + duplicate.Name = original.Name + " Copy" + + bundle, err := r.loadSecretBundle(original) + if err != nil { + return connection.SavedConnectionView{}, err + } + if bundle.hasAny() { + ref, storeErr := r.storeSecretBundle(duplicate.ID, "", bundle) + if storeErr != nil { + return connection.SavedConnectionView{}, storeErr + } + duplicate.SecretRef = ref + applyConnectionBundleFlags(&duplicate, bundle) + } else { + duplicate.SecretRef = "" + applyConnectionBundleFlags(&duplicate, connectionSecretBundle{}) + } + + connections, err := r.load() + if err != nil { + return connection.SavedConnectionView{}, err + } + connections = append(connections, duplicate) + if err := r.saveAll(connections); err != nil { + return connection.SavedConnectionView{}, err + } + return duplicate, nil +} diff --git a/internal/app/saved_connections_test.go b/internal/app/saved_connections_test.go new file mode 100644 index 0000000..3a81e76 --- /dev/null +++ b/internal/app/saved_connections_test.go @@ -0,0 +1,72 @@ +package app + +import ( + "os" + "testing" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/secretstore" +) + +func TestSplitConnectionSecretsStripsPasswordsAndOpaqueDSN(t *testing.T) { + input := connection.SavedConnectionInput{ + ID: "conn-1", + Name: "Primary", + Config: connection.ConnectionConfig{ + ID: "conn-1", + Type: "postgres", + Host: "db.local", + Password: "postgres-secret", + DSN: "postgres://user:pass@db.local/app", + }, + } + + view, bundle := splitConnectionSecrets(input) + if view.Config.Password != "" { + t.Fatal("metadata must not keep password") + } + if bundle.Password != "postgres-secret" { + t.Fatal("bundle should keep primary password") + } + if bundle.OpaqueDSN == "" { + t.Fatal("opaque DSN should be stored as secret") + } + if !view.HasPrimaryPassword { + t.Fatal("expected view to report primary password") + } + if !view.HasOpaqueDSN { + t.Fatal("expected view to report opaque DSN") + } +} + +type fakeAppSecretStore struct { + items map[string][]byte +} + +func newFakeAppSecretStore() *fakeAppSecretStore { + return &fakeAppSecretStore{items: make(map[string][]byte)} +} + +func (s *fakeAppSecretStore) Put(ref string, payload []byte) error { + s.items[ref] = append([]byte(nil), payload...) + return nil +} + +func (s *fakeAppSecretStore) Get(ref string) ([]byte, error) { + payload, ok := s.items[ref] + if !ok { + return nil, os.ErrNotExist + } + return append([]byte(nil), payload...), nil +} + +func (s *fakeAppSecretStore) Delete(ref string) error { + delete(s.items, ref) + return nil +} + +func (s *fakeAppSecretStore) HealthCheck() error { + return nil +} + +var _ secretstore.SecretStore = (*fakeAppSecretStore)(nil) diff --git a/internal/connection/saved_types.go b/internal/connection/saved_types.go new file mode 100644 index 0000000..a364a50 --- /dev/null +++ b/internal/connection/saved_types.go @@ -0,0 +1,46 @@ +package connection + +type SavedConnectionInput struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Config ConnectionConfig `json:"config"` +} + +type SavedConnectionView struct { + ID string `json:"id"` + Name string `json:"name"` + Config ConnectionConfig `json:"config"` + SecretRef string `json:"secretRef,omitempty"` + HasPrimaryPassword bool `json:"hasPrimaryPassword,omitempty"` + HasSSHPassword bool `json:"hasSSHPassword,omitempty"` + HasProxyPassword bool `json:"hasProxyPassword,omitempty"` + HasHTTPTunnelPassword bool `json:"hasHttpTunnelPassword,omitempty"` + HasMySQLReplicaPassword bool `json:"hasMySQLReplicaPassword,omitempty"` + HasMongoReplicaPassword bool `json:"hasMongoReplicaPassword,omitempty"` + HasOpaqueURI bool `json:"hasOpaqueURI,omitempty"` + HasOpaqueDSN bool `json:"hasOpaqueDSN,omitempty"` +} + +type LegacySavedConnection = SavedConnectionInput + +type SaveGlobalProxyInput struct { + Enabled bool `json:"enabled"` + Type string `json:"type"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user,omitempty"` + Password string `json:"password,omitempty"` +} + +type GlobalProxyView struct { + Enabled bool `json:"enabled"` + Type string `json:"type"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user,omitempty"` + Password string `json:"password,omitempty"` + HasPassword bool `json:"hasPassword,omitempty"` + SecretRef string `json:"secretRef,omitempty"` +} + +type LegacyGlobalProxyInput = SaveGlobalProxyInput diff --git a/internal/connection/types.go b/internal/connection/types.go index 2c1a22b..e6e770e 100644 --- a/internal/connection/types.go +++ b/internal/connection/types.go @@ -28,6 +28,7 @@ type HTTPTunnelConfig struct { // ConnectionConfig 存储数据库连接的完整配置,包括 SSH、代理、SSL 等网络层设置。 type ConnectionConfig struct { + ID string `json:"id,omitempty"` Type string `json:"type"` Host string `json:"host"` Port int `json:"port"`