Files
MyGoNavi/internal/app/saved_connections.go
Syngnat c7cf9526de 🐛 fix(security): 修复 macOS 无法打开应用及三平台依赖系统钥匙串的问题
- 密文存储:新增 dailysecret 本地存储引擎,连接/代理/AI 密钥不再依赖系统钥匙串
- 启动迁移:自动将已有钥匙串密文迁移到本地 JSON,用户无感知
- WebKit 迁移:从旧版 Wails WebKit LocalStorage 中恢复连接与代理数据
- DMG 修复:移除 --sandbox-safe 避免扩展属性污染签名,新增 xattr 清理与签名校验
- 安全适配:钥匙串不可用时标记完成而非回滚,消除无钥匙串环境下的阻塞
- 出口脱敏:所有连接/代理 API 返回前统一 sanitize 防止密文泄漏
2026-04-13 12:40:25 +08:00

459 lines
14 KiB
Go

package app
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"GoNavi-Wails/internal/appdata"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/dailysecret"
"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 {
return appdata.MustResolveActiveRoot()
}
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 applyConnectionSecretClears(bundle connectionSecretBundle, input connection.SavedConnectionInput) connectionSecretBundle {
cleared := bundle
if input.ClearPrimaryPassword {
cleared.Password = ""
}
if input.ClearSSHPassword {
cleared.SSHPassword = ""
}
if input.ClearProxyPassword {
cleared.ProxyPassword = ""
}
if input.ClearHTTPTunnelPassword {
cleared.HTTPTunnelPassword = ""
}
if input.ClearMySQLReplicaPassword {
cleared.MySQLReplicaPassword = ""
}
if input.ClearMongoReplicaPassword {
cleared.MongoReplicaPassword = ""
}
if input.ClearOpaqueURI {
cleared.OpaqueURI = ""
}
if input.ClearOpaqueDSN {
cleared.OpaqueDSN = ""
}
return cleared
}
func cloneStringSlice(input []string) []string {
if len(input) == 0 {
return nil
}
cloned := make([]string, len(input))
copy(cloned, input)
return cloned
}
func cloneIntSlice(input []int) []int {
if len(input) == 0 {
return nil
}
cloned := make([]int, len(input))
copy(cloned, input)
return cloned
}
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 := extractConnectionSecretBundle(meta)
meta = stripConnectionSecretFields(meta)
view := connection.SavedConnectionView{
ID: id,
Name: strings.TrimSpace(input.Name),
Config: meta,
IncludeDatabases: cloneStringSlice(input.IncludeDatabases),
IncludeRedisDatabases: cloneIntSlice(input.IncludeRedisDatabases),
IconType: strings.TrimSpace(input.IconType),
IconColor: strings.TrimSpace(input.IconColor),
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) dailySecrets() *dailysecret.Store {
return dailysecret.NewStore(r.configDir)
}
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)
}
mergedBundle = applyConnectionSecretClears(mergedBundle, input)
if mergedBundle.hasAny() {
if storeErr := r.saveSecretBundle(view.ID, mergedBundle); storeErr != nil {
return connection.SavedConnectionView{}, storeErr
}
} else {
if deleteErr := r.deleteSecretBundle(view.ID); deleteErr != nil {
return connection.SavedConnectionView{}, deleteErr
}
}
view.SecretRef = ""
applyConnectionBundleFlags(&view, mergedBundle)
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) saveSecretBundle(id string, bundle connectionSecretBundle) error {
return r.dailySecrets().PutConnection(id, toDailyConnectionBundle(bundle))
}
func (r *savedConnectionRepository) deleteSecretBundle(id string) error {
return r.dailySecrets().DeleteConnection(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) {
inline := extractConnectionSecretBundle(view.Config)
if inline.hasAny() {
return inline, nil
}
if !savedConnectionViewHasSecrets(view) {
return connectionSecretBundle{}, nil
}
bundle, ok, err := r.dailySecrets().GetConnection(view.ID)
if err != nil {
return connectionSecretBundle{}, err
}
if ok {
return fromDailyConnectionBundle(bundle), nil
}
return connectionSecretBundle{}, os.ErrNotExist
}
func (r *savedConnectionRepository) loadSecretBundleFromStore(view connection.SavedConnectionView) (connectionSecretBundle, error) {
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 buildDuplicateConnectionName(baseName string, existing []connection.SavedConnectionView) string {
trimmedBaseName := strings.TrimSpace(baseName)
if trimmedBaseName == "" {
trimmedBaseName = "连接"
}
suffix := " - 副本"
usedNames := make(map[string]struct{}, len(existing))
for _, item := range existing {
usedNames[strings.TrimSpace(item.Name)] = struct{}{}
}
candidate := trimmedBaseName + suffix
counter := 2
for {
if _, exists := usedNames[candidate]; !exists {
return candidate
}
candidate = fmt.Sprintf("%s%s %d", trimmedBaseName, suffix, counter)
counter++
}
}
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 deleteErr := r.deleteSecretBundle(item.ID); deleteErr != nil {
return deleteErr
}
continue
}
filtered = append(filtered, item)
}
return r.saveAll(filtered)
}
func (r *savedConnectionRepository) Duplicate(id string) (connection.SavedConnectionView, error) {
connections, err := r.load()
if err != nil {
return connection.SavedConnectionView{}, err
}
index := -1
for i, item := range connections {
if item.ID == strings.TrimSpace(id) {
index = i
break
}
}
if index < 0 {
return connection.SavedConnectionView{}, fmt.Errorf("saved connection not found: %s", id)
}
original := connections[index]
duplicate := original
duplicate.ID = "conn-" + uuid.New().String()[:8]
duplicate.Config.ID = duplicate.ID
duplicate.Name = buildDuplicateConnectionName(original.Name, connections)
bundle, err := r.loadSecretBundle(original)
if err != nil {
return connection.SavedConnectionView{}, err
}
if bundle.hasAny() {
if storeErr := r.saveSecretBundle(duplicate.ID, bundle); storeErr != nil {
return connection.SavedConnectionView{}, storeErr
}
}
duplicate.SecretRef = ""
applyConnectionBundleFlags(&duplicate, bundle)
connections = append(connections, duplicate)
if err := r.saveAll(connections); err != nil {
return connection.SavedConnectionView{}, err
}
return duplicate, nil
}