mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-29 03:21:51 +08:00
✨ feat(security): 新增连接配置与代理的密钥仓库
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
71
internal/app/connection_secret_resolution.go
Normal file
71
internal/app/connection_secret_resolution.go
Normal 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
|
||||
}
|
||||
42
internal/app/connection_secret_resolution_test.go
Normal file
42
internal/app/connection_secret_resolution_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
208
internal/app/global_proxy_persistence.go
Normal file
208
internal/app/global_proxy_persistence.go
Normal 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, "恢复全局代理配置失败")
|
||||
}
|
||||
}
|
||||
66
internal/app/global_proxy_secret_test.go
Normal file
66
internal/app/global_proxy_secret_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
||||
395
internal/app/saved_connections.go
Normal file
395
internal/app/saved_connections.go
Normal 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
|
||||
}
|
||||
72
internal/app/saved_connections_test.go
Normal file
72
internal/app/saved_connections_test.go
Normal 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)
|
||||
46
internal/connection/saved_types.go
Normal file
46
internal/connection/saved_types.go
Normal 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
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user