mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-30 23:49:33 +08:00
- 密文存储:新增 dailysecret 本地存储引擎,连接/代理/AI 密钥不再依赖系统钥匙串 - 启动迁移:自动将已有钥匙串密文迁移到本地 JSON,用户无感知 - WebKit 迁移:从旧版 Wails WebKit LocalStorage 中恢复连接与代理数据 - DMG 修复:移除 --sandbox-safe 避免扩展属性污染签名,新增 xattr 清理与签名校验 - 安全适配:钥匙串不可用时标记完成而非回滚,消除无钥匙串环境下的阻塞 - 出口脱敏:所有连接/代理 API 返回前统一 sanitize 防止密文泄漏
459 lines
14 KiB
Go
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
|
|
}
|