feat(security): 新增连接配置与代理的密钥仓库

This commit is contained in:
tianqijiuyun-latiao
2026-04-03 01:04:15 +08:00
parent b62d22395b
commit b5e8f5c022
10 changed files with 957 additions and 3 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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, "恢复全局代理配置失败")
}
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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"`