From e5e80cbbe85432026d93bebd235e541c5cccc0bc Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 28 Jul 2026 23:51:17 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(nacos):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=A4=9A=E7=89=88=E6=9C=AC=E8=BF=9E=E6=8E=A5=E4=B8=8E=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=9C=8D=E5=8A=A1=E5=8F=AF=E9=9D=A0=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支持 Nacos v1、v2、v3 就绪探测与受限账号连接 - 强化认证、缓存、单飞连接和关闭代际的并发边界 - 修复监听重复事件、导入身份碰撞及部分失败误报成功 - 补齐只读保护、结构化错误与六语言提示 --- internal/app/connection_readonly.go | 1 + internal/app/connection_readonly_test.go | 3 + internal/app/methods_nacos.go | 445 +++++-- internal/app/methods_nacos_cache_test.go | 1122 +++++++++++++++++ internal/app/methods_nacos_listen.go | 97 +- .../app/methods_nacos_listen_race_test.go | 367 ++++++ internal/app/methods_nacos_namespace_test.go | 93 ++ internal/app/methods_nacos_transfer.go | 273 +++- internal/app/methods_nacos_transfer_test.go | 421 +++++++ internal/nacos/api_version.go | 203 ++- internal/nacos/api_version_test.go | 387 +++++- internal/nacos/auth_lifecycle_test.go | 801 ++++++++++++ internal/nacos/backend_i18n.go | 35 + internal/nacos/client.go | 473 +++++-- internal/nacos/client_test.go | 487 +++++++ internal/nacos/listen.go | 70 +- internal/nacos/listen_test.go | 106 +- internal/nacos/naming.go | 15 +- internal/nacos/naming_test.go | 13 +- internal/nacos/types.go | 4 +- shared/i18n/de-DE.json | 15 + shared/i18n/en-US.json | 15 + shared/i18n/ja-JP.json | 15 + shared/i18n/ru-RU.json | 15 + shared/i18n/zh-CN.json | 15 + shared/i18n/zh-TW.json | 15 + 26 files changed, 5152 insertions(+), 354 deletions(-) create mode 100644 internal/app/methods_nacos_cache_test.go create mode 100644 internal/app/methods_nacos_listen_race_test.go create mode 100644 internal/app/methods_nacos_namespace_test.go create mode 100644 internal/app/methods_nacos_transfer_test.go create mode 100644 internal/nacos/auth_lifecycle_test.go diff --git a/internal/app/connection_readonly.go b/internal/app/connection_readonly.go index af31ff30..87e57bc4 100644 --- a/internal/app/connection_readonly.go +++ b/internal/app/connection_readonly.go @@ -29,6 +29,7 @@ var connectionReadOnlySupportedTypes = map[string]struct{}{ "mariadb": {}, "mongodb": {}, "mysql": {}, + "nacos": {}, "oceanbase": {}, "opengauss": {}, "oracle": {}, diff --git a/internal/app/connection_readonly_test.go b/internal/app/connection_readonly_test.go index 72302d51..a843dc73 100644 --- a/internal/app/connection_readonly_test.go +++ b/internal/app/connection_readonly_test.go @@ -15,6 +15,9 @@ func TestSupportsConnectionReadOnlyMode(t *testing.T) { if !supportsConnectionReadOnlyMode(connection.ConnectionConfig{Type: "mongodb"}) { t.Fatal("mongodb should support connection-level production guard") } + if !supportsConnectionReadOnlyMode(connection.ConnectionConfig{Type: "nacos"}) { + t.Fatal("nacos should support connection-level production guard") + } if supportsConnectionReadOnlyMode(connection.ConnectionConfig{Type: "redis"}) { t.Fatal("redis should not support connection-level production guard") } diff --git a/internal/app/methods_nacos.go b/internal/app/methods_nacos.go index 64ededd5..3201e71c 100644 --- a/internal/app/methods_nacos.go +++ b/internal/app/methods_nacos.go @@ -4,7 +4,11 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" + "errors" "fmt" + "net/http" + "net/url" "strconv" "strings" "sync" @@ -13,15 +17,29 @@ import ( "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/logger" "GoNavi-Wails/internal/nacos" + "golang.org/x/sync/singleflight" ) var ( - nacosCache = make(map[string]nacos.Client) - nacosCacheConfigs = make(map[string]connection.ConnectionConfig) - nacosCacheMu sync.Mutex - newNacosClientFunc = nacos.NewClient + nacosCache = make(map[string]nacos.Client) + nacosCacheMu sync.Mutex + nacosCacheGeneration uint64 + nacosCacheGenerationCtx context.Context + nacosCacheGenerationCancel context.CancelFunc + nacosConnectGroup singleflight.Group + newNacosClientFunc = nacos.NewClient ) +var errNacosCacheInvalidated = errors.New("Nacos 连接缓存已关闭") + +const defaultNacosOperationTimeoutSeconds = 30 + +const nacosNamespaceListForbiddenErrorCode = "nacos_namespace_list_forbidden" + +func init() { + nacosCacheGenerationCtx, nacosCacheGenerationCancel = context.WithCancel(context.Background()) +} + // NacosConfigQuery is the frontend search payload. type NacosConfigQuery struct { NamespaceID string `json:"namespaceId"` @@ -50,6 +68,7 @@ type NacosPublishConfigPayload struct { type NacosConfigIdentity struct { DataID string `json:"dataId"` Group string `json:"group"` + Index *int `json:"index,omitempty"` } // NacosExportConfigsOptions controls config export. @@ -105,6 +124,7 @@ type NacosServicePayload struct { NamespaceID string `json:"namespaceId"` ServiceName string `json:"serviceName"` GroupName string `json:"groupName,omitempty"` + Ephemeral *bool `json:"ephemeral,omitempty"` ProtectThreshold float64 `json:"protectThreshold,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` } @@ -126,7 +146,7 @@ type NacosInstancePayload struct { IP string `json:"ip"` Port int `json:"port"` ClusterName string `json:"clusterName,omitempty"` - Weight float64 `json:"weight,omitempty"` + Weight *float64 `json:"weight,omitempty"` Enabled *bool `json:"enabled,omitempty"` Healthy *bool `json:"healthy,omitempty"` Ephemeral *bool `json:"ephemeral,omitempty"` @@ -146,35 +166,121 @@ func formatNacosConnSummary(config connection.ConnectionConfig) string { b.WriteString(" 用户=") b.WriteString(user) } - if params := strings.TrimSpace(config.ConnectionParams); params != "" { - b.WriteString(" params=") - b.WriteString(params) + if contextPath := nacosContextPathForSummary(config.ConnectionParams); contextPath != "" { + b.WriteString(" contextPath=") + b.WriteString(contextPath) } return b.String() } +func nacosContextPathForSummary(raw string) string { + normalized := strings.NewReplacer(";", "&", "\r", "&", "\n", "&").Replace(raw) + values, _ := url.ParseQuery(normalized) + contextPath := strings.TrimSpace(values.Get("contextPath")) + if contextPath == "" { + return "" + } + for _, char := range contextPath { + if char < 0x20 || char == 0x7f { + return "" + } + } + if contextPath == "/" { + return "/" + } + if !strings.HasPrefix(contextPath, "/") { + contextPath = "/" + contextPath + } + return strings.TrimRight(contextPath, "/") +} + func getNacosClientCacheKey(config connection.ConnectionConfig) string { normalized := normalizeCacheKeyConfig(config) - raw := strings.Join([]string{ - "nacos", - strings.TrimSpace(normalized.Host), - strconv.Itoa(normalized.Port), - strings.TrimSpace(normalized.User), - strconv.FormatBool(normalized.UseSSL), - strings.TrimSpace(normalized.SSLMode), - strings.TrimSpace(normalized.ConnectionParams), - strings.TrimSpace(normalized.Database), - strconv.FormatBool(normalized.UseProxy), - strings.TrimSpace(normalized.Proxy.Type), - strings.TrimSpace(normalized.Proxy.Host), - strconv.Itoa(normalized.Proxy.Port), - strings.TrimSpace(normalized.Proxy.User), - }, "|") - sum := sha256.Sum256([]byte(raw)) + identity := struct { + Type string `json:"type"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Password string `json:"password"` + UseSSL bool `json:"useSSL"` + SSLMode string `json:"sslMode"` + SSLCAPath string `json:"sslCAPath"` + SSLCertPath string `json:"sslCertPath"` + SSLKeyPath string `json:"sslKeyPath"` + ConnectionParams string `json:"connectionParams"` + Database string `json:"database"` + UseSSH bool `json:"useSSH"` + SSHHost string `json:"sshHost"` + SSHPort int `json:"sshPort"` + SSHUser string `json:"sshUser"` + SSHPassword string `json:"sshPassword"` + SSHKeyPath string `json:"sshKeyPath"` + UseProxy bool `json:"useProxy"` + ProxyType string `json:"proxyType"` + ProxyHost string `json:"proxyHost"` + ProxyPort int `json:"proxyPort"` + ProxyUser string `json:"proxyUser"` + ProxyPassword string `json:"proxyPassword"` + UseHTTPTunnel bool `json:"useHttpTunnel"` + HTTPTunnelHost string `json:"httpTunnelHost"` + HTTPTunnelPort int `json:"httpTunnelPort"` + HTTPTunnelUser string `json:"httpTunnelUser"` + HTTPTunnelPassword string `json:"httpTunnelPassword"` + }{ + Type: "nacos", + Host: strings.TrimSpace(normalized.Host), + Port: normalized.Port, + User: strings.TrimSpace(normalized.User), + Password: normalized.Password, + UseSSL: normalized.UseSSL, + SSLMode: strings.TrimSpace(normalized.SSLMode), + SSLCAPath: strings.TrimSpace(normalized.SSLCAPath), + SSLCertPath: strings.TrimSpace(normalized.SSLCertPath), + SSLKeyPath: strings.TrimSpace(normalized.SSLKeyPath), + ConnectionParams: strings.TrimSpace(normalized.ConnectionParams), + Database: strings.TrimSpace(normalized.Database), + UseSSH: normalized.UseSSH, + SSHHost: strings.TrimSpace(normalized.SSH.Host), + SSHPort: normalized.SSH.Port, + SSHUser: strings.TrimSpace(normalized.SSH.User), + SSHPassword: normalized.SSH.Password, + SSHKeyPath: strings.TrimSpace(normalized.SSH.KeyPath), + UseProxy: normalized.UseProxy, + ProxyType: strings.TrimSpace(normalized.Proxy.Type), + ProxyHost: strings.TrimSpace(normalized.Proxy.Host), + ProxyPort: normalized.Proxy.Port, + ProxyUser: strings.TrimSpace(normalized.Proxy.User), + ProxyPassword: normalized.Proxy.Password, + UseHTTPTunnel: normalized.UseHTTPTunnel, + HTTPTunnelHost: strings.TrimSpace(normalized.HTTPTunnel.Host), + HTTPTunnelPort: normalized.HTTPTunnel.Port, + HTTPTunnelUser: strings.TrimSpace(normalized.HTTPTunnel.User), + HTTPTunnelPassword: normalized.HTTPTunnel.Password, + } + raw, _ := json.Marshal(identity) + sum := sha256.Sum256(raw) return hex.EncodeToString(sum[:]) } func (a *App) getNacosClient(config connection.ConnectionConfig) (nacos.Client, error) { + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + return a.getNacosClientWithContext(ctx, config) +} + +func (a *App) getNacosClientWithContext(ctx context.Context, config connection.ConnectionConfig) (nacos.Client, error) { + if ctx == nil { + return nil, fmt.Errorf("Nacos 连接上下文不能为空") + } + + nacosCacheMu.Lock() + requestGeneration := nacosCacheGeneration + requestGenerationCtx := nacosCacheGenerationCtx + nacosCacheMu.Unlock() + if err := ctx.Err(); err != nil { + return nil, err + } + resolvedConfig, err := a.resolveConnectionSecrets(config) if err != nil { wrapped := wrapConnectError(config, err) @@ -189,39 +295,101 @@ func (a *App) getNacosClient(config connection.ConnectionConfig) (nacos.Client, return nil, wrapped } connectConfig.Type = "nacos" - - key := getNacosClientCacheKey(connectConfig) - shortKey := key - if len(shortKey) > 12 { - shortKey = shortKey[:12] + if err := ctx.Err(); err != nil { + return nil, err } - nacosCacheMu.Lock() - defer nacosCacheMu.Unlock() + cacheIdentityConfig := resolvedConfig + cacheIdentityConfig.Type = "nacos" + key := getNacosClientCacheKey(cacheIdentityConfig) - if client, ok := nacosCache[key]; ok { - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) - defer cancel() - if err := client.Ping(ctx); err == nil { - return client, nil - } else { - logger.Error(err, "缓存 Nacos 连接不可用,准备重建:缓存Key=%s", shortKey) - _ = client.Close() - delete(nacosCache, key) - delete(nacosCacheConfigs, key) + flightKey := strconv.FormatUint(requestGeneration, 10) + ":" + key + ":" + + strconv.Itoa(nacosOperationTimeoutSeconds(connectConfig)) + + resultCh := nacosConnectGroup.DoChan(flightKey, func() (any, error) { + if requestGenerationCtx.Err() != nil { + return nil, errNacosCacheInvalidated } - } + nacosCacheMu.Lock() + if nacosCacheGeneration != requestGeneration { + nacosCacheMu.Unlock() + return nil, errNacosCacheInvalidated + } + cachedClient := nacosCache[key] + nacosCacheMu.Unlock() - client := newNacosClientFunc() - if err := client.Connect(connectConfig); err != nil { - _ = client.Close() - wrapped := wrapConnectError(connectConfig, err) - logger.Error(wrapped, "Nacos 连接失败:%s 缓存Key=%s", formatNacosConnSummary(connectConfig), shortKey) - return nil, wrapped + if cachedClient != nil { + // net/http transports reconnect on demand. Returning the published + // client directly also prevents timeout-specific flights from racing + // to evict and close the same cached client. + return cachedClient, nil + } + + // Another cache publisher may have won after this timeout-specific cold + // connection flight started. Recheck before opening a physical client. + nacosCacheMu.Lock() + if nacosCacheGeneration != requestGeneration { + nacosCacheMu.Unlock() + return nil, errNacosCacheInvalidated + } + cachedClient = nacosCache[key] + nacosCacheMu.Unlock() + if cachedClient != nil { + return cachedClient, nil + } + if requestGenerationCtx.Err() != nil { + return nil, errNacosCacheInvalidated + } + + client := newNacosClientFunc() + if err := client.Connect(connectConfig); err != nil { + _ = client.Close() + wrapped := wrapConnectError(connectConfig, err) + logger.Error(wrapped, "Nacos 连接失败:%s", formatNacosConnSummary(connectConfig)) + return nil, wrapped + } + if requestGenerationCtx.Err() != nil { + _ = client.Close() + return nil, errNacosCacheInvalidated + } + + nacosCacheMu.Lock() + cacheInvalidated := nacosCacheGeneration != requestGeneration + if !cacheInvalidated { + cachedClient = nacosCache[key] + } + if !cacheInvalidated && cachedClient == nil { + nacosCache[key] = client + } + nacosCacheMu.Unlock() + if cacheInvalidated { + _ = client.Close() + return nil, errNacosCacheInvalidated + } + if cachedClient != nil { + // Defensive loser cleanup: a cache writer outside this keyed flight + // must never leave an unpublished physical client alive. + _ = client.Close() + return cachedClient, nil + } + + logger.Infof("Nacos 连接成功并写入缓存:%s", formatNacosConnSummary(connectConfig)) + return client, nil + }) + + var result singleflight.Result + select { + case <-ctx.Done(): + return nil, ctx.Err() + case result = <-resultCh: + } + if result.Err != nil { + return nil, result.Err + } + client, ok := result.Val.(nacos.Client) + if !ok || client == nil { + return nil, fmt.Errorf("Nacos 连接缓存返回了无效实例") } - nacosCache[key] = client - nacosCacheConfigs[key] = normalizeCacheKeyConfig(connectConfig) - logger.Infof("Nacos 连接成功并写入缓存:%s 缓存Key=%s", formatNacosConnSummary(connectConfig), shortKey) return client, nil } @@ -250,17 +418,25 @@ func (a *App) openNacosClientIsolated(config connection.ConnectionConfig) (nacos } func (a *App) nacosOperationContext(config connection.ConnectionConfig) (context.Context, context.CancelFunc) { - timeout := config.Timeout - if timeout <= 0 { - timeout = 30 + return context.WithTimeout( + context.Background(), + time.Duration(nacosOperationTimeoutSeconds(config))*time.Second, + ) +} + +func nacosOperationTimeoutSeconds(config connection.ConnectionConfig) int { + if config.Timeout <= 0 { + return defaultNacosOperationTimeoutSeconds } - return context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) + return config.Timeout } // NacosConnect establishes and caches a Nacos connection. func (a *App) NacosConnect(config connection.ConnectionConfig) connection.QueryResult { config.Type = "nacos" - _, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + _, err := a.getNacosClientWithContext(ctx, config) if err != nil { logger.Error(err, "NacosConnect 连接失败:%s", formatNacosConnSummary(config)) return connection.QueryResult{Success: false, Message: err.Error()} @@ -293,16 +469,22 @@ func (a *App) NacosTestConnection(config connection.ConnectionConfig) connection // NacosListNamespaces lists namespaces for a connection. func (a *App) NacosListNamespaces(config connection.ConnectionConfig) connection.QueryResult { config.Type = "nacos" - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() namespaces, err := client.ListNamespaces(ctx) if err != nil { logger.Error(err, "NacosListNamespaces 失败:%s", formatNacosConnSummary(config)) - return connection.QueryResult{Success: false, Message: err.Error()} + result := connection.QueryResult{Success: false, Message: err.Error()} + if status, ok := nacos.HTTPStatusCode(err); ok && status == http.StatusForbidden { + result.Data = map[string]any{ + "errorCode": nacosNamespaceListForbiddenErrorCode, + } + } + return result } return connection.QueryResult{Success: true, Data: namespaces} } @@ -310,12 +492,12 @@ func (a *App) NacosListNamespaces(config connection.ConnectionConfig) connection // NacosListConfigGroups lists unique config groups under a namespace. func (a *App) NacosListConfigGroups(config connection.ConnectionConfig, namespaceID string) connection.QueryResult { config.Type = "nacos" - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() groups, err := client.ListConfigGroups(ctx, namespaceID) if err != nil { logger.Error(err, "NacosListConfigGroups 失败:%s", formatNacosConnSummary(config)) @@ -327,12 +509,12 @@ func (a *App) NacosListConfigGroups(config connection.ConnectionConfig, namespac // NacosSearchConfigs searches configs under a namespace. func (a *App) NacosSearchConfigs(config connection.ConnectionConfig, query NacosConfigQuery) connection.QueryResult { config.Type = "nacos" - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() page, err := client.SearchConfigs(ctx, nacos.ConfigQuery{ NamespaceID: query.NamespaceID, DataID: query.DataID, @@ -352,12 +534,12 @@ func (a *App) NacosSearchConfigs(config connection.ConnectionConfig, query Nacos // NacosGetConfig loads one config. func (a *App) NacosGetConfig(config connection.ConnectionConfig, namespaceID, group, dataID string) connection.QueryResult { config.Type = "nacos" - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() detail, err := client.GetConfig(ctx, namespaceID, group, dataID) if err != nil { logger.Error(err, "NacosGetConfig 失败:dataId=%s group=%s", dataID, group) @@ -372,12 +554,12 @@ func (a *App) NacosPublishConfig(config connection.ConnectionConfig, payload Nac if err := a.ensureNacosDataEditAllowed(config); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() if err := client.PublishConfig(ctx, nacos.PublishRequest{ NamespaceID: payload.NamespaceID, DataID: payload.DataID, @@ -406,12 +588,12 @@ func (a *App) NacosPublishConfig(config connection.ConnectionConfig, payload Nac // NacosGetBetaConfig loads beta config for one dataId/group. func (a *App) NacosGetBetaConfig(config connection.ConnectionConfig, namespaceID, group, dataID string) connection.QueryResult { config.Type = "nacos" - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() detail, err := client.GetBetaConfig(ctx, namespaceID, group, dataID) if err != nil { logger.Error(err, "NacosGetBetaConfig 失败:dataId=%s group=%s", dataID, group) @@ -426,12 +608,12 @@ func (a *App) NacosStopBetaConfig(config connection.ConnectionConfig, namespaceI if err := a.ensureNacosDataEditAllowed(config); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() if err := client.StopBetaConfig(ctx, namespaceID, group, dataID); err != nil { logger.Error(err, "NacosStopBetaConfig 失败:dataId=%s group=%s", dataID, group) return connection.QueryResult{Success: false, Message: err.Error()} @@ -448,12 +630,12 @@ func (a *App) NacosDeleteConfig(config connection.ConnectionConfig, namespaceID, if err := a.ensureNacosDataEditAllowed(config); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() if err := client.DeleteConfig(ctx, namespaceID, group, dataID); err != nil { logger.Error(err, "NacosDeleteConfig 失败:dataId=%s group=%s", dataID, group) return connection.QueryResult{Success: false, Message: err.Error()} @@ -470,12 +652,12 @@ func (a *App) NacosCreateNamespace(config connection.ConnectionConfig, payload N if err := a.ensureNacosStructureEditAllowed(config); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() if err := client.CreateNamespace(ctx, nacos.CreateNamespaceRequest{ ID: payload.ID, ShowName: payload.ShowName, @@ -496,12 +678,12 @@ func (a *App) NacosUpdateNamespace(config connection.ConnectionConfig, payload N if err := a.ensureNacosStructureEditAllowed(config); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() if err := client.UpdateNamespace(ctx, nacos.UpdateNamespaceRequest{ ID: payload.ID, ShowName: payload.ShowName, @@ -522,12 +704,12 @@ func (a *App) NacosDeleteNamespace(config connection.ConnectionConfig, namespace if err := a.ensureNacosStructureEditAllowed(config); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() if err := client.DeleteNamespace(ctx, namespaceID); err != nil { logger.Error(err, "NacosDeleteNamespace 失败:id=%s", namespaceID) return connection.QueryResult{Success: false, Message: err.Error()} @@ -541,12 +723,12 @@ func (a *App) NacosDeleteNamespace(config connection.ConnectionConfig, namespace // NacosListConfigHistory lists history for one config. func (a *App) NacosListConfigHistory(config connection.ConnectionConfig, query NacosHistoryQuery) connection.QueryResult { config.Type = "nacos" - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() page, err := client.ListConfigHistory(ctx, nacos.HistoryQuery{ NamespaceID: query.NamespaceID, DataID: query.DataID, @@ -564,12 +746,12 @@ func (a *App) NacosListConfigHistory(config connection.ConnectionConfig, query N // NacosGetConfigHistory loads one history detail. func (a *App) NacosGetConfigHistory(config connection.ConnectionConfig, namespaceID, group, dataID, nid string) connection.QueryResult { config.Type = "nacos" - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() item, err := client.GetConfigHistory(ctx, namespaceID, group, dataID, nid) if err != nil { logger.Error(err, "NacosGetConfigHistory 失败:nid=%s dataId=%s", nid, dataID) @@ -581,12 +763,12 @@ func (a *App) NacosGetConfigHistory(config connection.ConnectionConfig, namespac // NacosListServices lists services under a namespace. func (a *App) NacosListServices(config connection.ConnectionConfig, query NacosServiceQuery) connection.QueryResult { config.Type = "nacos" - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() page, err := client.ListServices(ctx, nacos.ServiceQuery{ NamespaceID: query.NamespaceID, GroupName: query.GroupName, @@ -603,12 +785,12 @@ func (a *App) NacosListServices(config connection.ConnectionConfig, query NacosS // NacosGetService loads service detail. func (a *App) NacosGetService(config connection.ConnectionConfig, namespaceID, serviceName, groupName string) connection.QueryResult { config.Type = "nacos" - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() detail, err := client.GetService(ctx, namespaceID, serviceName, groupName) if err != nil { logger.Error(err, "NacosGetService 失败:service=%s", serviceName) @@ -623,16 +805,17 @@ func (a *App) NacosCreateService(config connection.ConnectionConfig, payload Nac if err := a.ensureNacosStructureEditAllowed(config); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() if err := client.CreateService(ctx, nacos.CreateServiceRequest{ NamespaceID: payload.NamespaceID, ServiceName: payload.ServiceName, GroupName: payload.GroupName, + Ephemeral: payload.Ephemeral, ProtectThreshold: payload.ProtectThreshold, Metadata: payload.Metadata, }); err != nil { @@ -648,12 +831,12 @@ func (a *App) NacosUpdateService(config connection.ConnectionConfig, payload Nac if err := a.ensureNacosStructureEditAllowed(config); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() if err := client.UpdateService(ctx, nacos.UpdateServiceRequest{ NamespaceID: payload.NamespaceID, ServiceName: payload.ServiceName, @@ -673,12 +856,12 @@ func (a *App) NacosDeleteService(config connection.ConnectionConfig, namespaceID if err := a.ensureNacosStructureEditAllowed(config); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() if err := client.DeleteService(ctx, namespaceID, serviceName, groupName); err != nil { logger.Error(err, "NacosDeleteService 失败:service=%s", serviceName) return connection.QueryResult{Success: false, Message: err.Error()} @@ -689,12 +872,12 @@ func (a *App) NacosDeleteService(config connection.ConnectionConfig, namespaceID // NacosListInstances lists instances of a service. func (a *App) NacosListInstances(config connection.ConnectionConfig, query NacosInstanceQuery) connection.QueryResult { config.Type = "nacos" - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() list, err := client.ListInstances(ctx, nacos.InstanceQuery{ NamespaceID: query.NamespaceID, ServiceName: query.ServiceName, @@ -712,12 +895,12 @@ func (a *App) NacosListInstances(config connection.ConnectionConfig, query Nacos // NacosGetInstance loads one instance. func (a *App) NacosGetInstance(config connection.ConnectionConfig, payload NacosInstancePayload) connection.QueryResult { config.Type = "nacos" - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() inst, err := client.GetInstance(ctx, toNacosInstanceRequest(payload)) if err != nil { logger.Error(err, "NacosGetInstance 失败:%s:%d", payload.IP, payload.Port) @@ -732,12 +915,12 @@ func (a *App) NacosRegisterInstance(config connection.ConnectionConfig, payload if err := a.ensureNacosDataEditAllowed(config); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() if err := client.RegisterInstance(ctx, toNacosInstanceRequest(payload)); err != nil { logger.Error(err, "NacosRegisterInstance 失败:%s:%d", payload.IP, payload.Port) return connection.QueryResult{Success: false, Message: err.Error()} @@ -751,12 +934,12 @@ func (a *App) NacosUpdateInstance(config connection.ConnectionConfig, payload Na if err := a.ensureNacosDataEditAllowed(config); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() if err := client.UpdateInstance(ctx, toNacosInstanceRequest(payload)); err != nil { logger.Error(err, "NacosUpdateInstance 失败:%s:%d", payload.IP, payload.Port) return connection.QueryResult{Success: false, Message: err.Error()} @@ -770,12 +953,12 @@ func (a *App) NacosDeregisterInstance(config connection.ConnectionConfig, payloa if err := a.ensureNacosDataEditAllowed(config); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() if err := client.DeregisterInstance(ctx, toNacosInstanceRequest(payload)); err != nil { logger.Error(err, "NacosDeregisterInstance 失败:%s:%d", payload.IP, payload.Port) return connection.QueryResult{Success: false, Message: err.Error()} @@ -789,12 +972,12 @@ func (a *App) NacosUpdateInstanceHealth(config connection.ConnectionConfig, payl if err := a.ensureNacosDataEditAllowed(config); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() if err := client.UpdateInstanceHealth(ctx, toNacosInstanceRequest(payload)); err != nil { logger.Error(err, "NacosUpdateInstanceHealth 失败:%s:%d", payload.IP, payload.Port) return connection.QueryResult{Success: false, Message: err.Error()} @@ -819,7 +1002,7 @@ func toNacosInstanceRequest(payload NacosInstancePayload) nacos.InstanceRequest } func (a *App) ensureNacosDataEditAllowed(config connection.ConnectionConfig) error { - // Nacos is outside the SQL read-only type set; honor explicit flags directly. + // Keep the Nacos-specific message while honoring the shared production guard. if config.ReadOnly || config.Protection.RestrictDataEdit { return fmt.Errorf("%s", a.appText("nacos.backend.error.read_only", nil)) } @@ -827,7 +1010,7 @@ func (a *App) ensureNacosDataEditAllowed(config connection.ConnectionConfig) err } func (a *App) ensureNacosStructureEditAllowed(config connection.ConnectionConfig) error { - if config.ReadOnly || config.Protection.RestrictStructureEdit || config.Protection.RestrictDataEdit { + if config.ReadOnly || config.Protection.RestrictStructureEdit { return fmt.Errorf("%s", a.appText("nacos.backend.error.read_only", nil)) } return nil diff --git a/internal/app/methods_nacos_cache_test.go b/internal/app/methods_nacos_cache_test.go new file mode 100644 index 00000000..8548aae3 --- /dev/null +++ b/internal/app/methods_nacos_cache_test.go @@ -0,0 +1,1122 @@ +package app + +import ( + "context" + "errors" + "os" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/logger" + "GoNavi-Wails/internal/nacos" +) + +type nacosCacheTestClient struct { + nacos.Client + connect func(connection.ConnectionConfig) error + ping func(context.Context) error + listNamespaces func(context.Context) ([]nacos.Namespace, error) + closed atomic.Int32 +} + +func (client *nacosCacheTestClient) Connect(config connection.ConnectionConfig) error { + if client.connect != nil { + return client.connect(config) + } + return nil +} + +func (client *nacosCacheTestClient) Ping(ctx context.Context) error { + if client.ping != nil { + return client.ping(ctx) + } + return nil +} + +func (client *nacosCacheTestClient) ListNamespaces(ctx context.Context) ([]nacos.Namespace, error) { + if client.listNamespaces != nil { + return client.listNamespaces(ctx) + } + return nil, nil +} + +func (client *nacosCacheTestClient) Close() error { + client.closed.Add(1) + return nil +} + +var _ nacos.Client = (*nacosCacheTestClient)(nil) + +func installNacosCacheTestHooks(t *testing.T) { + t.Helper() + + originalNewNacosClientFunc := newNacosClientFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + + nacosCacheMu.Lock() + originalCache := nacosCache + originalCacheGeneration := nacosCacheGeneration + originalCacheGenerationCtx := nacosCacheGenerationCtx + originalCacheGenerationCancel := nacosCacheGenerationCancel + nacosCache = make(map[string]nacos.Client) + nacosCacheGeneration = 0 + nacosCacheGenerationCtx, nacosCacheGenerationCancel = context.WithCancel(context.Background()) + nacosCacheMu.Unlock() + + resolveDialConfigWithProxyFunc = func(config connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return config, nil + } + t.Cleanup(func() { + nacosCacheMu.Lock() + testCache := nacosCache + testCacheGenerationCancel := nacosCacheGenerationCancel + nacosCache = originalCache + nacosCacheGeneration = originalCacheGeneration + nacosCacheGenerationCtx = originalCacheGenerationCtx + nacosCacheGenerationCancel = originalCacheGenerationCancel + nacosCacheMu.Unlock() + if testCacheGenerationCancel != nil { + testCacheGenerationCancel() + } + + for _, client := range testCache { + if client != nil { + _ = client.Close() + } + } + newNacosClientFunc = originalNewNacosClientFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + }) +} + +func TestFormatNacosConnSummaryOnlyIncludesSafeContextPath(t *testing.T) { + const ( + accessToken = "nacos-access-token-secret" + password = "nacos-params-password-secret" + token = "nacos-generic-token-secret" + ) + summary := formatNacosConnSummary(connection.ConnectionConfig{ + Type: "nacos", + Host: "nacos.example.com", + Port: 8848, + User: "operator", + UseSSL: true, + ConnectionParams: "contextPath=custom-nacos&accessToken=" + accessToken + "&password=" + password + "&token=" + token, + }) + + for _, expected := range []string{ + "地址=nacos.example.com:8848", + "SSL=on", + "用户=operator", + "contextPath=/custom-nacos", + } { + if !strings.Contains(summary, expected) { + t.Fatalf("connection summary %q does not contain %q", summary, expected) + } + } + for _, sensitive := range []string{ + accessToken, + password, + token, + "accessToken=", + "password=", + "token=", + } { + if strings.Contains(summary, sensitive) { + t.Fatalf("connection summary exposed sensitive parameter %q: %q", sensitive, summary) + } + } +} + +func TestGetNacosClientCacheKeyIncludesAuthenticationAndTLSIdentity(t *testing.T) { + const sha256HexLength = 64 + + base := connection.ConnectionConfig{ + Type: "nacos", + Host: "nacos.example.com", + Port: 8848, + User: "nacos", + Password: "nacos-secret:not-hex", + UseSSL: true, + SSLMode: "required", + SSLCAPath: "C:/certs/ca-a.pem", + SSLCertPath: "C:/certs/client-a.pem", + SSLKeyPath: "C:/certs/client-a.key", + UseProxy: true, + Proxy: connection.ProxyConfig{ + Type: "http", + Host: "proxy.example.com", + Port: 8080, + User: "proxy-user", + Password: "proxy-secret:not-hex", + }, + } + baseKey := getNacosClientCacheKey(base) + if len(baseKey) != sha256HexLength { + t.Fatalf("cache key length = %d, want %d", len(baseKey), sha256HexLength) + } + for _, secret := range []string{base.Password, base.Proxy.Password} { + if strings.Contains(baseKey, secret) { + t.Fatalf("cache key exposed plaintext secret %q", secret) + } + } + + tests := []struct { + name string + mutate func(*connection.ConnectionConfig) + }{ + { + name: "nacos password", + mutate: func(config *connection.ConnectionConfig) { + config.Password = "another-nacos-secret" + }, + }, + { + name: "proxy password", + mutate: func(config *connection.ConnectionConfig) { + config.Proxy.Password = "another-proxy-secret" + }, + }, + { + name: "CA path", + mutate: func(config *connection.ConnectionConfig) { + config.SSLCAPath = "C:/certs/ca-b.pem" + }, + }, + { + name: "client certificate path", + mutate: func(config *connection.ConnectionConfig) { + config.SSLCertPath = "C:/certs/client-b.pem" + }, + }, + { + name: "client private key path", + mutate: func(config *connection.ConnectionConfig) { + config.SSLKeyPath = "C:/certs/client-b.key" + }, + }, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + changed := base + testCase.mutate(&changed) + if changedKey := getNacosClientCacheKey(changed); changedKey == baseKey { + t.Fatalf("cache key did not change when %s changed", testCase.name) + } + }) + } + + tunnelBase := base + tunnelBase.UseProxy = false + tunnelBase.Proxy = connection.ProxyConfig{} + tunnelBase.UseHTTPTunnel = true + tunnelBase.HTTPTunnel = connection.HTTPTunnelConfig{ + Host: "tunnel.example.com", + Port: 3128, + User: "tunnel-user", + Password: "tunnel-secret:not-hex", + } + tunnelBaseKey := getNacosClientCacheKey(tunnelBase) + if strings.Contains(tunnelBaseKey, tunnelBase.HTTPTunnel.Password) { + t.Fatalf("cache key exposed plaintext HTTP tunnel secret %q", tunnelBase.HTTPTunnel.Password) + } + tunnelTests := []struct { + name string + mutate func(*connection.ConnectionConfig) + }{ + { + name: "HTTP tunnel host", + mutate: func(config *connection.ConnectionConfig) { + config.HTTPTunnel.Host = "other-tunnel.example.com" + }, + }, + { + name: "HTTP tunnel port", + mutate: func(config *connection.ConnectionConfig) { + config.HTTPTunnel.Port = 8080 + }, + }, + { + name: "HTTP tunnel user", + mutate: func(config *connection.ConnectionConfig) { + config.HTTPTunnel.User = "other-tunnel-user" + }, + }, + { + name: "HTTP tunnel password", + mutate: func(config *connection.ConnectionConfig) { + config.HTTPTunnel.Password = "other-tunnel-secret" + }, + }, + } + for _, testCase := range tunnelTests { + t.Run(testCase.name, func(t *testing.T) { + changed := tunnelBase + testCase.mutate(&changed) + if changedKey := getNacosClientCacheKey(changed); changedKey == tunnelBaseKey { + t.Fatalf("cache key did not change when %s changed", testCase.name) + } + }) + } + + sshBase := base + sshBase.UseProxy = false + sshBase.Proxy = connection.ProxyConfig{} + sshBase.UseSSH = true + sshBase.SSH = connection.SSHConfig{ + Host: "ssh.example.com", + Port: 22, + User: "ssh-user", + Password: "ssh-secret:not-hex", + KeyPath: "C:/keys/nacos-a.pem", + } + sshBaseKey := getNacosClientCacheKey(sshBase) + if strings.Contains(sshBaseKey, sshBase.SSH.Password) { + t.Fatalf("cache key exposed plaintext SSH secret %q", sshBase.SSH.Password) + } + sshTests := []struct { + name string + mutate func(*connection.ConnectionConfig) + }{ + { + name: "SSH enabled", + mutate: func(config *connection.ConnectionConfig) { + config.UseSSH = false + }, + }, + { + name: "SSH host", + mutate: func(config *connection.ConnectionConfig) { + config.SSH.Host = "other-ssh.example.com" + }, + }, + { + name: "SSH port", + mutate: func(config *connection.ConnectionConfig) { + config.SSH.Port = 2222 + }, + }, + { + name: "SSH user", + mutate: func(config *connection.ConnectionConfig) { + config.SSH.User = "other-ssh-user" + }, + }, + { + name: "SSH password", + mutate: func(config *connection.ConnectionConfig) { + config.SSH.Password = "other-ssh-secret" + }, + }, + { + name: "SSH key path", + mutate: func(config *connection.ConnectionConfig) { + config.SSH.KeyPath = "C:/keys/nacos-b.pem" + }, + }, + } + for _, testCase := range sshTests { + t.Run(testCase.name, func(t *testing.T) { + changed := sshBase + testCase.mutate(&changed) + if changedKey := getNacosClientCacheKey(changed); changedKey == sshBaseKey { + t.Fatalf("cache key did not change when %s changed", testCase.name) + } + }) + } +} + +func TestGetNacosClientCacheHitDoesNotPingOrBlockOtherCacheKeys(t *testing.T) { + installNacosCacheTestHooks(t) + + var pingCalls atomic.Int32 + releaseSlowPing := make(chan struct{}) + slowClient := &nacosCacheTestClient{ + ping: func(context.Context) error { + pingCalls.Add(1) + <-releaseSlowPing + return nil + }, + } + fastClient := &nacosCacheTestClient{} + var factoryCalls atomic.Int32 + newNacosClientFunc = func() nacos.Client { + factoryCalls.Add(1) + return &nacosCacheTestClient{} + } + slowConfig := connection.ConnectionConfig{Type: "nacos", Host: "slow.nacos.local", Port: 8848} + fastConfig := connection.ConnectionConfig{Type: "nacos", Host: "fast.nacos.local", Port: 8848} + + nacosCacheMu.Lock() + nacosCache[getNacosClientCacheKey(slowConfig)] = slowClient + nacosCache[getNacosClientCacheKey(fastConfig)] = fastClient + nacosCacheMu.Unlock() + + app := &App{} + slowDone := make(chan struct { + client nacos.Client + err error + }, 1) + go func() { + client, err := app.getNacosClient(slowConfig) + slowDone <- struct { + client nacos.Client + err error + }{client: client, err: err} + }() + var slowResult struct { + client nacos.Client + err error + } + select { + case slowResult = <-slowDone: + case <-time.After(time.Second): + close(releaseSlowPing) + <-slowDone + t.Fatal("cached Nacos lookup invoked Ping and blocked") + } + + fastResult, fastErr := app.getNacosClient(fastConfig) + close(releaseSlowPing) + if slowResult.err != nil { + t.Fatalf("slow cache lookup failed: %v", slowResult.err) + } + if slowResult.client != slowClient { + t.Fatal("slow cache lookup did not return its cached client") + } + if fastErr != nil { + t.Fatalf("fast cache lookup failed: %v", fastErr) + } + if fastResult != fastClient { + t.Fatal("fast cache lookup did not return its cached client") + } + if got := pingCalls.Load(); got != 0 { + t.Fatalf("cached Nacos lookup invoked Ping %d time(s), want 0", got) + } + if got := factoryCalls.Load(); got != 0 { + t.Fatalf("cached Nacos lookup created %d client(s), want 0", got) + } +} + +func TestGetNacosClientConnectsDifferentCacheKeysConcurrently(t *testing.T) { + installNacosCacheTestHooks(t) + + connectStarted := make(chan string, 2) + releaseConnect := make(chan struct{}) + newNacosClientFunc = func() nacos.Client { + return &nacosCacheTestClient{ + connect: func(config connection.ConnectionConfig) error { + connectStarted <- config.Host + <-releaseConnect + return nil + }, + } + } + + app := &App{} + configs := []connection.ConnectionConfig{ + {Type: "nacos", Host: "one.nacos.local", Port: 8848}, + {Type: "nacos", Host: "two.nacos.local", Port: 8848}, + } + results := make(chan error, len(configs)) + for _, config := range configs { + config := config + go func() { + _, err := app.getNacosClient(config) + results <- err + }() + } + + seen := make(map[string]struct{}, len(configs)) + select { + case host := <-connectStarted: + seen[host] = struct{}{} + case <-time.After(time.Second): + t.Fatal("timed out waiting for first Nacos Connect") + } + secondStartedBeforeRelease := true + select { + case host := <-connectStarted: + seen[host] = struct{}{} + case <-time.After(300 * time.Millisecond): + secondStartedBeforeRelease = false + } + close(releaseConnect) + for range configs { + if err := <-results; err != nil { + t.Fatalf("getNacosClient failed: %v", err) + } + } + if !secondStartedBeforeRelease { + t.Fatal("Connect for one cache key blocked Connect for another cache key") + } + if len(seen) != len(configs) { + t.Fatalf("connected hosts = %v, want both cache keys", seen) + } +} + +func TestGetNacosClientCoalescesConcurrentColdConnects(t *testing.T) { + installNacosCacheTestHooks(t) + + const callers = 16 + var factoryCalls atomic.Int32 + connectStarted := make(chan struct{}) + releaseConnect := make(chan struct{}) + var connectStartedOnce sync.Once + sharedClient := &nacosCacheTestClient{ + connect: func(connection.ConnectionConfig) error { + connectStartedOnce.Do(func() { close(connectStarted) }) + <-releaseConnect + return nil + }, + } + newNacosClientFunc = func() nacos.Client { + factoryCalls.Add(1) + return sharedClient + } + + app := &App{} + config := connection.ConnectionConfig{Type: "nacos", Host: "shared.nacos.local", Port: 8848} + start := make(chan struct{}) + results := make(chan nacos.Client, callers) + errors := make(chan error, callers) + var workers sync.WaitGroup + workers.Add(callers) + for range callers { + go func() { + defer workers.Done() + <-start + client, err := app.getNacosClient(config) + results <- client + errors <- err + }() + } + close(start) + select { + case <-connectStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for shared Nacos Connect") + } + time.Sleep(100 * time.Millisecond) + close(releaseConnect) + workers.Wait() + close(results) + close(errors) + + for err := range errors { + if err != nil { + t.Fatalf("getNacosClient failed: %v", err) + } + } + for client := range results { + if client != sharedClient { + t.Fatal("concurrent caller did not receive the shared cached client") + } + } + if got := factoryCalls.Load(); got != 1 { + t.Fatalf("Nacos client factory calls = %d, want 1", got) + } + if got := sharedClient.closed.Load(); got != 0 { + t.Fatalf("shared cached client was closed %d times", got) + } +} + +func TestGetNacosClientWithContextCancelsSingleflightWaiter(t *testing.T) { + installNacosCacheTestHooks(t) + + connectStarted := make(chan struct{}) + releaseConnect := make(chan struct{}) + var connectStartedOnce sync.Once + newNacosClientFunc = func() nacos.Client { + return &nacosCacheTestClient{ + connect: func(connection.ConnectionConfig) error { + connectStartedOnce.Do(func() { close(connectStarted) }) + <-releaseConnect + return nil + }, + } + } + + app := &App{} + config := connection.ConnectionConfig{ + Type: "nacos", + Host: "cancel-waiter.nacos.local", + Port: 8848, + Timeout: 30, + } + leaderDone := make(chan error, 1) + go func() { + _, err := app.getNacosClientWithContext(context.Background(), config) + leaderDone <- err + }() + select { + case <-connectStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for shared Nacos Connect") + } + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + startedAt := time.Now() + _, err := app.getNacosClientWithContext(ctx, config) + elapsed := time.Since(startedAt) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("waiting caller error = %v, want context deadline exceeded", err) + } + if elapsed > 500*time.Millisecond { + t.Fatalf("waiting caller returned after %v, want prompt context cancellation", elapsed) + } + + close(releaseConnect) + if err := <-leaderDone; err != nil { + t.Fatalf("leader getNacosClientWithContext failed: %v", err) + } +} + +func TestGetNacosClientWithContextCanceledLeaderDoesNotPoisonWaiter(t *testing.T) { + installNacosCacheTestHooks(t) + + connectStarted := make(chan struct{}) + releaseConnect := make(chan struct{}) + var connectStartedOnce sync.Once + sharedClient := &nacosCacheTestClient{ + connect: func(connection.ConnectionConfig) error { + connectStartedOnce.Do(func() { close(connectStarted) }) + <-releaseConnect + return nil + }, + } + newNacosClientFunc = func() nacos.Client { + return sharedClient + } + + app := &App{} + config := connection.ConnectionConfig{ + Type: "nacos", + Host: "cancel-leader.nacos.local", + Port: 8848, + Timeout: 30, + } + leaderCtx, cancelLeader := context.WithCancel(context.Background()) + leaderDone := make(chan error, 1) + go func() { + _, err := app.getNacosClientWithContext(leaderCtx, config) + leaderDone <- err + }() + select { + case <-connectStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for leader Nacos Connect") + } + + waiterDone := make(chan struct { + client nacos.Client + err error + }, 1) + go func() { + client, err := app.getNacosClientWithContext(context.Background(), config) + waiterDone <- struct { + client nacos.Client + err error + }{client: client, err: err} + }() + time.Sleep(50 * time.Millisecond) + cancelLeader() + if err := <-leaderDone; !errors.Is(err, context.Canceled) { + t.Fatalf("leader error = %v, want context canceled", err) + } + + close(releaseConnect) + waiter := <-waiterDone + if waiter.err != nil { + t.Fatalf("live waiter inherited canceled leader error: %v", waiter.err) + } + if waiter.client != sharedClient { + t.Fatal("live waiter did not receive the shared client") + } +} + +func TestGetNacosClientWithContextSeparatesFlightsByTimeout(t *testing.T) { + installNacosCacheTestHooks(t) + + shortConnectStarted := make(chan struct{}) + releaseShortConnect := make(chan struct{}) + longConnectStarted := make(chan struct{}) + var shortStartedOnce sync.Once + var longStartedOnce sync.Once + var factoryCalls atomic.Int32 + newNacosClientFunc = func() nacos.Client { + factoryCalls.Add(1) + return &nacosCacheTestClient{ + connect: func(config connection.ConnectionConfig) error { + switch config.Timeout { + case 1: + shortStartedOnce.Do(func() { close(shortConnectStarted) }) + <-releaseShortConnect + return errors.New("short connection timeout") + case 30: + longStartedOnce.Do(func() { close(longConnectStarted) }) + return nil + default: + return errors.New("unexpected connection timeout") + } + }, + } + } + + app := &App{} + baseConfig := connection.ConnectionConfig{ + Type: "nacos", + Host: "timeout-flight.nacos.local", + Port: 8848, + } + shortConfig := baseConfig + shortConfig.Timeout = 1 + longConfig := baseConfig + longConfig.Timeout = 30 + + shortDone := make(chan error, 1) + go func() { + _, err := app.getNacosClientWithContext(context.Background(), shortConfig) + shortDone <- err + }() + select { + case <-shortConnectStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for short-timeout Nacos Connect") + } + + longDone := make(chan struct { + client nacos.Client + err error + }, 1) + go func() { + client, err := app.getNacosClientWithContext(context.Background(), longConfig) + longDone <- struct { + client nacos.Client + err error + }{client: client, err: err} + }() + longStartedBeforeShortRelease := true + select { + case <-longConnectStarted: + case <-time.After(300 * time.Millisecond): + longStartedBeforeShortRelease = false + } + + close(releaseShortConnect) + shortErr := <-shortDone + longResult := <-longDone + if !longStartedBeforeShortRelease { + t.Fatal("long-timeout caller joined the short-timeout connection flight") + } + if shortErr == nil { + t.Fatal("short-timeout Connect unexpectedly succeeded") + } + if longResult.err != nil { + t.Fatalf("long-timeout Connect inherited short-timeout error: %v", longResult.err) + } + if longResult.client == nil { + t.Fatal("long-timeout Connect returned nil client") + } + if got := factoryCalls.Load(); got != 2 { + t.Fatalf("Nacos client factory calls = %d, want 2 timeout-specific flights", got) + } +} + +func TestGetNacosClientDifferentTimeoutCacheHitsDoNotPingOrCloseSharedClient(t *testing.T) { + installNacosCacheTestHooks(t) + + var pingCalls atomic.Int32 + cachedClient := &nacosCacheTestClient{ + ping: func(context.Context) error { + pingCalls.Add(1) + return errors.New("cached client must not be health-probed") + }, + } + var factoryCalls atomic.Int32 + newNacosClientFunc = func() nacos.Client { + factoryCalls.Add(1) + return &nacosCacheTestClient{} + } + + baseConfig := connection.ConnectionConfig{ + Type: "nacos", + Host: "timeout-cache-hit.nacos.local", + Port: 8848, + } + nacosCacheMu.Lock() + nacosCache[getNacosClientCacheKey(baseConfig)] = cachedClient + nacosCacheMu.Unlock() + + configs := []connection.ConnectionConfig{baseConfig, baseConfig} + configs[0].Timeout = 1 + configs[1].Timeout = 30 + start := make(chan struct{}) + results := make(chan struct { + client nacos.Client + err error + }, len(configs)) + for _, config := range configs { + config := config + go func() { + <-start + client, err := (&App{}).getNacosClientWithContext(context.Background(), config) + results <- struct { + client nacos.Client + err error + }{client: client, err: err} + }() + } + close(start) + + for range configs { + result := <-results + if result.err != nil { + t.Fatalf("cached Nacos lookup failed: %v", result.err) + } + if result.client != cachedClient { + t.Fatal("timeout-specific cache hit did not return the shared client") + } + } + if got := pingCalls.Load(); got != 0 { + t.Fatalf("timeout-specific cache hits invoked Ping %d time(s), want 0", got) + } + if got := cachedClient.closed.Load(); got != 0 { + t.Fatalf("timeout-specific cache hits closed shared client %d time(s)", got) + } + if got := factoryCalls.Load(); got != 0 { + t.Fatalf("timeout-specific cache hits created %d client(s), want 0", got) + } +} + +func TestNacosOperationTimeoutIncludesClientAcquisition(t *testing.T) { + installNacosCacheTestHooks(t) + + operationStartedAt := time.Now() + var operationDeadline time.Time + testClient := &nacosCacheTestClient{ + connect: func(connection.ConnectionConfig) error { + time.Sleep(400 * time.Millisecond) + return nil + }, + listNamespaces: func(ctx context.Context) ([]nacos.Namespace, error) { + var ok bool + operationDeadline, ok = ctx.Deadline() + if !ok { + return nil, errors.New("Nacos operation context has no deadline") + } + return []nacos.Namespace{}, nil + }, + } + newNacosClientFunc = func() nacos.Client { + return testClient + } + + result := (&App{}).NacosListNamespaces(connection.ConnectionConfig{ + Type: "nacos", + Host: "shared-deadline.nacos.local", + Port: 8848, + Timeout: 1, + }) + if !result.Success { + t.Fatalf("NacosListNamespaces failed: %s", result.Message) + } + if totalBudget := operationDeadline.Sub(operationStartedAt); totalBudget > 1250*time.Millisecond { + t.Fatalf("operation deadline budget = %v, want connection and operation to share one timeout", totalBudget) + } +} + +func TestCloseAllNacosClientsCanceledListenerCannotRepopulateFreshCache(t *testing.T) { + installNacosCacheTestHooks(t) + + proxyResolutionStarted := make(chan struct{}) + releaseProxyResolution := make(chan struct{}) + var proxyResolutionStartedOnce sync.Once + resolveDialConfigWithProxyFunc = func(config connection.ConnectionConfig) (connection.ConnectionConfig, error) { + proxyResolutionStartedOnce.Do(func() { close(proxyResolutionStarted) }) + <-releaseProxyResolution + return config, nil + } + var factoryCalls atomic.Int32 + newNacosClientFunc = func() nacos.Client { + factoryCalls.Add(1) + return &nacosCacheTestClient{} + } + + ctx, cancel := context.WithCancel(context.Background()) + const watchID = "close-all-canceled-listener" + nacosListenMu.Lock() + nacosListenSessions[watchID] = &nacosListenSession{ + watchID: watchID, + cancel: cancel, + } + nacosListenMu.Unlock() + + app := &App{} + config := connection.ConnectionConfig{ + Type: "nacos", + Host: "listener-race.nacos.local", + Port: 8848, + } + connectDone := make(chan error, 1) + go func() { + _, err := app.getNacosClientWithContext(ctx, config) + connectDone <- err + }() + select { + case <-proxyResolutionStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for listener connection preparation") + } + + CloseAllNacosClients() + close(releaseProxyResolution) + if err := <-connectDone; !errors.Is(err, context.Canceled) { + t.Fatalf("canceled listener connection error = %v, want context canceled", err) + } + if got := factoryCalls.Load(); got != 0 { + t.Fatalf("Nacos client factory called %d time(s) after listener cancellation", got) + } + nacosCacheMu.Lock() + cachedClients := len(nacosCache) + nacosCacheMu.Unlock() + if cachedClients != 0 { + t.Fatalf("Nacos cache contains %d client(s) after CloseAll", cachedClients) + } +} + +func TestNacosCacheLogsDoNotExposeCredentialDerivedFingerprint(t *testing.T) { + installNacosCacheTestHooks(t) + + config := connection.ConnectionConfig{ + Type: "nacos", + Host: "log-fingerprint.nacos.local", + Port: 8848, + User: "nacos", + Password: "unique-cache-log-secret", + } + cacheKey := getNacosClientCacheKey(config) + fingerprint := cacheKey[:12] + newNacosClientFunc = func() nacos.Client { + return &nacosCacheTestClient{} + } + + logPath := logger.Path() + before, err := os.Stat(logPath) + if err != nil { + t.Fatalf("stat Nacos log: %v", err) + } + + if _, err := (&App{}).getNacosClient(config); err != nil { + t.Fatalf("getNacosClient: %v", err) + } + CloseAllNacosClients() + + logContents, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read Nacos log: %v", err) + } + appended := logContents[before.Size():] + if strings.Contains(string(appended), fingerprint) { + t.Fatalf("Nacos cache log exposed credential-derived fingerprint %q", fingerprint) + } +} + +func TestGetNacosClientClosesUnpublishedClientWhenCachePublisherWins(t *testing.T) { + installNacosCacheTestHooks(t) + + connectStarted := make(chan struct{}) + releaseConnect := make(chan struct{}) + var connectStartedOnce sync.Once + connectingClient := &nacosCacheTestClient{ + connect: func(connection.ConnectionConfig) error { + connectStartedOnce.Do(func() { close(connectStarted) }) + <-releaseConnect + return nil + }, + } + cachedWinner := &nacosCacheTestClient{} + newNacosClientFunc = func() nacos.Client { + return connectingClient + } + + app := &App{} + config := connection.ConnectionConfig{Type: "nacos", Host: "winner.nacos.local", Port: 8848} + result := make(chan struct { + client nacos.Client + err error + }, 1) + go func() { + client, err := app.getNacosClient(config) + result <- struct { + client nacos.Client + err error + }{client: client, err: err} + }() + select { + case <-connectStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for Nacos Connect") + } + + key := getNacosClientCacheKey(config) + nacosCacheMu.Lock() + nacosCache[key] = cachedWinner + nacosCacheMu.Unlock() + close(releaseConnect) + + got := <-result + if got.err != nil { + t.Fatalf("getNacosClient failed: %v", got.err) + } + if got.client != cachedWinner { + t.Fatal("getNacosClient did not preserve the existing cache winner") + } + if closed := connectingClient.closed.Load(); closed != 1 { + t.Fatalf("unpublished client close count = %d, want 1", closed) + } +} + +func TestCloseAllNacosClientsPreventsInflightConnectLateCacheWrite(t *testing.T) { + installNacosCacheTestHooks(t) + + connectStarted := make(chan struct{}) + releaseConnect := make(chan struct{}) + var connectStartedOnce sync.Once + connectingClient := &nacosCacheTestClient{ + connect: func(connection.ConnectionConfig) error { + connectStartedOnce.Do(func() { close(connectStarted) }) + <-releaseConnect + return nil + }, + } + newNacosClientFunc = func() nacos.Client { + return connectingClient + } + + app := &App{} + config := connection.ConnectionConfig{Type: "nacos", Host: "closing.nacos.local", Port: 8848} + connectDone := make(chan error, 1) + go func() { + _, err := app.getNacosClient(config) + connectDone <- err + }() + select { + case <-connectStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for in-flight Nacos Connect") + } + + CloseAllNacosClients() + close(releaseConnect) + err := <-connectDone + if err == nil { + t.Fatal("in-flight Connect unexpectedly succeeded after CloseAll") + } + if got := connectingClient.closed.Load(); got != 1 { + t.Fatalf("in-flight client close count = %d, want 1", got) + } + nacosCacheMu.Lock() + cachedClients := len(nacosCache) + nacosCacheMu.Unlock() + if cachedClients != 0 { + t.Fatalf("Nacos cache contains %d client(s) after CloseAll returned", cachedClients) + } +} + +func TestCloseAllNacosClientsLetsFreshGenerationBypassOldFlight(t *testing.T) { + installNacosCacheTestHooks(t) + + oldConnectStarted := make(chan struct{}) + releaseOldConnect := make(chan struct{}) + var oldConnectStartedOnce sync.Once + oldClient := &nacosCacheTestClient{ + connect: func(connection.ConnectionConfig) error { + oldConnectStartedOnce.Do(func() { close(oldConnectStarted) }) + <-releaseOldConnect + return nil + }, + } + freshConnectStarted := make(chan struct{}) + var freshConnectStartedOnce sync.Once + freshClient := &nacosCacheTestClient{ + connect: func(connection.ConnectionConfig) error { + freshConnectStartedOnce.Do(func() { close(freshConnectStarted) }) + return nil + }, + } + var factoryCalls atomic.Int32 + newNacosClientFunc = func() nacos.Client { + switch factoryCalls.Add(1) { + case 1: + return oldClient + case 2: + return freshClient + default: + return &nacosCacheTestClient{} + } + } + + app := &App{} + config := connection.ConnectionConfig{Type: "nacos", Host: "generation.nacos.local", Port: 8848} + oldDone := make(chan error, 1) + go func() { + _, err := app.getNacosClient(config) + oldDone <- err + }() + select { + case <-oldConnectStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for old-generation Nacos Connect") + } + + CloseAllNacosClients() + freshDone := make(chan struct { + client nacos.Client + err error + }, 1) + go func() { + client, err := app.getNacosClient(config) + freshDone <- struct { + client nacos.Client + err error + }{client: client, err: err} + }() + + freshStartedBeforeOldRelease := true + select { + case <-freshConnectStarted: + case <-time.After(300 * time.Millisecond): + freshStartedBeforeOldRelease = false + } + close(releaseOldConnect) + oldErr := <-oldDone + freshResult := <-freshDone + + if !freshStartedBeforeOldRelease { + t.Fatal("fresh-generation Connect joined the invalidated old-generation flight") + } + if oldErr == nil { + t.Fatal("old-generation Connect unexpectedly succeeded after CloseAll") + } + if freshResult.err != nil { + t.Fatalf("fresh-generation Connect failed: %v", freshResult.err) + } + if freshResult.client != freshClient { + t.Fatal("fresh-generation lookup did not return the fresh client") + } + if got := factoryCalls.Load(); got != 2 { + t.Fatalf("Nacos client factory calls = %d, want 2 generations", got) + } + if got := oldClient.closed.Load(); got != 1 { + t.Fatalf("old-generation client close count = %d, want 1", got) + } + if got := freshClient.closed.Load(); got != 0 { + t.Fatalf("fresh-generation client was closed %d times", got) + } +} diff --git a/internal/app/methods_nacos_listen.go b/internal/app/methods_nacos_listen.go index 2ef87564..d55dec08 100644 --- a/internal/app/methods_nacos_listen.go +++ b/internal/app/methods_nacos_listen.go @@ -52,8 +52,12 @@ type nacosListenSession struct { } var ( - nacosListenMu sync.Mutex - nacosListenSessions = make(map[string]*nacosListenSession) + nacosListenMu sync.Mutex + nacosListenSessions = make(map[string]*nacosListenSession) + nacosListenGeneration uint64 + nacosListenGenerationCtx, nacosListenGenerationCancel = context.WithCancel(context.Background()) + nacosListenClosingCount uint64 + nacosListenStartAfterConnectHook func() ) // NacosStartConfigListen starts background long-poll for a config. @@ -77,10 +81,29 @@ func (a *App) NacosStartConfigListen(config connection.ConnectionConfig, payload contentMD5 = "" } + nacosListenMu.Lock() + if nacosListenClosingCount > 0 { + nacosListenMu.Unlock() + return connection.QueryResult{Success: false, Message: errNacosCacheInvalidated.Error()} + } + startGeneration := nacosListenGeneration + startGenerationCtx := nacosListenGenerationCtx + afterConnectHook := nacosListenStartAfterConnectHook + nacosListenMu.Unlock() + // Ensure client can connect before starting loop. - if _, err := a.getNacosClient(config); err != nil { + connectCtx, cancelConnect := context.WithTimeout( + startGenerationCtx, + time.Duration(nacosOperationTimeoutSeconds(config))*time.Second, + ) + _, err := a.getNacosClientWithContext(connectCtx, config) + cancelConnect() + if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } + if afterConnectHook != nil { + afterConnectHook() + } watchID := strings.TrimSpace(payload.WatchID) if watchID == "" { @@ -88,6 +111,10 @@ func (a *App) NacosStartConfigListen(config connection.ConnectionConfig, payload } nacosListenMu.Lock() + if nacosListenClosingCount > 0 || nacosListenGeneration != startGeneration { + nacosListenMu.Unlock() + return connection.QueryResult{Success: false, Message: errNacosCacheInvalidated.Error()} + } if existing, ok := nacosListenSessions[watchID]; ok && existing != nil { existing.cancel() delete(nacosListenSessions, watchID) @@ -172,7 +199,7 @@ func (a *App) runNacosConfigListenLoop(config connection.ConnectionConfig, sessi if ctx.Err() != nil { return } - client, err := a.getNacosClient(config) + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { logger.Warnf("Nacos 监听获取连接失败,稍后重试:watchId=%s err=%v", session.watchID, err) if !sleepWithContext(ctx, nacosListenRestartBackoff) { @@ -205,7 +232,8 @@ func (a *App) runNacosConfigListenLoop(config connection.ConnectionConfig, sessi continue } - // Emit change event for matching target. + // This watch is one-shot: after the matching target changes, the + // frontend reloads the config and starts a fresh watch with its new MD5. for _, item := range changed { if !nacosListenTargetMatch(session, item) { continue @@ -218,9 +246,6 @@ func (a *App) runNacosConfigListenLoop(config connection.ConnectionConfig, sessi Group: session.group, ChangedAt: time.Now().UnixMilli(), }) - } - // Small pause to avoid tight loop if client keeps reporting change before MD5 update. - if !sleepWithContext(ctx, 300*time.Millisecond) { return } } @@ -280,9 +305,12 @@ func newNacosWatchID() string { return hex.EncodeToString(buf[:]) + hex.EncodeToString([]byte(time.Now().Format("150405"))) } -// CloseAllNacosListeners stops all config listeners. -func CloseAllNacosListeners() { +func beginNacosListenerClose() []*nacosListenSession { nacosListenMu.Lock() + previousGenerationCancel := nacosListenGenerationCancel + nacosListenGeneration++ + nacosListenGenerationCtx, nacosListenGenerationCancel = context.WithCancel(context.Background()) + nacosListenClosingCount++ sessions := make([]*nacosListenSession, 0, len(nacosListenSessions)) for _, session := range nacosListenSessions { sessions = append(sessions, session) @@ -290,6 +318,21 @@ func CloseAllNacosListeners() { nacosListenSessions = make(map[string]*nacosListenSession) nacosListenMu.Unlock() + if previousGenerationCancel != nil { + previousGenerationCancel() + } + return sessions +} + +func finishNacosListenerClose() { + nacosListenMu.Lock() + if nacosListenClosingCount > 0 { + nacosListenClosingCount-- + } + nacosListenMu.Unlock() +} + +func cancelNacosListenSessions(sessions []*nacosListenSession) { for _, session := range sessions { if session != nil && session.cancel != nil { session.cancel() @@ -297,20 +340,38 @@ func CloseAllNacosListeners() { } } +// CloseAllNacosListeners stops all config listeners. +func CloseAllNacosListeners() { + sessions := beginNacosListenerClose() + defer finishNacosListenerClose() + cancelNacosListenSessions(sessions) +} + // CloseAllNacosClients closes cached nacos clients and listeners. func CloseAllNacosClients() { - CloseAllNacosListeners() + sessions := beginNacosListenerClose() + defer finishNacosListenerClose() + cancelNacosListenSessions(sessions) nacosCacheMu.Lock() - defer nacosCacheMu.Unlock() - for key, client := range nacosCache { + previousGenerationCancel := nacosCacheGenerationCancel + nacosCacheGeneration++ + nacosCacheGenerationCtx, nacosCacheGenerationCancel = context.WithCancel(context.Background()) + clients := nacosCache + nacosCache = make(map[string]nacos.Client) + nacosCacheMu.Unlock() + if previousGenerationCancel != nil { + previousGenerationCancel() + } + + closedClients := 0 + for _, client := range clients { if client != nil { _ = client.Close() - if len(key) >= 12 { - logger.Infof("已关闭 Nacos 连接:%s", key[:12]) - } + closedClients++ } } - nacosCache = make(map[string]nacos.Client) - nacosCacheConfigs = make(map[string]connection.ConnectionConfig) + if closedClients > 0 { + logger.Infof("已关闭 %d 个 Nacos 连接", closedClients) + } } diff --git a/internal/app/methods_nacos_listen_race_test.go b/internal/app/methods_nacos_listen_race_test.go new file mode 100644 index 00000000..df4b25bf --- /dev/null +++ b/internal/app/methods_nacos_listen_race_test.go @@ -0,0 +1,367 @@ +package app + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/nacos" + "GoNavi-Wails/internal/uievents" +) + +type nacosListenerBarrierTestClient struct { + nacos.Client + listenStarted chan struct{} + listenOnce sync.Once + closed atomic.Int32 +} + +func (client *nacosListenerBarrierTestClient) Connect(connection.ConnectionConfig) error { + return nil +} + +func (client *nacosListenerBarrierTestClient) ListenOnce( + ctx context.Context, + _ []nacos.ConfigListenTarget, + _ int, +) ([]nacos.ConfigListenTarget, error) { + client.listenOnce.Do(func() { + close(client.listenStarted) + }) + <-ctx.Done() + return nil, ctx.Err() +} + +func (client *nacosListenerBarrierTestClient) Close() error { + client.closed.Add(1) + return nil +} + +type nacosBlockingCloseTestClient struct { + nacos.Client + closeStarted chan struct{} + releaseClose chan struct{} + closeOnce sync.Once +} + +type nacosRepeatingChangedTestClient struct { + nacos.Client + firstListen chan struct{} + secondListen chan struct{} + firstOnce sync.Once + secondOnce sync.Once + listenCalls atomic.Int32 +} + +type nacosNoopListenEventEmitter struct{} + +func (nacosNoopListenEventEmitter) Emit(string, ...any) {} + +func (client *nacosRepeatingChangedTestClient) Connect(connection.ConnectionConfig) error { + return nil +} + +func (client *nacosRepeatingChangedTestClient) ListenOnce( + _ context.Context, + targets []nacos.ConfigListenTarget, + _ int, +) ([]nacos.ConfigListenTarget, error) { + call := client.listenCalls.Add(1) + if call == 1 { + client.firstOnce.Do(func() { + close(client.firstListen) + }) + } else { + client.secondOnce.Do(func() { + close(client.secondListen) + }) + } + return targets, nil +} + +func (client *nacosRepeatingChangedTestClient) Close() error { + return nil +} + +func (client *nacosBlockingCloseTestClient) Connect(connection.ConnectionConfig) error { + return nil +} + +func (client *nacosBlockingCloseTestClient) Close() error { + client.closeOnce.Do(func() { + close(client.closeStarted) + }) + <-client.releaseClose + return nil +} + +func TestNacosConfigListenStopsAfterMatchingChange(t *testing.T) { + installNacosCacheTestHooks(t) + CloseAllNacosListeners() + defer CloseAllNacosClients() + + client := &nacosRepeatingChangedTestClient{ + firstListen: make(chan struct{}), + secondListen: make(chan struct{}), + } + newNacosClientFunc = func() nacos.Client { + return client + } + + const watchID = "listener-one-shot" + app := &App{ + ctx: uievents.WithEmitter(context.Background(), nacosNoopListenEventEmitter{}), + } + result := app.NacosStartConfigListen(connection.ConnectionConfig{ + Type: "nacos", + Host: "listener-one-shot.nacos.local", + Port: 8848, + Timeout: 1, + }, NacosStartConfigListenPayload{ + WatchID: watchID, + NamespaceID: "dev", + DataID: "application.yaml", + Group: "DEFAULT_GROUP", + ContentMD5: "original-md5", + }) + if !result.Success { + t.Fatalf("start Nacos listener: %s", result.Message) + } + + select { + case <-client.firstListen: + case <-time.After(time.Second): + t.Fatal("timed out waiting for first Nacos listen request") + } + + select { + case <-client.secondListen: + t.Fatal("listener polled again after emitting a matching change") + case <-time.After(500 * time.Millisecond): + } + + deadline := time.Now().Add(time.Second) + for { + nacosListenMu.Lock() + _, exists := nacosListenSessions[watchID] + nacosListenMu.Unlock() + if !exists { + break + } + if time.Now().After(deadline) { + t.Fatal("one-shot listener session was not removed after a matching change") + } + time.Sleep(10 * time.Millisecond) + } + if got := client.listenCalls.Load(); got != 1 { + t.Fatalf("Nacos listen calls = %d, want 1", got) + } +} + +func TestNacosStartConfigListenCannotRegisterAfterCloseAllReturns(t *testing.T) { + installNacosCacheTestHooks(t) + CloseAllNacosListeners() + defer CloseAllNacosClients() + + startConnected := make(chan struct{}) + releaseRegistration := make(chan struct{}) + var startConnectedOnce sync.Once + var releaseRegistrationOnce sync.Once + releaseStartRegistration := func() { + releaseRegistrationOnce.Do(func() { + close(releaseRegistration) + }) + } + defer releaseStartRegistration() + originalAfterConnectHook := nacosListenStartAfterConnectHook + nacosListenStartAfterConnectHook = func() { + startConnectedOnce.Do(func() { + close(startConnected) + }) + <-releaseRegistration + } + t.Cleanup(func() { + nacosListenStartAfterConnectHook = originalAfterConnectHook + }) + + listenStarted := make(chan struct{}) + var factoryCalls atomic.Int32 + newNacosClientFunc = func() nacos.Client { + if factoryCalls.Add(1) == 1 { + return &nacosCacheTestClient{} + } + return &nacosListenerBarrierTestClient{ + listenStarted: listenStarted, + } + } + + app := &App{} + config := connection.ConnectionConfig{ + Type: "nacos", + Host: "listener-register-race.nacos.local", + Port: 8848, + Timeout: 1, + } + if _, err := app.getNacosClient(config); err != nil { + t.Fatalf("prewarm Nacos client: %v", err) + } + + startDone := make(chan connection.QueryResult, 1) + go func() { + startDone <- app.NacosStartConfigListen(config, NacosStartConfigListenPayload{ + WatchID: "listener-register-race", + DataID: "application.yaml", + Group: "DEFAULT_GROUP", + }) + }() + + select { + case <-startConnected: + case <-time.After(time.Second): + t.Fatal("timed out waiting for listener Start to acquire its client") + } + + CloseAllNacosClients() + if got := factoryCalls.Load(); got != 1 { + t.Fatalf("client factory calls before releasing Start = %d, want 1", got) + } + + releaseStartRegistration() + var result connection.QueryResult + select { + case result = <-startDone: + case <-time.After(time.Second): + t.Fatal("listener Start did not return after CloseAll") + } + + if result.Success { + select { + case <-listenStarted: + case <-time.After(time.Second): + t.Fatal("stale listener registered but did not expose its cache rebuild") + } + } + + nacosListenMu.Lock() + sessionCount := len(nacosListenSessions) + nacosListenMu.Unlock() + nacosCacheMu.Lock() + cachedClientCount := len(nacosCache) + nacosCacheMu.Unlock() + + if result.Success { + t.Fatal("listener Start unexpectedly succeeded after CloseAll returned") + } + if sessionCount != 0 { + t.Fatalf("listener sessions after CloseAll = %d, want 0", sessionCount) + } + if got := factoryCalls.Load(); got != 1 { + t.Fatalf("client factory calls after stale Start = %d, want 1", got) + } + if cachedClientCount != 0 { + t.Fatalf("Nacos cache entries after stale Start = %d, want 0", cachedClientCount) + } +} + +func TestNacosStartConfigListenDoesNotWaitForCloseAllAndCanStartAfterItReturns(t *testing.T) { + installNacosCacheTestHooks(t) + CloseAllNacosListeners() + defer CloseAllNacosClients() + + closeStarted := make(chan struct{}) + releaseClose := make(chan struct{}) + var releaseCloseOnce sync.Once + releaseClientClose := func() { + releaseCloseOnce.Do(func() { + close(releaseClose) + }) + } + defer releaseClientClose() + listenStarted := make(chan struct{}) + var factoryCalls atomic.Int32 + newNacosClientFunc = func() nacos.Client { + if factoryCalls.Add(1) == 1 { + return &nacosBlockingCloseTestClient{ + closeStarted: closeStarted, + releaseClose: releaseClose, + } + } + return &nacosListenerBarrierTestClient{ + listenStarted: listenStarted, + } + } + + app := &App{} + config := connection.ConnectionConfig{ + Type: "nacos", + Host: "listener-close-barrier.nacos.local", + Port: 8848, + Timeout: 1, + } + if _, err := app.getNacosClient(config); err != nil { + t.Fatalf("prewarm Nacos client: %v", err) + } + + closeDone := make(chan struct{}) + go func() { + CloseAllNacosClients() + close(closeDone) + }() + select { + case <-closeStarted: + case <-time.After(time.Second): + t.Fatal("timed out waiting for CloseAll client close") + } + + blockedStartDone := make(chan connection.QueryResult, 1) + go func() { + blockedStartDone <- app.NacosStartConfigListen(config, NacosStartConfigListenPayload{ + WatchID: "listener-during-close", + DataID: "application.yaml", + Group: "DEFAULT_GROUP", + }) + }() + select { + case result := <-blockedStartDone: + if result.Success { + t.Fatal("listener Start unexpectedly succeeded while CloseAll was active") + } + case <-time.After(300 * time.Millisecond): + t.Fatal("listener Start waited for CloseAll instead of failing promptly") + } + if got := factoryCalls.Load(); got != 1 { + t.Fatalf("client factory calls while CloseAll was active = %d, want 1", got) + } + + releaseClientClose() + select { + case <-closeDone: + case <-time.After(time.Second): + t.Fatal("CloseAll did not return after client close was released") + } + + freshResult := app.NacosStartConfigListen(config, NacosStartConfigListenPayload{ + WatchID: "listener-after-close", + DataID: "application.yaml", + Group: "DEFAULT_GROUP", + }) + if !freshResult.Success { + t.Fatalf("fresh listener Start after CloseAll failed: %s", freshResult.Message) + } + stopResult := app.NacosStopConfigListen("listener-after-close") + if !stopResult.Success { + t.Fatalf("fresh listener Stop after CloseAll failed: %s", stopResult.Message) + } + nacosListenMu.Lock() + sessionCount := len(nacosListenSessions) + nacosListenMu.Unlock() + if sessionCount != 0 { + t.Fatalf("listener sessions after fresh Stop = %d, want 0", sessionCount) + } + if got := factoryCalls.Load(); got != 2 { + t.Fatalf("client factory calls after fresh Start = %d, want 2", got) + } +} diff --git a/internal/app/methods_nacos_namespace_test.go b/internal/app/methods_nacos_namespace_test.go new file mode 100644 index 00000000..52d545bb --- /dev/null +++ b/internal/app/methods_nacos_namespace_test.go @@ -0,0 +1,93 @@ +package app + +import ( + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/nacos" +) + +func TestNacosListNamespacesReturnsStableForbiddenCodeOnlyForForbidden(t *testing.T) { + tests := []struct { + name string + status int + wantErrorCode bool + }{ + {name: "forbidden", status: http.StatusForbidden, wantErrorCode: true}, + {name: "unauthorized", status: http.StatusUnauthorized}, + {name: "server error", status: http.StatusInternalServerError}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + installNacosCacheTestHooks(t) + newNacosClientFunc = nacos.NewClient + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch request.URL.Path { + case "/v3/admin/core/state/readiness": + _, _ = fmt.Fprint(w, `{"code":0,"message":"success","data":"ok"}`) + case "/v3/admin/core/namespace/list": + w.WriteHeader(test.status) + _, _ = fmt.Fprintf(w, `{"code":%d,"message":"denied"}`, test.status) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + host, portText, err := net.SplitHostPort(serverURL.Host) + if err != nil { + t.Fatalf("split server address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse server port: %v", err) + } + + result := (&App{}).NacosListNamespaces(connection.ConnectionConfig{ + Type: "nacos", + Host: host, + Port: port, + ConnectionParams: "contextPath=/", + Timeout: 2, + }) + if result.Success { + t.Fatal("NacosListNamespaces unexpectedly succeeded") + } + if !strings.Contains(result.Message, strconv.Itoa(test.status)) { + t.Fatalf("message = %q, want HTTP status %d", result.Message, test.status) + } + + data, hasData := result.Data.(map[string]any) + if test.wantErrorCode { + if !hasData { + t.Fatalf("data = %#v, want stable forbidden error code", result.Data) + } + if got := data["errorCode"]; got != nacosNamespaceListForbiddenErrorCode { + t.Fatalf("errorCode = %#v, want %q", got, nacosNamespaceListForbiddenErrorCode) + } + return + } + if hasData { + if _, exists := data["errorCode"]; exists { + t.Fatalf("non-forbidden status exposed fallback error code: %#v", data) + } + } else if result.Data != nil { + t.Fatalf("non-forbidden data = %#v, want nil", result.Data) + } + }) + } +} diff --git a/internal/app/methods_nacos_transfer.go b/internal/app/methods_nacos_transfer.go index 76a3f920..0d59bfe9 100644 --- a/internal/app/methods_nacos_transfer.go +++ b/internal/app/methods_nacos_transfer.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "time" @@ -18,6 +19,7 @@ import ( ) type nacosImportPreviewItem struct { + Index int `json:"index"` DataID string `json:"dataId"` Group string `json:"group"` Type string `json:"type,omitempty"` @@ -25,6 +27,11 @@ type nacosImportPreviewItem struct { Selected bool `json:"selected"` } +type nacosConfigIdentityKey struct { + group string + dataID string +} + type nacosImportPreview struct { File string `json:"file"` ExportedAt string `json:"exportedAt,omitempty"` @@ -39,11 +46,6 @@ type nacosImportPreview struct { // NacosExportConfigs exports configs from a namespace to a JSON file. func (a *App) NacosExportConfigs(config connection.ConnectionConfig, options NacosExportConfigsOptions) connection.QueryResult { config.Type = "nacos" - client, err := a.getNacosClient(config) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - scope := strings.ToLower(strings.TrimSpace(options.Scope)) if scope == "" { scope = "all" @@ -79,6 +81,10 @@ func (a *App) NacosExportConfigs(config connection.ConnectionConfig, options Nac ctx, cancel := a.nacosOperationContext(config) defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } entries, err := collectNacosExportEntries(ctx, client, namespaceID, scope, options) if err != nil { @@ -127,24 +133,55 @@ func (a *App) NacosPreviewImportConfigs(config connection.ConnectionConfig, name return connection.QueryResult{Success: false, Message: err.Error()} } - client, err := a.getNacosClient(config) + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() - preview := buildNacosImportPreview(ctx, client, selection, namespaceID, payload) + preview, err := buildNacosImportPreview(ctx, client, selection, namespaceID, payload) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } return connection.QueryResult{Success: true, Data: preview} } // NacosImportConfigs imports configs from a transfer file. func (a *App) NacosImportConfigs(config connection.ConnectionConfig, options NacosImportConfigsOptions) connection.QueryResult { config.Type = "nacos" - if err := a.ensureNacosDataEditAllowed(config); err != nil { + if err := a.ensureNacosDataImportAllowed(config); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } + scope := strings.ToLower(strings.TrimSpace(options.Scope)) + selectedByIndex := make(map[int]nacosConfigIdentityKey, len(options.Items)) + selectedByIdentity := make(map[nacosConfigIdentityKey]struct{}, len(options.Items)) + invalidIndexedSelection := false + for _, item := range options.Items { + key, ok := normalizeNacosConfigIdentityKey(item.Group, item.DataID) + if !ok { + continue + } + if item.Index == nil { + selectedByIdentity[key] = struct{}{} + continue + } + if *item.Index < 0 { + continue + } + if existing, exists := selectedByIndex[*item.Index]; exists && existing != key { + invalidIndexedSelection = true + } + selectedByIndex[*item.Index] = key + } + if scope == "selected" && len(selectedByIndex)+len(selectedByIdentity) == 0 { + return connection.QueryResult{ + Success: false, + Message: a.appText("nacos.backend.error.import_selection_required", nil), + } + } + selection := strings.TrimSpace(options.File) var err error if selection == "" { @@ -162,25 +199,26 @@ func (a *App) NacosImportConfigs(config connection.ConnectionConfig, options Nac return connection.QueryResult{Success: false, Message: err.Error()} } - client, err := a.getNacosClient(config) + useSelected := scope == "selected" + if useSelected && (invalidIndexedSelection || + !nacosImportSelectionMatchesPayload(payload.Configs, selectedByIndex, selectedByIdentity)) { + return connection.QueryResult{ + Success: false, + Message: a.appText("nacos.backend.error.import_selection_required", nil), + } + } + + ctx, cancel := a.nacosOperationContext(config) + defer cancel() + client, err := a.getNacosClientWithContext(ctx, config) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - ctx, cancel := a.nacosOperationContext(config) - defer cancel() conflictMode := strings.ToLower(strings.TrimSpace(options.ConflictMode)) if conflictMode != "overwrite" { conflictMode = "skip" } - selected := make(map[string]struct{}, len(options.Items)) - for _, item := range options.Items { - key := nacosConfigKey(item.Group, item.DataID) - if key != "" { - selected[key] = struct{}{} - } - } - useSelected := strings.ToLower(strings.TrimSpace(options.Scope)) == "selected" && len(selected) > 0 imported := 0 skipped := 0 @@ -188,12 +226,11 @@ func (a *App) NacosImportConfigs(config connection.ConnectionConfig, options Nac var firstErr error namespaceID := strings.TrimSpace(options.NamespaceID) - for _, item := range payload.Configs { - key := nacosConfigKey(item.Group, item.DataID) - if useSelected { - if _, ok := selected[key]; !ok { - continue - } + for index, item := range payload.Configs { + key, validIdentity := normalizeNacosConfigIdentityKey(item.Group, item.DataID) + if useSelected && (!validIdentity || + !nacosImportRowSelected(index, key, selectedByIndex, selectedByIdentity)) { + continue } exists, existsErr := nacosConfigExists(ctx, client, namespaceID, item.Group, item.DataID) if existsErr != nil { @@ -225,19 +262,33 @@ func (a *App) NacosImportConfigs(config connection.ConnectionConfig, options Nac imported++ } - if imported == 0 && failed > 0 && firstErr != nil { - return connection.QueryResult{Success: false, Message: firstErr.Error()} + resultData := map[string]any{ + "imported": imported, + "skipped": skipped, + "failed": failed, + "file": selection, } logger.Infof("Nacos 配置导入完成:imported=%d skipped=%d failed=%d file=%s", imported, skipped, failed, selection) + if failed > 0 { + detail := "" + if firstErr != nil { + detail = firstErr.Error() + } + return connection.QueryResult{ + Success: false, + Message: a.appText("nacos.backend.error.import_partial_failed", map[string]any{ + "imported": imported, + "skipped": skipped, + "failed": failed, + "detail": detail, + }), + Data: resultData, + } + } return connection.QueryResult{ Success: true, Message: a.appText("nacos.backend.message.import_success", nil), - Data: map[string]any{ - "imported": imported, - "skipped": skipped, - "failed": failed, - "file": selection, - }, + Data: resultData, } } @@ -294,7 +345,7 @@ func collectNacosExportEntries( const pageSize = 100 pageNo := 1 entries := make([]nacos.TransferConfigEntry, 0, 64) - seen := make(map[string]struct{}) + seen := make(map[nacosConfigIdentityKey]struct{}) for { page, err := client.SearchConfigs(ctx, nacos.ConfigQuery{ NamespaceID: namespaceID, @@ -309,7 +360,10 @@ func collectNacosExportEntries( break } for _, item := range page.PageItems { - key := nacosConfigKey(item.Group, item.DataID) + key, validIdentity := normalizeNacosConfigIdentityKey(item.Group, item.DataID) + if !validIdentity { + continue + } if _, ok := seen[key]; ok { continue } @@ -361,15 +415,19 @@ func buildNacosImportPreview( client nacos.Client, file, namespaceID string, payload nacos.TransferFile, -) nacosImportPreview { +) (nacosImportPreview, error) { items := make([]nacosImportPreviewItem, 0, len(payload.Configs)) existsCount := 0 - for _, cfg := range payload.Configs { - exists, _ := nacosConfigExists(ctx, client, namespaceID, cfg.Group, cfg.DataID) + for index, cfg := range payload.Configs { + exists, err := nacosConfigExists(ctx, client, namespaceID, cfg.Group, cfg.DataID) + if err != nil { + return nacosImportPreview{}, err + } if exists { existsCount++ } items = append(items, nacosImportPreviewItem{ + Index: index, DataID: cfg.DataID, Group: cfg.Group, Type: cfg.Type, @@ -386,7 +444,7 @@ func buildNacosImportPreview( ExistsCount: existsCount, NewCount: len(items) - existsCount, Items: items, - } + }, nil } func nacosConfigExists(ctx context.Context, client nacos.Client, namespaceID, group, dataID string) (bool, error) { @@ -397,28 +455,143 @@ func nacosConfigExists(ctx context.Context, client nacos.Client, namespaceID, gr if err == nil { return true, nil } - // Treat not-found style errors as missing. - msg := strings.ToLower(err.Error()) - if strings.Contains(msg, "not found") || strings.Contains(msg, "不存在") { + if nacos.IsConfigNotFound(err) { return false, nil } - // Some servers return 404 wrapped as http_status. - if strings.Contains(msg, "404") { - return false, nil + if status, ok := nacos.HTTPStatusCode(err); ok { + if status == 404 { + return false, nil + } + return false, err + } + // Compatibility fallback for errors produced before structured status and + // not-found types were introduced. Match complete localized prefixes only. + msg := strings.ToLower(strings.TrimSpace(err.Error())) + if status, ok := explicitNacosHTTPStatus(msg); ok { + if status == 404 { + return false, nil + } + return false, err + } + for _, prefix := range []string{ + "config not found:", + "配置不存在:", + "設定不存在:", + "設定が見つかりません:", + "konfiguration nicht gefunden:", + "конфигурация не найдена:", + } { + if strings.HasPrefix(msg, prefix) { + return false, nil + } } return false, err } -func nacosConfigKey(group, dataID string) string { +func explicitNacosHTTPStatus(message string) (int, bool) { + markers := []string{ + "nacos http", + "nacos-http-fehler", + "http nacos", + } + for _, marker := range markers { + index := strings.Index(message, marker) + if index < 0 { + continue + } + rest := message[index+len(marker):] + start := -1 + for index, char := range rest { + if char >= '0' && char <= '9' { + start = index + break + } + } + if start < 0 { + return 0, false + } + end := start + for end < len(rest) && rest[end] >= '0' && rest[end] <= '9' { + end++ + } + if end-start != 3 { + return 0, false + } + status, err := strconv.Atoi(rest[start:end]) + if err != nil || status < 100 || status > 599 { + return 0, false + } + return status, true + } + return 0, false +} + +func normalizeNacosConfigIdentityKey(group, dataID string) (nacosConfigIdentityKey, bool) { dataID = strings.TrimSpace(dataID) if dataID == "" { - return "" + return nacosConfigIdentityKey{}, false } group = strings.TrimSpace(group) if group == "" { group = "DEFAULT_GROUP" } - return group + "@@" + dataID + return nacosConfigIdentityKey{group: group, dataID: dataID}, true +} + +func nacosImportSelectionMatchesPayload( + configs []nacos.TransferConfigEntry, + selectedByIndex map[int]nacosConfigIdentityKey, + selectedByIdentity map[nacosConfigIdentityKey]struct{}, +) bool { + for index, selectedKey := range selectedByIndex { + if index < 0 || index >= len(configs) { + return false + } + payloadKey, ok := normalizeNacosConfigIdentityKey(configs[index].Group, configs[index].DataID) + if !ok || payloadKey != selectedKey { + return false + } + } + for selectedKey := range selectedByIdentity { + matched := false + for _, config := range configs { + payloadKey, ok := normalizeNacosConfigIdentityKey(config.Group, config.DataID) + if ok && payloadKey == selectedKey { + matched = true + break + } + } + if !matched { + return false + } + } + return true +} + +func nacosImportRowSelected( + index int, + key nacosConfigIdentityKey, + selectedByIndex map[int]nacosConfigIdentityKey, + selectedByIdentity map[nacosConfigIdentityKey]struct{}, +) bool { + if selectedKey, ok := selectedByIndex[index]; ok && selectedKey == key { + return true + } + _, ok := selectedByIdentity[key] + return ok +} + +func (a *App) ensureNacosDataImportAllowed(config connection.ConnectionConfig) error { + if config.ReadOnly { + return errors.New(a.appText("nacos.backend.error.read_only", nil)) + } + if config.Protection.RestrictDataImport { + return errors.New(readOnlyConnectionActionBlockedMessageWithText( + "connection.backend.action.import_data", + a.appText, + )) + } + return nil } func normalizeNacosTransferFilename(filename string) string { diff --git a/internal/app/methods_nacos_transfer_test.go b/internal/app/methods_nacos_transfer_test.go new file mode 100644 index 00000000..59f9d772 --- /dev/null +++ b/internal/app/methods_nacos_transfer_test.go @@ -0,0 +1,421 @@ +package app + +import ( + "context" + "errors" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/internal/nacos" +) + +type nacosTransferTestClient struct { + nacos.Client + getConfig func(context.Context, string, string, string) (*nacos.ConfigDetail, error) + publish func(context.Context, nacos.PublishRequest) error + closed atomic.Int32 +} + +func (client *nacosTransferTestClient) Connect(connection.ConnectionConfig) error { + return nil +} + +func (client *nacosTransferTestClient) Close() error { + client.closed.Add(1) + return nil +} + +func (client *nacosTransferTestClient) GetConfig( + ctx context.Context, + namespaceID, group, dataID string, +) (*nacos.ConfigDetail, error) { + if client.getConfig != nil { + return client.getConfig(ctx, namespaceID, group, dataID) + } + return nil, errors.New("Config not found: test fixture") +} + +func (client *nacosTransferTestClient) PublishConfig(ctx context.Context, request nacos.PublishRequest) error { + if client.publish != nil { + return client.publish(ctx, request) + } + return nil +} + +func TestNacosImportConfigsRejectsEmptyEffectiveSelection(t *testing.T) { + tests := []struct { + name string + items []NacosConfigIdentity + }{ + {name: "empty", items: nil}, + { + name: "all invalid", + items: []NacosConfigIdentity{ + {DataID: " ", Group: "DEFAULT_GROUP"}, + {DataID: "", Group: "DEV_GROUP"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + installNacosCacheTestHooks(t) + var published atomic.Int32 + client := &nacosTransferTestClient{ + publish: func(context.Context, nacos.PublishRequest) error { + published.Add(1) + return nil + }, + } + newNacosClientFunc = func() nacos.Client { return client } + + transfer := nacos.NewTransferFile("dev", "Development") + transfer.Configs = []nacos.TransferConfigEntry{{ + DataID: "application.yaml", + Group: "DEFAULT_GROUP", + Content: "enabled: true", + }} + filename := filepath.Join(t.TempDir(), "selected-import.json") + if err := nacos.WriteTransferFile(filename, transfer); err != nil { + t.Fatalf("WriteTransferFile: %v", err) + } + + result := (&App{}).NacosImportConfigs(connection.ConnectionConfig{ + Type: "nacos", + Host: "nacos.example.test", + Port: 8848, + Timeout: 1, + }, NacosImportConfigsOptions{ + NamespaceID: "dev", + ConflictMode: "overwrite", + File: filename, + Scope: "selected", + Items: test.items, + }) + if result.Success { + t.Fatalf("selected import unexpectedly succeeded: %#v", result) + } + if got := published.Load(); got != 0 { + t.Fatalf("selected import published %d config(s), want 0", got) + } + }) + } +} + +func TestNacosImportConfigsMatchesSelectedIdentityWithoutDelimiterCollisions(t *testing.T) { + installNacosCacheTestHooks(t) + + var published []nacos.PublishRequest + client := &nacosTransferTestClient{ + publish: func(_ context.Context, request nacos.PublishRequest) error { + published = append(published, request) + return nil + }, + } + newNacosClientFunc = func() nacos.Client { return client } + + transfer := nacos.NewTransferFile("dev", "Development") + transfer.Configs = []nacos.TransferConfigEntry{ + {Group: "A", DataID: "B@@C", Content: "first"}, + {Group: "A@@B", DataID: "C", Content: "second"}, + } + filename := filepath.Join(t.TempDir(), "delimiter-selection.json") + if err := nacos.WriteTransferFile(filename, transfer); err != nil { + t.Fatalf("WriteTransferFile: %v", err) + } + selectedIndex := 1 + + result := (&App{}).NacosImportConfigs(connection.ConnectionConfig{ + Type: "nacos", + Host: "nacos.example.test", + Port: 8848, + }, NacosImportConfigsOptions{ + NamespaceID: "dev", + ConflictMode: "overwrite", + File: filename, + Scope: "selected", + Items: []NacosConfigIdentity{{ + Index: &selectedIndex, + Group: "A@@B", + DataID: "C", + }}, + }) + if !result.Success { + t.Fatalf("selected import failed: %#v", result) + } + if len(published) != 1 { + t.Fatalf("published %d configs, want exactly 1", len(published)) + } + if published[0].Group != "A@@B" || published[0].DataID != "C" || published[0].Content != "second" { + t.Fatalf("published %#v, want the second preview row", published[0]) + } +} + +func TestNacosImportConfigsRejectsSelectedItemsThatDoNotMatchTheFile(t *testing.T) { + installNacosCacheTestHooks(t) + + var published atomic.Int32 + client := &nacosTransferTestClient{ + publish: func(context.Context, nacos.PublishRequest) error { + published.Add(1) + return nil + }, + } + newNacosClientFunc = func() nacos.Client { return client } + + transfer := nacos.NewTransferFile("dev", "Development") + transfer.Configs = []nacos.TransferConfigEntry{{ + Group: "DEFAULT_GROUP", DataID: "application.yaml", Content: "enabled: true", + }} + filename := filepath.Join(t.TempDir(), "selection-mismatch.json") + if err := nacos.WriteTransferFile(filename, transfer); err != nil { + t.Fatalf("WriteTransferFile: %v", err) + } + + result := (&App{}).NacosImportConfigs(connection.ConnectionConfig{ + Type: "nacos", + Host: "nacos.example.test", + Port: 8848, + }, NacosImportConfigsOptions{ + NamespaceID: "dev", + File: filename, + Scope: "selected", + Items: []NacosConfigIdentity{{ + Group: "DEFAULT_GROUP", DataID: "missing.yaml", + }}, + }) + if result.Success { + t.Fatalf("mismatched selected import unexpectedly succeeded: %#v", result) + } + if got := published.Load(); got != 0 { + t.Fatalf("mismatched selected import published %d config(s)", got) + } +} + +func TestNacosImportConfigsReportsPartialFailures(t *testing.T) { + installNacosCacheTestHooks(t) + + client := &nacosTransferTestClient{ + publish: func(_ context.Context, request nacos.PublishRequest) error { + if request.DataID == "failed.yaml" { + return errors.New("publish denied") + } + return nil + }, + } + newNacosClientFunc = func() nacos.Client { return client } + + transfer := nacos.NewTransferFile("dev", "Development") + transfer.Configs = []nacos.TransferConfigEntry{ + {Group: "DEFAULT_GROUP", DataID: "ok.yaml", Content: "ok"}, + {Group: "DEFAULT_GROUP", DataID: "failed.yaml", Content: "failed"}, + } + filename := filepath.Join(t.TempDir(), "partial-failure.json") + if err := nacos.WriteTransferFile(filename, transfer); err != nil { + t.Fatalf("WriteTransferFile: %v", err) + } + + result := (&App{}).NacosImportConfigs(connection.ConnectionConfig{ + Type: "nacos", + Host: "nacos.example.test", + Port: 8848, + }, NacosImportConfigsOptions{ + NamespaceID: "dev", + ConflictMode: "overwrite", + File: filename, + Scope: "all", + }) + if result.Success { + t.Fatalf("partial import unexpectedly reported success: %#v", result) + } + counts, ok := result.Data.(map[string]any) + if !ok { + t.Fatalf("partial import data = %#v, want count map", result.Data) + } + if counts["imported"] != 1 || counts["failed"] != 1 { + t.Fatalf("partial import counts = %#v, want imported=1 failed=1", counts) + } +} + +func TestBuildNacosImportPreviewFailsWhenExistenceCheckIsForbidden(t *testing.T) { + messages := []string{ + "Nacos HTTP error 403: permission denied; config not found", + "Nacos HTTP 错误 403:权限不足;配置不存在", + "Nacos HTTP 錯誤 403:權限不足;設定不存在", + "Nacos HTTP エラー 403: 権限がありません; 設定が見つかりません", + "Nacos-HTTP-Fehler 403: Keine Berechtigung; Konfiguration nicht gefunden", + "Ошибка HTTP Nacos 403: нет разрешения; Конфигурация не найдена", + } + for _, forbiddenMessage := range messages { + t.Run(forbiddenMessage, func(t *testing.T) { + client := &nacosTransferTestClient{ + getConfig: func(context.Context, string, string, string) (*nacos.ConfigDetail, error) { + return nil, errors.New(forbiddenMessage) + }, + } + payload := nacos.NewTransferFile("source", "Source") + payload.Configs = []nacos.TransferConfigEntry{{ + DataID: "application.yaml", + Group: "DEFAULT_GROUP", + }} + + _, err := buildNacosImportPreview(context.Background(), client, "import.json", "target", payload) + if err == nil { + t.Fatal("preview unexpectedly treated forbidden existence check as missing config") + } + if !strings.Contains(err.Error(), "403") { + t.Fatalf("preview error = %v, want forbidden existence check error", err) + } + }) + } +} + +func TestBuildNacosImportPreviewFailsOnNonNotFoundErrors(t *testing.T) { + tests := []struct { + name string + err error + }{ + {name: "timeout", err: context.DeadlineExceeded}, + {name: "unrelated number", err: errors.New("upstream port 4040 is unavailable")}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := &nacosTransferTestClient{ + getConfig: func(context.Context, string, string, string) (*nacos.ConfigDetail, error) { + return nil, test.err + }, + } + payload := nacos.NewTransferFile("source", "Source") + payload.Configs = []nacos.TransferConfigEntry{{ + DataID: "application.yaml", + Group: "DEFAULT_GROUP", + }} + + _, err := buildNacosImportPreview(context.Background(), client, "import.json", "target", payload) + if err == nil { + t.Fatalf("preview unexpectedly treated %v as missing config", test.err) + } + if !errors.Is(err, test.err) && err.Error() != test.err.Error() { + t.Fatalf("preview error = %v, want %v", err, test.err) + } + }) + } +} + +func TestBuildNacosImportPreviewTreatsExplicitHTTP404AsMissing(t *testing.T) { + client := &nacosTransferTestClient{ + getConfig: func(context.Context, string, string, string) (*nacos.ConfigDetail, error) { + return nil, errors.New("Nacos HTTP error 404: config is absent") + }, + } + payload := nacos.NewTransferFile("source", "Source") + payload.Configs = []nacos.TransferConfigEntry{{ + DataID: "application.yaml", + Group: "DEFAULT_GROUP", + }} + + preview, err := buildNacosImportPreview(context.Background(), client, "import.json", "target", payload) + if err != nil { + t.Fatalf("preview rejected an explicit HTTP 404: %v", err) + } + if preview.ExistsCount != 0 || preview.NewCount != 1 || len(preview.Items) != 1 || + preview.Items[0].Exists || preview.Items[0].Index != 0 { + t.Fatalf("preview = %#v, want one missing config", preview) + } +} + +func TestBuildNacosImportPreviewTreatsLocalizedConfigNotFoundAsMissing(t *testing.T) { + messages := []string{ + "Config not found: DEFAULT_GROUP / application.yaml", + "配置不存在:DEFAULT_GROUP / application.yaml", + "設定不存在:DEFAULT_GROUP / application.yaml", + "設定が見つかりません: DEFAULT_GROUP / application.yaml", + "Konfiguration nicht gefunden: DEFAULT_GROUP / application.yaml", + "Конфигурация не найдена: DEFAULT_GROUP / application.yaml", + } + for _, message := range messages { + t.Run(message, func(t *testing.T) { + client := &nacosTransferTestClient{ + getConfig: func(context.Context, string, string, string) (*nacos.ConfigDetail, error) { + return nil, errors.New(message) + }, + } + payload := nacos.NewTransferFile("source", "Source") + payload.Configs = []nacos.TransferConfigEntry{{ + DataID: "application.yaml", + Group: "DEFAULT_GROUP", + }} + + preview, err := buildNacosImportPreview(context.Background(), client, "import.json", "target", payload) + if err != nil { + t.Fatalf("preview rejected localized config-not-found error: %v", err) + } + if preview.NewCount != 1 || preview.ExistsCount != 0 { + t.Fatalf("preview = %#v, want one missing config", preview) + } + }) + } +} + +func TestEnsureNacosDataImportAllowedHonorsExplicitProtection(t *testing.T) { + app := &App{} + base := connection.ConnectionConfig{Type: "nacos"} + + if err := app.ensureNacosDataImportAllowed(base); err != nil { + t.Fatalf("unrestricted import was rejected: %v", err) + } + + importRestricted := base + importRestricted.Protection.RestrictDataImport = true + if err := app.ensureNacosDataImportAllowed(importRestricted); err == nil { + t.Fatal("restrictDataImport should reject Nacos config import") + } + + dataEditRestricted := base + dataEditRestricted.Protection.RestrictDataEdit = true + if err := app.ensureNacosDataImportAllowed(dataEditRestricted); err != nil { + t.Fatalf("restrictDataEdit should not reject independently protected Nacos config import: %v", err) + } + + readOnly := base + readOnly.ReadOnly = true + if err := app.ensureNacosDataImportAllowed(readOnly); err == nil { + t.Fatal("readOnly should reject Nacos config import") + } +} + +func TestEnsureNacosStructureEditAllowedHonorsOwnProtection(t *testing.T) { + app := &App{} + base := connection.ConnectionConfig{Type: "nacos"} + + if err := app.ensureNacosStructureEditAllowed(base); err != nil { + t.Fatalf("unrestricted structure edit was rejected: %v", err) + } + + structureRestricted := base + structureRestricted.Protection.RestrictStructureEdit = true + if err := app.ensureNacosStructureEditAllowed(structureRestricted); err == nil { + t.Fatal("restrictStructureEdit should reject Nacos structure edits") + } + + dataEditRestricted := base + dataEditRestricted.Protection.RestrictDataEdit = true + if err := app.ensureNacosStructureEditAllowed(dataEditRestricted); err != nil { + t.Fatalf("restrictDataEdit should not reject independently protected Nacos structure edits: %v", err) + } + + importRestricted := base + importRestricted.Protection.RestrictDataImport = true + if err := app.ensureNacosStructureEditAllowed(importRestricted); err != nil { + t.Fatalf("restrictDataImport should not reject independently protected Nacos structure edits: %v", err) + } + + readOnly := base + readOnly.ReadOnly = true + if err := app.ensureNacosStructureEditAllowed(readOnly); err == nil { + t.Fatal("readOnly should reject Nacos structure edits") + } +} diff --git a/internal/nacos/api_version.go b/internal/nacos/api_version.go index 49093c78..ae527341 100644 --- a/internal/nacos/api_version.go +++ b/internal/nacos/api_version.go @@ -3,12 +3,59 @@ package nacos import ( "context" "encoding/json" + "errors" "net/http" "strings" ) +const ( + nacosV3ReadinessPath = "/v3/admin/core/state/readiness" + nacosV2ReadinessPath = "/v2/console/health/readiness" + nacosV1ReadinessPath = "/v1/console/health/readiness" +) + type nacosAPIFamily uint8 +type nacosHTTPError struct { + status int + body string +} + +type nacosConfigNotFoundError struct { + group string + dataID string +} + +func (e *nacosHTTPError) Error() string { + return localizedNacosBackendText("nacos.backend.error.http_status", map[string]any{ + "status": e.status, + "body": e.body, + }) +} + +func (e *nacosConfigNotFoundError) Error() string { + return localizedNacosBackendText("nacos.backend.error.config_not_found", map[string]any{ + "group": e.group, + "dataId": e.dataID, + }) +} + +// HTTPStatusCode extracts a Nacos HTTP status from an error. +func HTTPStatusCode(err error) (int, bool) { + var statusErr *nacosHTTPError + if !errors.As(err, &statusErr) { + return 0, false + } + return statusErr.status, true +} + +// IsConfigNotFound reports whether an error explicitly represents a missing +// Nacos config. It intentionally does not classify arbitrary error text. +func IsConfigNotFound(err error) bool { + var notFoundErr *nacosConfigNotFoundError + return errors.As(err, ¬FoundErr) +} + const ( nacosAPIUnknown nacosAPIFamily = iota nacosAPIV1 @@ -101,21 +148,62 @@ func (c *ClientImpl) currentAPIRoutes() nacosAPIRoutes { func (c *ClientImpl) detectAPIFamily(ctx context.Context) error { probes := []struct { - family nacosAPIFamily - path string + family nacosAPIFamily + path string + readiness bool + validate func([]byte) error }{ - {family: nacosAPIV3, path: routesForNacosAPI(nacosAPIV3).namespaceList}, - {family: nacosAPIV2, path: routesForNacosAPI(nacosAPIV2).namespaceList}, - {family: nacosAPIV1, path: routesForNacosAPI(nacosAPIV1).namespaceList}, + { + family: nacosAPIV3, + path: nacosV3ReadinessPath, + readiness: true, + validate: validateNacosAPIReadinessProbe, + }, + { + family: nacosAPIV2, + path: nacosV2ReadinessPath, + readiness: true, + validate: validateNacosAPIReadinessProbe, + }, + // Nacos 2.2 introduced the v2 APIs before the v2 readiness endpoint. + // Its namespace list is unprotected and remains the compatibility probe. + { + family: nacosAPIV2, + path: routesForNacosAPI(nacosAPIV2).namespaceList, + validate: validateNacosAPIProbe, + }, + { + family: nacosAPIV1, + path: nacosV1ReadinessPath, + readiness: true, + validate: validateNacosV1ReadinessProbe, + }, + // Official Nacos 1.x has exposed readiness since 1.0.0. Keep the + // namespace route as a final compatibility fallback for deployments + // whose reverse proxy intentionally hides the health controller. + { + family: nacosAPIV1, + path: routesForNacosAPI(nacosAPIV1).namespaceList, + validate: validateNacosAPIProbe, + }, } for _, probe := range probes { - body, status, err := c.doRequest(ctx, http.MethodGet, probe.path, nil, nil) + var ( + body []byte + status int + err error + ) + if probe.readiness { + body, status, err = c.doReadinessRequest(ctx, probe.path) + } else { + body, status, err = c.doRequest(ctx, http.MethodGet, probe.path, nil, nil) + } if err != nil { return err } if status >= 200 && status < 300 { - if err := validateNacosAPIProbe(body); err != nil { + if err := probe.validate(body); err != nil { return err } c.mu.Lock() @@ -132,30 +220,105 @@ func (c *ClientImpl) detectAPIFamily(ctx context.Context) error { return nacosHTTPStatusError(http.StatusNotFound, []byte("no supported Nacos API family found")) } -func validateNacosAPIProbe(body []byte) error { - var envelope struct { - Code *int `json:"code"` - Message string `json:"message"` - Data json.RawMessage `json:"data"` +func (c *ClientImpl) probeReadiness(ctx context.Context, family nacosAPIFamily) error { + path := nacosV1ReadinessPath + validate := validateNacosV1ReadinessProbe + switch family { + case nacosAPIV3: + path = nacosV3ReadinessPath + validate = validateNacosAPIReadinessProbe + case nacosAPIV2: + path = nacosV2ReadinessPath + validate = validateNacosAPIReadinessProbe } + + body, status, err := c.doReadinessRequest(ctx, path) + if err != nil { + return err + } + if status < 200 || status >= 300 { + if family != nacosAPIV3 && isMissingNacosAPI(status, body) { + // Nacos 2.2 predates the v2 readiness controller. The same + // fallback also preserves compatibility with Nacos 1.x + // deployments whose reverse proxy hides the health endpoint. + _, err = c.ListNamespaces(ctx) + return err + } + return nacosHTTPStatusError(status, body) + } + return validate(body) +} + +func (c *ClientImpl) doReadinessRequest(ctx context.Context, path string) ([]byte, int, error) { + body, status, err := c.doRequestRaw(ctx, http.MethodGet, path, nil, nil, false) + if err != nil || (status != http.StatusUnauthorized && status != http.StatusForbidden) { + return body, status, err + } + + c.mu.Lock() + hasCredentials := strings.TrimSpace(c.config.User) != "" + c.mu.Unlock() + if !hasCredentials { + return body, status, nil + } + return c.doRequest(ctx, http.MethodGet, path, nil, nil) +} + +type nacosAPIProbeEnvelope struct { + Code *int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +func parseNacosAPIProbe(body []byte) (nacosAPIProbeEnvelope, error) { + var envelope nacosAPIProbeEnvelope if err := json.Unmarshal(body, &envelope); err != nil || envelope.Code == nil { detail := "response is not a Nacos API result" if err != nil { detail = err.Error() } - return localizedNacosBackendError("nacos.backend.error.parse_namespaces", map[string]any{ + return nacosAPIProbeEnvelope{}, localizedNacosBackendError("nacos.backend.error.parse_namespaces", map[string]any{ "detail": detail, }) } if *envelope.Code != 0 && *envelope.Code != 200 { - return localizedNacosBackendError("nacos.backend.error.api_code", map[string]any{ + return nacosAPIProbeEnvelope{}, localizedNacosBackendError("nacos.backend.error.api_code", map[string]any{ "code": *envelope.Code, - "message": strings.TrimSpace(envelope.Message), + "message": truncateForError(envelope.Message), + }) + } + return envelope, nil +} + +func validateNacosAPIProbe(body []byte) error { + _, err := parseNacosAPIProbe(body) + return err +} + +func validateNacosAPIReadinessProbe(body []byte) error { + envelope, err := parseNacosAPIProbe(body) + if err != nil { + return err + } + var readiness string + if err := json.Unmarshal(envelope.Data, &readiness); err != nil || + !strings.EqualFold(strings.TrimSpace(readiness), "ok") { + return localizedNacosBackendError("nacos.backend.error.parse_namespaces", map[string]any{ + "detail": "response is not a Nacos API readiness result", }) } return nil } +func validateNacosV1ReadinessProbe(body []byte) error { + if strings.EqualFold(strings.TrimSpace(string(body)), "OK") { + return nil + } + return localizedNacosBackendError("nacos.backend.error.parse_namespaces", map[string]any{ + "detail": "response is not a Nacos v1 readiness result", + }) +} + func isMissingNacosAPI(status int, body []byte) bool { if status == http.StatusNotFound || status == http.StatusMethodNotAllowed { return true @@ -170,10 +333,10 @@ func isMissingNacosAPI(status int, body []byte) bool { } func nacosHTTPStatusError(status int, body []byte) error { - return localizedNacosBackendError("nacos.backend.error.http_status", map[string]any{ - "status": status, - "body": truncateForError(string(body)), - }) + return &nacosHTTPError{ + status: status, + body: truncateForError(string(body)), + } } // unwrapNacosResult extracts data from the Result envelope used by Nacos @@ -190,7 +353,7 @@ func unwrapNacosResult(body []byte) ([]byte, error) { if *envelope.Code != 0 && *envelope.Code != 200 { return nil, localizedNacosBackendError("nacos.backend.error.api_code", map[string]any{ "code": *envelope.Code, - "message": strings.TrimSpace(envelope.Message), + "message": truncateForError(envelope.Message), }) } if len(envelope.Data) == 0 || string(envelope.Data) == "null" { diff --git a/internal/nacos/api_version_test.go b/internal/nacos/api_version_test.go index bd83b69b..34cb5b1b 100644 --- a/internal/nacos/api_version_test.go +++ b/internal/nacos/api_version_test.go @@ -7,9 +7,11 @@ import ( "net/http" "net/http/httptest" "net/url" + "regexp" "strconv" "strings" "sync" + "sync/atomic" "testing" "GoNavi-Wails/internal/connection" @@ -134,7 +136,7 @@ func TestClientAPIFamilyDetectionDoesNotFallbackOnForbidden(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { recorder.record(request) switch request.URL.Path { - case routesForNacosAPI(nacosAPIV3).namespaceList: + case nacosV3ReadinessPath: w.WriteHeader(http.StatusForbidden) _, _ = io.WriteString(w, `{"code":403,"message":"no such api for this account"}`) case routesForNacosAPI(nacosAPIV2).namespaceList, routesForNacosAPI(nacosAPIV1).namespaceList: @@ -153,7 +155,7 @@ func TestClientAPIFamilyDetectionDoesNotFallbackOnForbidden(t *testing.T) { if !strings.Contains(err.Error(), "403") { t.Fatalf("Connect error = %q, want HTTP 403", err) } - if got := recorder.countPath(routesForNacosAPI(nacosAPIV3).namespaceList); got == 0 { + if got := recorder.countPath(nacosV3ReadinessPath); got == 0 { t.Fatal("v3 probe was not requested") } if got := recorder.countPath(routesForNacosAPI(nacosAPIV2).namespaceList); got != 0 { @@ -164,8 +166,214 @@ func TestClientAPIFamilyDetectionDoesNotFallbackOnForbidden(t *testing.T) { } } -func TestClientAuthUsesV3LoginWithoutLegacyFallback(t *testing.T) { +func TestClientAPIFamilyDetectionUsesV2ReadinessWithoutNamespacePermission(t *testing.T) { recorder := &nacosAPIRequestRecorder{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + recorder.record(request) + switch request.URL.Path { + case nacosV3ReadinessPath: + http.NotFound(w, request) + case nacosV2ReadinessPath: + writeNacosResult(w, nacosAPIV2, "ok") + case routesForNacosAPI(nacosAPIV2).namespaceList: + http.Error(w, "namespace permission denied", http.StatusForbidden) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + + client := connectAPIVersionTestClient(t, server) + defer client.Close() + if client.apiFamily != nacosAPIV2 { + t.Fatalf("detected API family = %d, want v2", client.apiFamily) + } + if got := recorder.countPath(nacosV2ReadinessPath); got != 2 { + t.Fatalf("v2 readiness probe count = %d, want 2 for detection and ping", got) + } + if got := recorder.countPath(routesForNacosAPI(nacosAPIV2).namespaceList); got != 0 { + t.Fatalf("v2 namespace probe count = %d, want 0", got) + } +} + +func TestClientAPIFamilyDetectionUsesV1ReadinessWithoutNamespacePermission(t *testing.T) { + recorder := &nacosAPIRequestRecorder{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + recorder.record(request) + switch request.URL.Path { + case nacosV3ReadinessPath, nacosV2ReadinessPath, routesForNacosAPI(nacosAPIV2).namespaceList: + http.NotFound(w, request) + case nacosV1ReadinessPath: + _, _ = io.WriteString(w, "OK") + case routesForNacosAPI(nacosAPIV1).namespaceList: + http.Error(w, "namespace permission denied", http.StatusForbidden) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + + client := connectAPIVersionTestClient(t, server) + defer client.Close() + if client.apiFamily != nacosAPIV1 { + t.Fatalf("detected API family = %d, want v1", client.apiFamily) + } + if got := recorder.countPath(nacosV1ReadinessPath); got != 2 { + t.Fatalf("v1 readiness probe count = %d, want 2 for detection and ping", got) + } + if got := recorder.countPath(routesForNacosAPI(nacosAPIV1).namespaceList); got != 0 { + t.Fatalf("v1 namespace probe count = %d, want 0", got) + } +} + +func TestClientAPIFamilyDetectionKeepsNacos22NamespaceFallback(t *testing.T) { + recorder := &nacosAPIRequestRecorder{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + recorder.record(request) + switch request.URL.Path { + case nacosV3ReadinessPath, nacosV2ReadinessPath: + http.NotFound(w, request) + case routesForNacosAPI(nacosAPIV2).namespaceList: + writeNacosResult(w, nacosAPIV2, []any{}) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + + client := connectAPIVersionTestClient(t, server) + defer client.Close() + if client.apiFamily != nacosAPIV2 { + t.Fatalf("detected API family = %d, want v2", client.apiFamily) + } + if got := recorder.countPath(nacosV2ReadinessPath); got != 2 { + t.Fatalf("v2 readiness probe count = %d, want 2 for detection and ping", got) + } + if got := recorder.countPath(routesForNacosAPI(nacosAPIV2).namespaceList); got != 2 { + t.Fatalf("v2 namespace probe count = %d, want 2 for detection and ping fallback", got) + } +} + +func TestValidateNacosV1ReadinessProbe(t *testing.T) { + for _, body := range []string{"OK", " ok\r\n"} { + if err := validateNacosV1ReadinessProbe([]byte(body)); err != nil { + t.Fatalf("validate v1 readiness body %q: %v", body, err) + } + } + for _, body := range []string{"", "console", `{"code":200,"data":"OK"}`} { + if err := validateNacosV1ReadinessProbe([]byte(body)); err == nil { + t.Fatalf("validate v1 readiness body %q unexpectedly succeeded", body) + } + } +} + +func TestValidateNacosAPIReadinessProbe(t *testing.T) { + for _, body := range []string{ + `{"code":0,"message":"success","data":"ok"}`, + `{"code":200,"message":"success","data":" OK "}`, + } { + if err := validateNacosAPIReadinessProbe([]byte(body)); err != nil { + t.Fatalf("validate readiness body %q: %v", body, err) + } + } + for _, body := range []string{ + `{"code":0,"message":"success","data":true}`, + `{"code":0,"message":"success","data":"ready"}`, + `{"code":0,"message":"success","data":null}`, + `{"code":0,"message":"success"}`, + } { + if err := validateNacosAPIReadinessProbe([]byte(body)); err == nil { + t.Fatalf("validate readiness body %q unexpectedly succeeded", body) + } + } +} + +func TestClientReadinessRejectsNonOfficialSuccessPayload(t *testing.T) { + tests := []struct { + name string + invalidOnCall int + }{ + {name: "detection", invalidOnCall: 1}, + {name: "connect ping", invalidOnCall: 2}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var readinessRequests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case nacosV3ReadinessPath: + requestNumber := int(readinessRequests.Add(1)) + data := any("ok") + if requestNumber == test.invalidOnCall { + data = true + } + writeNacosResult(w, nacosAPIV3, data) + case routesForNacosAPI(nacosAPIV2).namespaceList: + writeNacosResult(w, nacosAPIV2, []any{}) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + + client := &ClientImpl{} + err := client.Connect(nacosAPITestConnectionConfig(t, server)) + if err == nil { + _ = client.Close() + t.Fatal("Connect unexpectedly accepted non-official readiness data") + } + if got := int(readinessRequests.Load()); got != test.invalidOnCall { + t.Fatalf("readiness requests = %d, want %d", got, test.invalidOnCall) + } + }) + } +} + +func TestClientPublicReadinessOmitsAccessToken(t *testing.T) { + const accessToken = "public-readiness-token" + var readinessMu sync.Mutex + var readinessTokens []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v3/auth/user/login": + writeNacosJSON(w, map[string]any{"accessToken": accessToken, "tokenTtl": 3600}) + case nacosV3ReadinessPath: + readinessMu.Lock() + readinessTokens = append(readinessTokens, request.URL.Query().Get("accessToken")) + readinessMu.Unlock() + writeNacosResult(w, nacosAPIV3, "ok") + default: + http.NotFound(w, request) + } + })) + defer server.Close() + + config := nacosAPITestConnectionConfig(t, server) + config.User = "nacos" + config.Password = "secret" + client := &ClientImpl{} + if err := client.Connect(config); err != nil { + t.Fatalf("Connect: %v", err) + } + defer client.Close() + + readinessMu.Lock() + gotReadinessTokens := append([]string(nil), readinessTokens...) + readinessMu.Unlock() + if len(gotReadinessTokens) != 2 { + t.Fatalf("readiness requests = %d, want 2", len(gotReadinessTokens)) + } + for index, token := range gotReadinessTokens { + if token != "" { + t.Fatalf("readiness request %d sent accessToken %q", index+1, token) + } + } +} + +func TestClientReadinessRetriesWithTokenForAuthGatedProxy(t *testing.T) { + recorder := &nacosAPIRequestRecorder{} + var readinessMu sync.Mutex + var readinessTokens []string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { recorder.record(request) switch request.URL.Path { @@ -177,12 +385,16 @@ func TestClientAuthUsesV3LoginWithoutLegacyFallback(t *testing.T) { writeNacosJSON(w, map[string]any{"accessToken": "v3-token", "tokenTtl": 3600}) case "/v1/auth/users/login": http.Error(w, "unexpected legacy login", http.StatusInternalServerError) - case routesForNacosAPI(nacosAPIV3).namespaceList: - if request.URL.Query().Get("accessToken") != "v3-token" { + case nacosV3ReadinessPath: + token := request.URL.Query().Get("accessToken") + readinessMu.Lock() + readinessTokens = append(readinessTokens, token) + readinessMu.Unlock() + if token != "v3-token" { http.Error(w, "missing token", http.StatusForbidden) return } - writeNacosResult(w, nacosAPIV3, []any{}) + writeNacosResult(w, nacosAPIV3, "ok") default: http.NotFound(w, request) } @@ -204,6 +416,18 @@ func TestClientAuthUsesV3LoginWithoutLegacyFallback(t *testing.T) { if got := recorder.countPath("/v1/auth/users/login"); got != 0 { t.Fatalf("legacy login count = %d, want 0", got) } + wantReadinessTokens := []string{"", "v3-token", "", "v3-token"} + readinessMu.Lock() + gotReadinessTokens := append([]string(nil), readinessTokens...) + readinessMu.Unlock() + if len(gotReadinessTokens) != len(wantReadinessTokens) { + t.Fatalf("readiness tokens = %#v, want %#v", gotReadinessTokens, wantReadinessTokens) + } + for index := range wantReadinessTokens { + if gotReadinessTokens[index] != wantReadinessTokens[index] { + t.Fatalf("readiness tokens = %#v, want %#v", gotReadinessTokens, wantReadinessTokens) + } + } } func TestClientAuthDoesNotFallbackAfterV3LoginForbidden(t *testing.T) { @@ -242,7 +466,7 @@ func TestNacosV2ListServicesWithoutGroupUsesV1Catalog(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { recorder.record(request) switch request.URL.Path { - case routesForNacosAPI(nacosAPIV3).namespaceList: + case nacosV3ReadinessPath: http.NotFound(w, request) case routesForNacosAPI(nacosAPIV2).namespaceList: writeNacosResult(w, nacosAPIV2, []any{}) @@ -292,8 +516,8 @@ func TestNacosV3ListServicesFiltersExactGroupAcrossPages(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { recorder.record(request) switch request.URL.Path { - case routesForNacosAPI(nacosAPIV3).namespaceList: - writeNacosResult(w, nacosAPIV3, []any{}) + case nacosV3ReadinessPath: + writeNacosResult(w, nacosAPIV3, "ok") case routesForNacosAPI(nacosAPIV3).serviceList: pageNumber, _ := strconv.Atoi(request.Form.Get("pageNo")) pageItems := []any{ @@ -355,12 +579,118 @@ func TestNacosV3ListServicesFiltersExactGroupAcrossPages(t *testing.T) { } } +func TestNacosV3ListServicesEscapesExactGroupPattern(t *testing.T) { + const targetGroup = "PAY[1]" + + recorder := &nacosAPIRequestRecorder{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + recorder.record(request) + switch request.URL.Path { + case nacosV3ReadinessPath: + writeNacosResult(w, nacosAPIV3, "ok") + case routesForNacosAPI(nacosAPIV3).serviceList: + groupPattern, err := regexp.Compile(request.Form.Get("groupNameParam")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + candidates := []nacosServiceItem{ + {Name: "literal", GroupName: targetGroup}, + {Name: "regex-lookalike", GroupName: "PAY1"}, + {Name: "prefix", GroupName: targetGroup + "-ARCHIVE"}, + } + pageItems := make([]nacosServiceItem, 0, len(candidates)) + for _, candidate := range candidates { + if groupPattern.MatchString(candidate.GroupName) { + pageItems = append(pageItems, candidate) + } + } + writeNacosResult(w, nacosAPIV3, map[string]any{ + "totalCount": len(pageItems), + "pageNumber": 1, + "pagesAvailable": 1, + "pageItems": pageItems, + }) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + + client := connectAPIVersionTestClient(t, server) + defer client.Close() + page, err := client.ListServices(context.Background(), ServiceQuery{ + NamespaceID: "dev-id", + GroupName: targetGroup, + PageNo: 1, + PageSize: 20, + }) + if err != nil { + t.Fatalf("ListServices: %v", err) + } + if page.Count != 1 || len(page.ServiceNames) != 1 || page.ServiceNames[0] != targetGroup+"@@literal" { + t.Fatalf("exact group page = %#v", page) + } + + request := mustLastNacosAPIRequest(t, recorder, http.MethodGet, routesForNacosAPI(nacosAPIV3).serviceList) + if got, want := request.values.Get("groupNameParam"), regexp.QuoteMeta(targetGroup); got != want { + t.Fatalf("groupNameParam = %q, want %q", got, want) + } +} + +func TestCreateEphemeralServiceAPIVersionBoundary(t *testing.T) { + tests := []struct { + name string + family nacosAPIFamily + }{ + {name: "v1 rejects before request", family: nacosAPIV1}, + {name: "v2 forwards ephemeral", family: nacosAPIV2}, + {name: "v3 forwards ephemeral", family: nacosAPIV3}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := &nacosAPIRequestRecorder{} + server := httptest.NewServer(nacosAPIMatrixHandler(test.family, recorder)) + defer server.Close() + + client := connectAPIVersionTestClient(t, server) + defer client.Close() + ephemeral := true + err := client.CreateService(context.Background(), CreateServiceRequest{ + NamespaceID: "dev-id", + ServiceName: "orders", + GroupName: "MKEFU", + Ephemeral: &ephemeral, + }) + routes := routesForNacosAPI(test.family) + if test.family == nacosAPIV1 { + if err == nil { + t.Fatal("expected v1 ephemeral service creation to fail") + } + if !strings.Contains(err.Error(), "Nacos v1") { + t.Fatalf("CreateService error = %q, want explicit Nacos v1 boundary", err) + } + if _, ok := recorder.last(http.MethodPost, routes.service); ok { + t.Fatal("v1 ephemeral service creation sent an HTTP request") + } + return + } + if err != nil { + t.Fatalf("CreateService: %v", err) + } + request := mustLastNacosAPIRequest(t, recorder, http.MethodPost, routes.service) + assertNacosAPIValues(t, request.values, map[string]string{"ephemeral": "true"}) + }) + } +} + func TestNacosV2GetConfigPreservesJSONContent(t *testing.T) { recorder := &nacosAPIRequestRecorder{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { recorder.record(request) switch request.URL.Path { - case routesForNacosAPI(nacosAPIV3).namespaceList: + case nacosV3ReadinessPath: http.NotFound(w, request) case routesForNacosAPI(nacosAPIV2).namespaceList: writeNacosResult(w, nacosAPIV2, []any{}) @@ -391,18 +721,19 @@ func assertProbeSequenceAndCache( family nacosAPIFamily, ) { t.Helper() + readinessPath := nacosV3ReadinessPath v3Path := routesForNacosAPI(nacosAPIV3).namespaceList v2Path := routesForNacosAPI(nacosAPIV2).namespaceList v1Path := routesForNacosAPI(nacosAPIV1).namespaceList wantBefore := map[nacosAPIFamily]map[string]int{ - nacosAPIV3: {v3Path: 2, v2Path: 0, v1Path: 0}, - nacosAPIV2: {v3Path: 1, v2Path: 2, v1Path: 0}, - nacosAPIV1: {v3Path: 1, v2Path: 1, v1Path: 2}, + nacosAPIV3: {readinessPath: 2, v3Path: 0, v2Path: 0, v1Path: 0}, + nacosAPIV2: {readinessPath: 1, v3Path: 0, v2Path: 2, v1Path: 0}, + nacosAPIV1: {readinessPath: 1, v3Path: 0, v2Path: 1, v1Path: 2}, }[family] for path, want := range wantBefore { if got := recorder.countPath(path); got != want { - t.Fatalf("namespace request count for %s = %d, want %d", path, got, want) + t.Fatalf("probe request count for %s = %d, want %d", path, got, want) } } @@ -661,19 +992,28 @@ func exerciseNacosAPIFamily( if err != nil { t.Fatalf("GetService: %v", err) } - if service.Name != "orders" || service.GroupName != "MKEFU" || service.NamespaceID != "dev-id" { + if service.Name != "orders" || service.GroupName != "MKEFU" || service.NamespaceID != "dev-id" || !service.Ephemeral { t.Fatalf("service detail = %#v", service) } request = mustLastNacosAPIRequest(t, recorder, http.MethodGet, routes.service) assertNacosNamingIdentity(t, request.values, test.qualifiedNaming) + serviceEphemeral := false if err := client.CreateService(ctx, CreateServiceRequest{ NamespaceID: "dev-id", ServiceName: "orders", GroupName: "MKEFU", ProtectThreshold: 0.5, + Ephemeral: &serviceEphemeral, }); err != nil { t.Fatalf("CreateService: %v", err) } request = mustLastNacosAPIRequest(t, recorder, http.MethodPost, routes.service) assertNacosNamingIdentity(t, request.values, test.qualifiedNaming) + if test.family == nacosAPIV1 { + if _, ok := request.values["ephemeral"]; ok { + t.Fatalf("v1 CreateService unexpectedly sent ephemeral=%q", request.values.Get("ephemeral")) + } + } else { + assertNacosAPIValues(t, request.values, map[string]string{"ephemeral": "false"}) + } if err := client.UpdateService(ctx, UpdateServiceRequest{ NamespaceID: "dev-id", ServiceName: "orders", GroupName: "MKEFU", ProtectThreshold: 0.25, }); err != nil { @@ -703,6 +1043,7 @@ func exerciseNacosAPIFamily( request = mustLastNacosAPIRequest(t, recorder, http.MethodGet, routes.instanceList) assertNacosNamingIdentity(t, request.values, test.qualifiedNaming) + zeroWeight := 0.0 instanceRequest := InstanceRequest{ NamespaceID: "dev-id", ServiceName: "orders", @@ -710,6 +1051,7 @@ func exerciseNacosAPIFamily( IP: "10.0.0.1", Port: 8080, ClusterName: "DEFAULT", + Weight: &zeroWeight, } instance, err := client.GetInstance(ctx, instanceRequest) if err != nil { @@ -730,11 +1072,19 @@ func exerciseNacosAPIFamily( } request = mustLastNacosAPIRequest(t, recorder, http.MethodPost, routes.instance) assertNacosNamingIdentity(t, request.values, test.qualifiedNaming) + assertNacosAPIValues(t, request.values, map[string]string{ + "ephemeral": "false", + "weight": "0", + }) if err := client.UpdateInstance(ctx, instanceRequest); err != nil { t.Fatalf("UpdateInstance: %v", err) } request = mustLastNacosAPIRequest(t, recorder, http.MethodPut, routes.instance) assertNacosNamingIdentity(t, request.values, test.qualifiedNaming) + assertNacosAPIValues(t, request.values, map[string]string{ + "ephemeral": "false", + "weight": "0", + }) if err := client.DeregisterInstance(ctx, instanceRequest); err != nil { t.Fatalf("DeregisterInstance: %v", err) } @@ -757,6 +1107,12 @@ func nacosAPIMatrixHandler(family nacosAPIFamily, recorder *nacosAPIRequestRecor recorder.record(request) values := request.Form switch { + case request.Method == http.MethodGet && request.URL.Path == nacosV3ReadinessPath: + if family == nacosAPIV3 { + writeNacosResult(w, nacosAPIV3, "ok") + } else { + http.NotFound(w, request) + } case request.Method == http.MethodGet && request.URL.Path == routes.namespaceList: writeNacosResult(w, family, []map[string]any{{ "namespace": "dev-id", @@ -791,6 +1147,7 @@ func nacosAPIMatrixHandler(family nacosAPIFamily, recorder *nacosAPIRequestRecor "name": "orders", "groupName": "MKEFU", "namespaceId": "dev-id", + "ephemeral": true, "protectThreshold": 0.5, "metadata": map[string]string{"owner": "team-a"}, "clusters": []any{}, diff --git a/internal/nacos/auth_lifecycle_test.go b/internal/nacos/auth_lifecycle_test.go new file mode 100644 index 00000000..1fa6cccb --- /dev/null +++ b/internal/nacos/auth_lifecycle_test.go @@ -0,0 +1,801 @@ +package nacos + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + "unicode/utf8" + + "GoNavi-Wails/internal/connection" +) + +func TestScopedNacosV3ConnectsViaReadinessWithoutNamespaceAdmin(t *testing.T) { + var ( + loginRequests atomic.Int32 + readinessRequests atomic.Int32 + namespaceRequests atomic.Int32 + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v3/auth/user/login": + loginRequests.Add(1) + writeAuthLifecycleJSON(w, map[string]any{ + "accessToken": "scoped-token", + "tokenTtl": 3600, + }) + case nacosV3ReadinessPath: + readinessRequests.Add(1) + writeAuthLifecycleJSON(w, map[string]any{ + "code": 0, + "message": "success", + "data": "ok", + }) + case routesForNacosAPI(nacosAPIV3).namespaceList: + namespaceRequests.Add(1) + http.Error(w, "namespace administrator permission required", http.StatusForbidden) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + + config := authLifecycleServerConfig(t, server) + config.User = "scoped-user" + config.Password = "scoped-password" + client := &ClientImpl{} + if err := client.Connect(config); err != nil { + t.Fatalf("Connect: %v", err) + } + defer client.Close() + + if client.currentAPIFamily() != nacosAPIV3 { + t.Fatalf("API family = %d, want v3", client.currentAPIFamily()) + } + if got := namespaceRequests.Load(); got != 0 { + t.Fatalf("namespace requests during Connect = %d, want 0", got) + } + if err := client.Ping(context.Background()); err != nil { + t.Fatalf("Ping: %v", err) + } + if got := namespaceRequests.Load(); got != 0 { + t.Fatalf("namespace requests after Ping = %d, want 0", got) + } + if got := readinessRequests.Load(); got != 3 { + t.Fatalf("readiness requests = %d, want 3 (detect, Connect Ping, explicit Ping)", got) + } + + _, err := client.ListNamespaces(context.Background()) + if err == nil { + t.Fatal("ListNamespaces unexpectedly succeeded for scoped account") + } + status, ok := HTTPStatusCode(fmt.Errorf("wrapped namespace error: %w", err)) + if !ok || status != http.StatusForbidden { + t.Fatalf("HTTPStatusCode(%v) = %d, %v; want 403, true", err, status, ok) + } + if got := namespaceRequests.Load(); got != 2 { + t.Fatalf("namespace requests = %d, want one request plus one auth retry", got) + } + if got := loginRequests.Load(); got != 2 { + t.Fatalf("login requests = %d, want initial login plus one 403 retry", got) + } +} + +func TestEnsureAuthDoesNotReuseCachedConnectionOperationTimeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v3/auth/user/login" { + http.NotFound(w, request) + return + } + time.Sleep(1200 * time.Millisecond) + writeAuthLifecycleJSON(w, map[string]any{ + "accessToken": "refreshed-token", + "tokenTtl": 3600, + }) + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + client := &ClientImpl{ + config: connection.ConnectionConfig{ + User: "scoped-user", + Password: "scoped-password", + // Simulate a cached client first opened by a short operation. + Timeout: 1, + }, + httpClient: server.Client(), + baseURL: baseURL, + } + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := client.ensureAuth(ctx); err != nil { + t.Fatalf("ensureAuth inherited the cached connection timeout: %v", err) + } +} + +func TestNacosHTTPErrorRedactsStructuredSecretsWithoutDroppingDiagnostics(t *testing.T) { + const ( + accessToken = "access-token-secret" + jsonSecret = "json-token-secret" + formSecret = "form-password-secret" + bearerSecret = "bearer-token-secret" + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v3/auth/user/login": + writeAuthLifecycleJSON(w, map[string]any{ + "accessToken": accessToken, + "tokenTtl": 3600, + }) + case nacosV3ReadinessPath: + writeAuthLifecycleJSON(w, map[string]any{ + "code": 0, + "message": "success", + "data": "ok", + }) + case routesForNacosAPI(nacosAPIV3).namespaceList: + w.WriteHeader(http.StatusBadGateway) + _, _ = fmt.Fprintf( + w, + `request failed: url=/namespace?accessToken=%s&group=DEFAULT_GROUP `+ + `json={"refreshToken":"%s","message":"keep-json"} `+ + `form=password=%s&reason=denied `+ + `Authorization: Bearer %s; ordinary token and password words remain`, + url.QueryEscape(accessToken), + jsonSecret, + url.QueryEscape(formSecret), + bearerSecret, + ) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + + config := authLifecycleServerConfig(t, server) + config.User = "nacos" + config.Password = formSecret + client := &ClientImpl{} + if err := client.Connect(config); err != nil { + t.Fatalf("Connect: %v", err) + } + defer client.Close() + + _, err := client.ListNamespaces(context.Background()) + if err == nil { + t.Fatal("ListNamespaces unexpectedly succeeded") + } + if status, ok := HTTPStatusCode(err); !ok || status != http.StatusBadGateway { + t.Fatalf("HTTPStatusCode(%v) = %d, %v; want 502, true", err, status, ok) + } + message := err.Error() + for _, secret := range []string{accessToken, jsonSecret, formSecret, bearerSecret} { + if strings.Contains(message, secret) || strings.Contains(message, url.QueryEscape(secret)) { + t.Fatalf("HTTP error leaked %q: %s", secret, message) + } + } + for _, diagnostic := range []string{ + "group=DEFAULT_GROUP", + `"message":"keep-json"`, + "reason=denied", + "ordinary token and password words remain", + } { + if !strings.Contains(message, diagnostic) { + t.Fatalf("HTTP error dropped non-sensitive diagnostic %q: %s", diagnostic, message) + } + } +} + +func TestNacosAPIErrorRedactsStructuredSecrets(t *testing.T) { + const ( + accessToken = "api-error-access-token" + apiSecret = "api-error-json-secret" + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v3/auth/user/login": + writeAuthLifecycleJSON(w, map[string]any{ + "accessToken": accessToken, + "tokenTtl": 3600, + }) + case nacosV3ReadinessPath: + writeAuthLifecycleJSON(w, map[string]any{ + "code": 0, + "message": "success", + "data": "ok", + }) + case routesForNacosAPI(nacosAPIV3).namespaceList: + writeAuthLifecycleJSON(w, map[string]any{ + "code": 500, + "message": fmt.Sprintf( + `upstream rejected /namespace?accessToken=%s payload={"secret":"%s"}; keep api diagnostic %s OMITTED_TAIL`, + url.QueryEscape(accessToken), + apiSecret, + strings.Repeat("x", 500), + ), + }) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + + config := authLifecycleServerConfig(t, server) + config.User = "nacos" + config.Password = "nacos-password" + client := &ClientImpl{} + if err := client.Connect(config); err != nil { + t.Fatalf("Connect: %v", err) + } + defer client.Close() + + _, err := client.ListNamespaces(context.Background()) + if err == nil { + t.Fatal("ListNamespaces unexpectedly succeeded") + } + message := err.Error() + for _, secret := range []string{accessToken, apiSecret} { + if strings.Contains(message, secret) || strings.Contains(message, url.QueryEscape(secret)) { + t.Fatalf("API error leaked %q: %s", secret, message) + } + } + if !strings.Contains(message, "keep api diagnostic") { + t.Fatalf("API error dropped non-sensitive diagnostic: %s", message) + } + if strings.Contains(message, "OMITTED_TAIL") { + t.Fatalf("API error message was not bounded: %s", message) + } +} + +func TestShortLivedAuthTokenIsReusedUntilDynamicRefreshWindow(t *testing.T) { + const accessToken = "short-lived-token" + var loginRequests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v3/auth/user/login": + loginRequests.Add(1) + writeAuthLifecycleJSON(w, map[string]any{ + "accessToken": accessToken, + "tokenTtl": 30, + }) + case nacosV3ReadinessPath: + writeAuthLifecycleJSON(w, map[string]any{ + "code": 0, + "message": "success", + "data": "ok", + }) + case routesForNacosAPI(nacosAPIV3).namespaceList: + if got := request.URL.Query().Get("accessToken"); got != accessToken { + t.Errorf("namespace accessToken = %q, want %q", got, accessToken) + } + writeAuthLifecycleJSON(w, map[string]any{ + "code": 0, + "message": "success", + "data": []any{}, + }) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + + config := authLifecycleServerConfig(t, server) + config.User = "nacos" + config.Password = "nacos-password" + client := &ClientImpl{} + if err := client.Connect(config); err != nil { + t.Fatalf("Connect: %v", err) + } + defer client.Close() + + if _, err := client.ListNamespaces(context.Background()); err != nil { + t.Fatalf("ListNamespaces: %v", err) + } + if got := loginRequests.Load(); got != 1 { + t.Fatalf("login requests = %d, want 1 before the dynamic refresh window", got) + } +} + +func TestTruncateForErrorPreservesUTF8(t *testing.T) { + result := truncateForError(strings.Repeat("a", 399) + "界") + if !utf8.ValidString(result) { + t.Fatalf("truncateForError returned invalid UTF-8: %q", result) + } + if !strings.HasSuffix(result, "...") { + t.Fatalf("truncateForError result = %q, want truncation suffix", result) + } +} + +func TestLocalizedNacosBackendDiagnosticsAreSanitized(t *testing.T) { + tests := []struct { + name string + key string + params map[string]any + secret string + diagnostic string + }{ + { + name: "detail", + key: "nacos.backend.error.request_failed", + params: map[string]any{ + "detail": "proxy failed Authorization: Basic detail-secret; keep-detail", + }, + secret: "detail-secret", + diagnostic: "keep-detail", + }, + { + name: "body", + key: "nacos.backend.error.http_status", + params: map[string]any{ + "status": 502, + "body": "upstream password=body-secret&reason=keep-body", + }, + secret: "body-secret", + diagnostic: "reason=keep-body", + }, + { + name: "message", + key: "nacos.backend.error.api_code", + params: map[string]any{ + "code": 500, + "message": `upstream {"refreshToken":"message-secret"} keep-message`, + }, + secret: "message-secret", + diagnostic: "keep-message", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + message := localizedNacosBackendError(test.key, test.params).Error() + if strings.Contains(message, test.secret) { + t.Fatalf("localized diagnostic leaked %q: %s", test.secret, message) + } + if !strings.Contains(message, test.diagnostic) { + t.Fatalf("localized diagnostic dropped %q: %s", test.diagnostic, message) + } + }) + } +} + +func TestLocalizedNacosBackendErrorPreservesStructuredClassification(t *testing.T) { + httpErr := localizedNacosBackendError("nacos.backend.error.http_status", map[string]any{ + "status": http.StatusForbidden, + "body": "permission denied", + }) + if status, ok := HTTPStatusCode(fmt.Errorf("wrapped: %w", httpErr)); !ok || status != http.StatusForbidden { + t.Fatalf("HTTPStatusCode(%v) = %d, %v; want 403, true", httpErr, status, ok) + } + + notFoundErr := localizedNacosBackendError("nacos.backend.error.config_not_found", map[string]any{ + "group": "DEFAULT_GROUP", + "dataId": "application.yaml", + }) + if !IsConfigNotFound(fmt.Errorf("wrapped: %w", notFoundErr)) { + t.Fatalf("IsConfigNotFound(%v) = false, want true", notFoundErr) + } + if IsConfigNotFound(errors.New("upstream config not found in an unrelated diagnostic")) { + t.Fatal("IsConfigNotFound classified an untyped diagnostic as missing") + } +} + +func TestAuthSingleflightSharesOneLogin(t *testing.T) { + const callers = 16 + loginStarted := make(chan struct{}) + releaseLogin := make(chan struct{}) + var ( + loginCalls atomic.Int32 + startedOnce sync.Once + releaseOnce sync.Once + ) + release := func() { + releaseOnce.Do(func() { close(releaseLogin) }) + } + defer release() + + client := newManualAuthLifecycleClient(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + if request.URL.Path != "/nacos/v3/auth/user/login" { + return nil, fmt.Errorf("unexpected request: %s", request.URL.Path) + } + loginCalls.Add(1) + startedOnce.Do(func() { close(loginStarted) }) + <-releaseLogin + return authLifecycleResponse(http.StatusOK, `{"accessToken":"shared-token","tokenTtl":3600}`), nil + }), "") + defer client.Close() + + start := make(chan struct{}) + results := make(chan error, callers) + var workers sync.WaitGroup + workers.Add(callers) + for range callers { + go func() { + defer workers.Done() + <-start + results <- client.ensureAuth(context.Background()) + }() + } + close(start) + waitAuthLifecycleSignal(t, loginStarted, "shared login start") + release() + + done := make(chan struct{}) + go func() { + workers.Wait() + close(done) + }() + waitAuthLifecycleSignal(t, done, "all auth waiters") + close(results) + for err := range results { + if err != nil { + t.Errorf("ensureAuth: %v", err) + } + } + if got := loginCalls.Load(); got != 1 { + t.Fatalf("login requests = %d, want 1", got) + } + client.mu.Lock() + token := client.accessToken + client.mu.Unlock() + if token != "shared-token" { + t.Fatalf("access token = %q, want shared-token", token) + } +} + +func TestAuthCallerCancellationDoesNotCancelSharedLogin(t *testing.T) { + loginStarted := make(chan struct{}) + releaseLogin := make(chan struct{}) + var ( + loginCalls atomic.Int32 + startedOnce sync.Once + releaseOnce sync.Once + ) + release := func() { + releaseOnce.Do(func() { close(releaseLogin) }) + } + defer release() + + client := newManualAuthLifecycleClient(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + loginCalls.Add(1) + startedOnce.Do(func() { close(loginStarted) }) + select { + case <-releaseLogin: + return authLifecycleResponse(http.StatusOK, `{"accessToken":"surviving-token","tokenTtl":3600}`), nil + case <-request.Context().Done(): + return nil, request.Context().Err() + } + }), "") + defer client.Close() + + firstCtx, cancelFirst := context.WithCancel(context.Background()) + firstResult := make(chan error, 1) + go func() { + firstResult <- client.ensureAuth(firstCtx) + }() + waitAuthLifecycleSignal(t, loginStarted, "login start") + + secondResult := make(chan error, 1) + go func() { + secondResult <- client.ensureAuth(context.Background()) + }() + cancelFirst() + if err := waitAuthLifecycleError(t, firstResult, "canceled auth caller"); !errors.Is(err, context.Canceled) { + t.Fatalf("first ensureAuth error = %v, want context.Canceled", err) + } + + release() + if err := waitAuthLifecycleError(t, secondResult, "surviving auth caller"); err != nil { + t.Fatalf("second ensureAuth: %v", err) + } + if got := loginCalls.Load(); got != 1 { + t.Fatalf("login requests = %d, want 1", got) + } +} + +func TestAuthClosePreventsLateLoginFromOverwritingReconnect(t *testing.T) { + oldLoginStarted := make(chan struct{}) + releaseOldLogin := make(chan struct{}) + var ( + oldLoginCalls atomic.Int32 + startedOnce sync.Once + releaseOnce sync.Once + ) + release := func() { + releaseOnce.Do(func() { close(releaseOldLogin) }) + } + defer release() + + client := newManualAuthLifecycleClient(t, roundTripFunc(func(*http.Request) (*http.Response, error) { + oldLoginCalls.Add(1) + startedOnce.Do(func() { close(oldLoginStarted) }) + // Intentionally ignore request cancellation to exercise the generation guard. + <-releaseOldLogin + return authLifecycleResponse(http.StatusOK, `{"accessToken":"late-old-token","tokenTtl":3600}`), nil + }), "") + + oldResult := make(chan error, 1) + go func() { + oldResult <- client.ensureAuth(context.Background()) + }() + waitAuthLifecycleSignal(t, oldLoginStarted, "old login start") + if err := client.Close(); err != nil { + t.Fatalf("Close old lifecycle: %v", err) + } + + var newLoginCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/v3/auth/user/login": + newLoginCalls.Add(1) + writeAuthLifecycleJSON(w, map[string]any{ + "accessToken": "new-generation-token", + "tokenTtl": 3600, + }) + case nacosV3ReadinessPath: + writeAuthLifecycleJSON(w, map[string]any{ + "code": 0, + "message": "success", + "data": "ok", + }) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + + config := authLifecycleServerConfig(t, server) + config.User = "new-user" + config.Password = "new-password" + if err := client.Connect(config); err != nil { + t.Fatalf("Connect new lifecycle: %v", err) + } + defer client.Close() + + release() + if err := waitAuthLifecycleError(t, oldResult, "old login completion"); !errors.Is(err, context.Canceled) { + t.Fatalf("old ensureAuth error = %v, want context.Canceled", err) + } + client.mu.Lock() + token := client.accessToken + client.mu.Unlock() + if token != "new-generation-token" { + t.Fatalf("access token after old login completed = %q, want new-generation-token", token) + } + if got := oldLoginCalls.Load(); got != 1 { + t.Fatalf("old login requests = %d, want 1", got) + } + if got := newLoginCalls.Load(); got != 1 { + t.Fatalf("new login requests = %d, want 1", got) + } +} + +func TestLateUnauthorizedDoesNotInvalidateRefreshedToken(t *testing.T) { + tests := []struct { + name string + requestPath string + invoke func(*ClientImpl) error + }{ + { + name: "ordinary request", + requestPath: "/nacos/test-resource", + invoke: func(client *ClientImpl) error { + _, status, err := client.doRequest(context.Background(), http.MethodGet, "/test-resource", nil, nil) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("status = %d, want 200", status) + } + return nil + }, + }, + { + name: "long listener", + requestPath: "/nacos/v1/cs/configs/listener", + invoke: func(client *ClientImpl) error { + _, err := client.ListenOnce(context.Background(), []ConfigListenTarget{{ + DataID: "application.yaml", + Group: "DEFAULT_GROUP", + NamespaceID: "dev", + ContentMD5: ContentMD5("old"), + }}, minListenTimeoutMs) + return err + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + firstOldRelease := make(chan struct{}) + secondOldRelease := make(chan struct{}) + secondOldStarted := make(chan struct{}) + firstNewTokenRequest := make(chan struct{}) + var ( + oldRequests atomic.Int32 + newTokenRequests atomic.Int32 + loginRequests atomic.Int32 + secondStartedOnce sync.Once + newTokenSeenOnce sync.Once + firstReleaseOnce sync.Once + secondReleaseOnce sync.Once + ) + releaseFirst := func() { + firstReleaseOnce.Do(func() { close(firstOldRelease) }) + } + releaseSecond := func() { + secondReleaseOnce.Do(func() { close(secondOldRelease) }) + } + defer releaseFirst() + defer releaseSecond() + + client := newManualAuthLifecycleClient(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case "/nacos/v3/auth/user/login": + loginRequests.Add(1) + return authLifecycleResponse( + http.StatusOK, + `{"accessToken":"token-2","tokenTtl":3600}`, + ), nil + case test.requestPath: + switch request.URL.Query().Get("accessToken") { + case "token-1": + sequence := oldRequests.Add(1) + switch sequence { + case 1: + <-firstOldRelease + case 2: + secondStartedOnce.Do(func() { close(secondOldStarted) }) + <-secondOldRelease + default: + return nil, fmt.Errorf("unexpected old-token request #%d", sequence) + } + return authLifecycleResponse(http.StatusUnauthorized, "expired token"), nil + case "token-2": + newTokenRequests.Add(1) + newTokenSeenOnce.Do(func() { close(firstNewTokenRequest) }) + return authLifecycleResponse(http.StatusOK, ""), nil + default: + return nil, fmt.Errorf( + "unexpected access token %q", + request.URL.Query().Get("accessToken"), + ) + } + default: + return nil, fmt.Errorf("unexpected request: %s", request.URL.Path) + } + }), "token-1") + defer client.Close() + + start := make(chan struct{}) + results := make(chan error, 2) + for range 2 { + go func() { + <-start + results <- test.invoke(client) + }() + } + close(start) + waitAuthLifecycleSignal(t, secondOldStarted, "both old-token requests") + releaseFirst() + waitAuthLifecycleSignal(t, firstNewTokenRequest, "first refreshed-token retry") + releaseSecond() + + for range 2 { + if err := waitAuthLifecycleError(t, results, "request result"); err != nil { + t.Errorf("request failed: %v", err) + } + } + if got := oldRequests.Load(); got != 2 { + t.Fatalf("old-token requests = %d, want 2", got) + } + if got := newTokenRequests.Load(); got != 2 { + t.Fatalf("new-token requests = %d, want 2", got) + } + if got := loginRequests.Load(); got != 1 { + t.Fatalf("login requests = %d, want 1", got) + } + client.mu.Lock() + token := client.accessToken + client.mu.Unlock() + if token != "token-2" { + t.Fatalf("access token = %q, want token-2", token) + } + }) + } +} + +func newManualAuthLifecycleClient( + t *testing.T, + transport http.RoundTripper, + accessToken string, +) *ClientImpl { + t.Helper() + baseURL, err := url.Parse("http://nacos.example.test/nacos") + if err != nil { + t.Fatal(err) + } + expiry := time.Time{} + if accessToken != "" { + expiry = time.Now().Add(time.Hour) + } + return &ClientImpl{ + config: connection.ConnectionConfig{ + Type: "nacos", + User: "nacos", + Password: "nacos-password", + Timeout: 2, + }, + httpClient: &http.Client{Transport: transport}, + baseURL: baseURL, + apiFamily: nacosAPIV1, + accessToken: accessToken, + tokenExpiry: expiry, + } +} + +func authLifecycleServerConfig(t *testing.T, server *httptest.Server) connection.ConnectionConfig { + t.Helper() + parsed, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + t.Fatal(err) + } + return connection.ConnectionConfig{ + Type: "nacos", + Host: parsed.Hostname(), + Port: port, + Timeout: 2, + ConnectionParams: "contextPath=/", + } +} + +func authLifecycleResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func writeAuthLifecycleJSON(w http.ResponseWriter, value any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(value) +} + +func waitAuthLifecycleSignal(t *testing.T, signal <-chan struct{}, label string) { + t.Helper() + select { + case <-signal: + case <-time.After(3 * time.Second): + t.Fatalf("timed out waiting for %s", label) + } +} + +func waitAuthLifecycleError(t *testing.T, result <-chan error, label string) error { + t.Helper() + select { + case err := <-result: + return err + case <-time.After(3 * time.Second): + t.Fatalf("timed out waiting for %s", label) + return nil + } +} diff --git a/internal/nacos/backend_i18n.go b/internal/nacos/backend_i18n.go index 03b47f1d..6b30237d 100644 --- a/internal/nacos/backend_i18n.go +++ b/internal/nacos/backend_i18n.go @@ -2,6 +2,8 @@ package nacos import ( "fmt" + "strconv" + "strings" "sync" "GoNavi-Wails/shared/i18n" @@ -36,6 +38,8 @@ func SetBackendLanguage(language i18n.Language) { } func localizedNacosBackendText(key string, params map[string]any) string { + params = sanitizeNacosBackendDiagnosticParams(params) + nacosBackendTextMu.RLock() if nacosBackendTextLocalizer != nil { text := nacosBackendTextLocalizer.T(key, params) @@ -57,6 +61,37 @@ func localizedNacosBackendText(key string, params map[string]any) string { return nacosBackendTextLocalizer.T(key, params) } +func sanitizeNacosBackendDiagnosticParams(params map[string]any) map[string]any { + if len(params) == 0 { + return params + } + sanitized := make(map[string]any, len(params)) + for key, value := range params { + sanitized[key] = value + } + for _, key := range []string{"body", "detail", "message"} { + if text, ok := sanitized[key].(string); ok { + sanitized[key] = truncateForError(text) + } + } + return sanitized +} + func localizedNacosBackendError(key string, params map[string]any) error { + switch key { + case "nacos.backend.error.http_status": + status, err := strconv.Atoi(strings.TrimSpace(fmt.Sprint(params["status"]))) + if err == nil && status >= 100 && status <= 599 { + return &nacosHTTPError{ + status: status, + body: truncateForError(fmt.Sprint(params["body"])), + } + } + case "nacos.backend.error.config_not_found": + return &nacosConfigNotFoundError{ + group: strings.TrimSpace(fmt.Sprint(params["group"])), + dataID: strings.TrimSpace(fmt.Sprint(params["dataId"])), + } + } return fmt.Errorf("%s", localizedNacosBackendText(key, params)) } diff --git a/internal/nacos/client.go b/internal/nacos/client.go index 8ea6832c..e07a0724 100644 --- a/internal/nacos/client.go +++ b/internal/nacos/client.go @@ -9,14 +9,19 @@ import ( "net" "net/http" "net/url" + "regexp" "sort" "strconv" "strings" "sync" "time" + "unicode/utf8" "GoNavi-Wails/internal/connection" + proxytunnel "GoNavi-Wails/internal/proxy" + "GoNavi-Wails/internal/ssh" "GoNavi-Wails/internal/tlsconfig" + "golang.org/x/sync/singleflight" ) const ( @@ -25,23 +30,100 @@ const ( defaultNacosTimeout = 30 * time.Second defaultConfigPageSize = 20 maxConfigPageSize = 200 - tokenRefreshSkew = 60 * time.Second + maxTokenRefreshSkew = 60 * time.Second ) +var dialNacosProxyContext = proxytunnel.DialContext + +var ( + nacosJSONSecretPattern = regexp.MustCompile( + `(?i)("(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|secret|client[_-]?secret|secret[_-]?key|api[_-]?key|authorization)"\s*:\s*")((?:\\.|[^"\\])*)(")`, + ) + nacosAuthorizationPattern = regexp.MustCompile( + `(?i)(\bauthorization\s*[:=]\s*)(?:bearer|basic)\s+[a-z0-9._~+/%=-]+`, + ) + nacosSecretAssignmentPattern = regexp.MustCompile( + `(?i)(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|passwd|pwd|secret|client[_-]?secret|secret[_-]?key|api[_-]?key|authorization)\s*=\s*)([^&\s"'<>;,]+)`, + ) + nacosBearerPattern = regexp.MustCompile(`(?i)(\bbearer\s+)[a-z0-9._~+/%=-]+`) +) + +type nacosForwarderLease interface { + LocalAddress() string + Release() error +} + +type nacosForwarderAcquirer func(connection.SSHConfig, string, int) (nacosForwarderLease, error) + +type nacosAuthResult struct { + token string + expiry time.Time + refreshAt time.Time +} + +type nacosTokenSnapshot struct { + value string + generation uint64 +} + +type nacosRawResponse struct { + body []byte + status int + usedToken nacosTokenSnapshot +} + +type localForwarderLeaseAdapter struct { + forwarder *ssh.LocalForwarder +} + +func (l *localForwarderLeaseAdapter) LocalAddress() string { + if l == nil || l.forwarder == nil { + return "" + } + return l.forwarder.LocalAddr +} + +func (l *localForwarderLeaseAdapter) Release() error { + if l == nil || l.forwarder == nil { + return nil + } + return l.forwarder.Release() +} + +func acquireNacosForwarder( + sshConfig connection.SSHConfig, + remoteHost string, + remotePort int, +) (nacosForwarderLease, error) { + forwarder, err := ssh.AcquireLocalForwarder(sshConfig, remoteHost, remotePort) + if err != nil { + return nil, err + } + return &localForwarderLeaseAdapter{forwarder: forwarder}, nil +} + // ClientImpl is an HTTP client for the supported Nacos API families. type ClientImpl struct { - mu sync.Mutex - config connection.ConnectionConfig - httpClient *http.Client - baseURL *url.URL - apiFamily nacosAPIFamily - accessToken string - tokenExpiry time.Time + mu sync.Mutex + config connection.ConnectionConfig + httpClient *http.Client + baseURL *url.URL + requestHost string + apiFamily nacosAPIFamily + accessToken string + tokenExpiry time.Time + tokenRefreshAt time.Time + sshForwarder nacosForwarderLease + acquireSSHForwarder nacosForwarderAcquirer + authGroup *singleflight.Group + lifecycleCtx context.Context + lifecycleCancel context.CancelFunc + lifecycleGeneration uint64 } // NewClient creates a new Nacos client instance. func NewClient() Client { - return &ClientImpl{} + return &ClientImpl{acquireSSHForwarder: acquireNacosForwarder} } // Connect prepares the HTTP client and validates reachability. @@ -51,18 +133,60 @@ func (c *ClientImpl) Connect(config connection.ConnectionConfig) error { return err } - httpClient, baseURL, err := buildNacosHTTPClient(normalized) - if err != nil { + if err := c.Close(); err != nil { return err } + var forwarder nacosForwarderLease + dialAddress := "" + if normalized.UseSSH { + acquire := c.acquireSSHForwarder + if acquire == nil { + acquire = acquireNacosForwarder + } + forwarder, err = acquire(normalized.SSH, normalized.Host, normalized.Port) + if err != nil { + return localizedNacosBackendError("nacos.backend.error.ssh_tunnel_create_failed", map[string]any{ + "detail": err.Error(), + }) + } + if forwarder == nil { + return localizedNacosBackendError("nacos.backend.error.ssh_tunnel_create_failed", map[string]any{ + "detail": "forwarder acquisition returned no lease", + }) + } + dialAddress = strings.TrimSpace(forwarder.LocalAddress()) + if dialAddress == "" { + _ = forwarder.Release() + return localizedNacosBackendError("nacos.backend.error.ssh_tunnel_create_failed", map[string]any{ + "detail": "local forward address is empty", + }) + } + } + + httpClient, baseURL, err := buildNacosHTTPClientWithDialAddress(normalized, dialAddress) + if err != nil { + if forwarder != nil { + _ = forwarder.Release() + } + return err + } + lifecycleCtx, lifecycleCancel := context.WithCancel(context.Background()) + c.mu.Lock() + c.lifecycleGeneration++ c.config = normalized c.httpClient = httpClient c.baseURL = baseURL + c.requestHost = net.JoinHostPort(normalized.Host, strconv.Itoa(normalized.Port)) c.apiFamily = nacosAPIUnknown c.accessToken = "" c.tokenExpiry = time.Time{} + c.tokenRefreshAt = time.Time{} + c.sshForwarder = forwarder + c.authGroup = &singleflight.Group{} + c.lifecycleCtx = lifecycleCtx + c.lifecycleCancel = lifecycleCancel c.mu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), normalizeNacosTimeout(normalized.Timeout)) @@ -85,21 +209,38 @@ func (c *ClientImpl) Connect(config connection.ConnectionConfig) error { // Close releases client resources. func (c *ClientImpl) Close() error { c.mu.Lock() - defer c.mu.Unlock() + httpClient := c.httpClient + forwarder := c.sshForwarder + lifecycleCancel := c.lifecycleCancel + c.lifecycleGeneration++ + c.config = connection.ConnectionConfig{} c.httpClient = nil c.baseURL = nil + c.requestHost = "" c.apiFamily = nacosAPIUnknown c.accessToken = "" c.tokenExpiry = time.Time{} + c.tokenRefreshAt = time.Time{} + c.sshForwarder = nil + c.authGroup = nil + c.lifecycleCtx = nil + c.lifecycleCancel = nil + c.mu.Unlock() + if lifecycleCancel != nil { + lifecycleCancel() + } + if httpClient != nil { + httpClient.CloseIdleConnections() + } + if forwarder != nil { + return forwarder.Release() + } return nil } -// Ping checks server reachability via namespace list (works with/without auth). +// Ping checks server reachability without requiring namespace administrator access. func (c *ClientImpl) Ping(ctx context.Context) error { - if _, err := c.ListNamespaces(ctx); err != nil { - return err - } - return nil + return c.probeReadiness(ctx, c.currentAPIFamily()) } // ListNamespaces returns all namespaces including public. @@ -109,10 +250,7 @@ func (c *ClientImpl) ListNamespaces(ctx context.Context) ([]Namespace, error) { return nil, err } if status < 200 || status >= 300 { - return nil, localizedNacosBackendError("nacos.backend.error.http_status", map[string]any{ - "status": status, - "body": truncateForError(string(body)), - }) + return nil, nacosHTTPStatusError(status, body) } data, err := unwrapNacosResult(body) @@ -992,23 +1130,95 @@ func stringifyAnyID(value any) string { } func (c *ClientImpl) ensureAuth(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + c.mu.Lock() username := strings.TrimSpace(c.config.User) - password := c.config.Password - needLogin := username != "" - tokenValid := c.accessToken != "" && time.Now().Before(c.tokenExpiry.Add(-tokenRefreshSkew)) + if username == "" { + c.mu.Unlock() + return nil + } + c.ensureAuthLifecycleLocked() + if c.accessTokenValidLocked(time.Now()) { + c.mu.Unlock() + return nil + } + authGroup := c.authGroup + lifecycleCtx := c.lifecycleCtx + generation := c.lifecycleGeneration c.mu.Unlock() - if !needLogin { - return nil + resultCh := authGroup.DoChan("login", func() (any, error) { + c.mu.Lock() + if c.lifecycleGeneration != generation || c.lifecycleCtx == nil || c.httpClient == nil { + c.mu.Unlock() + return nil, context.Canceled + } + if c.accessTokenValidLocked(time.Now()) { + c.mu.Unlock() + return nil, nil + } + loginUser := strings.TrimSpace(c.config.User) + loginPassword := c.config.Password + // Cached clients may be shared by operations with different deadlines. + // The caller context still controls how long that caller waits, while + // the shared login uses a stable lifecycle timeout so the first + // connection's short operation timeout cannot poison later refreshes. + loginTimeout := defaultNacosTimeout + c.mu.Unlock() + + loginCtx, cancel := context.WithTimeout(lifecycleCtx, loginTimeout) + defer cancel() + authResult, err := c.login(loginCtx, loginUser, loginPassword) + if err != nil { + return nil, err + } + + c.mu.Lock() + defer c.mu.Unlock() + if c.lifecycleGeneration != generation || c.lifecycleCtx == nil || + c.httpClient == nil || lifecycleCtx.Err() != nil { + return nil, context.Canceled + } + c.accessToken = authResult.token + c.tokenExpiry = authResult.expiry + c.tokenRefreshAt = authResult.refreshAt + return nil, nil + }) + + select { + case <-ctx.Done(): + return ctx.Err() + case result := <-resultCh: + return result.Err } - if tokenValid { - return nil - } - return c.login(ctx, username, password) } -func (c *ClientImpl) login(ctx context.Context, username, password string) error { +func (c *ClientImpl) ensureAuthLifecycleLocked() { + if c.authGroup != nil && c.lifecycleCtx != nil { + return + } + lifecycleCtx, lifecycleCancel := context.WithCancel(context.Background()) + c.lifecycleGeneration++ + c.authGroup = &singleflight.Group{} + c.lifecycleCtx = lifecycleCtx + c.lifecycleCancel = lifecycleCancel +} + +func (c *ClientImpl) accessTokenValidLocked(now time.Time) bool { + if c.accessToken == "" { + return false + } + refreshAt := c.tokenRefreshAt + if refreshAt.IsZero() { + refreshAt = c.tokenExpiry.Add(-maxTokenRefreshSkew) + } + return now.Before(refreshAt) +} + +func (c *ClientImpl) login(ctx context.Context, username, password string) (nacosAuthResult, error) { query := url.Values{} query.Set("username", username) form := url.Values{} @@ -1023,7 +1233,7 @@ func (c *ClientImpl) login(ctx context.Context, username, password string) error for index, loginPath := range loginPaths { body, status, err = c.doRequestRaw(ctx, http.MethodPost, loginPath, query, form, false) if err != nil { - return err + return nacosAuthResult{}, err } if status >= 200 && status < 300 { break @@ -1031,7 +1241,7 @@ func (c *ClientImpl) login(ctx context.Context, username, password string) error if index < len(loginPaths)-1 && isUnsupportedNacosLogin(status) { continue } - return localizedNacosBackendError("nacos.backend.error.login_failed", map[string]any{ + return nacosAuthResult{}, localizedNacosBackendError("nacos.backend.error.login_failed", map[string]any{ "status": status, "body": truncateForError(string(body)), }) @@ -1042,24 +1252,31 @@ func (c *ClientImpl) login(ctx context.Context, username, password string) error TokenTtl int64 `json:"tokenTtl"` } if err := json.Unmarshal(body, &payload); err != nil { - return localizedNacosBackendError("nacos.backend.error.login_parse", map[string]any{ + return nacosAuthResult{}, localizedNacosBackendError("nacos.backend.error.login_parse", map[string]any{ "detail": err.Error(), }) } token := strings.TrimSpace(payload.AccessToken) if token == "" { - return localizedNacosBackendError("nacos.backend.error.login_empty_token", nil) + return nacosAuthResult{}, localizedNacosBackendError("nacos.backend.error.login_empty_token", nil) } ttl := payload.TokenTtl if ttl <= 0 { ttl = 18000 } + issuedAt := time.Now() + ttlDuration := time.Duration(ttl) * time.Second + refreshSkew := ttlDuration / 10 + if refreshSkew > maxTokenRefreshSkew { + refreshSkew = maxTokenRefreshSkew + } + expiry := issuedAt.Add(ttlDuration) - c.mu.Lock() - c.accessToken = token - c.tokenExpiry = time.Now().Add(time.Duration(ttl) * time.Second) - c.mu.Unlock() - return nil + return nacosAuthResult{ + token: token, + expiry: expiry, + refreshAt: expiry.Add(-refreshSkew), + }, nil } func isUnsupportedNacosLogin(status int) bool { @@ -1082,22 +1299,49 @@ func (c *ClientImpl) doRequestWithHeaders( if err := c.ensureAuth(ctx); err != nil { return nil, 0, err } - body, status, err := c.doRequestRawWithHeaders(ctx, method, apiPath, query, form, headers, true) + response, err := c.doRequestRawWithHeadersResult(ctx, method, apiPath, query, form, headers, true) if err != nil { - return nil, status, err + return nil, response.status, err } - // Token expired mid-flight: force re-login once. - if status == http.StatusForbidden || status == http.StatusUnauthorized { - c.mu.Lock() + if response.status == http.StatusForbidden || response.status == http.StatusUnauthorized { + retry, authErr := c.reauthenticateAfterUnauthorized(ctx, response.usedToken) + if authErr != nil { + return nil, response.status, authErr + } + if retry { + response, err = c.doRequestRawWithHeadersResult(ctx, method, apiPath, query, form, headers, true) + if err != nil { + return nil, response.status, err + } + } + } + return response.body, response.status, nil +} + +func (c *ClientImpl) reauthenticateAfterUnauthorized( + ctx context.Context, + usedToken nacosTokenSnapshot, +) (bool, error) { + if strings.TrimSpace(usedToken.value) == "" { + return false, nil + } + + c.mu.Lock() + if c.lifecycleGeneration != usedToken.generation { + c.mu.Unlock() + return false, nil + } + if c.accessToken == usedToken.value { c.accessToken = "" c.tokenExpiry = time.Time{} - c.mu.Unlock() - if err := c.ensureAuth(ctx); err != nil { - return nil, status, err - } - return c.doRequestRawWithHeaders(ctx, method, apiPath, query, form, headers, true) + c.tokenRefreshAt = time.Time{} } - return body, status, nil + c.mu.Unlock() + + if err := c.ensureAuth(ctx); err != nil { + return false, err + } + return true, nil } func (c *ClientImpl) doRequestRaw( @@ -1118,14 +1362,34 @@ func (c *ClientImpl) doRequestRawWithHeaders( headers http.Header, withToken bool, ) ([]byte, int, error) { + response, err := c.doRequestRawWithHeadersResult(ctx, method, apiPath, query, form, headers, withToken) + return response.body, response.status, err +} + +func (c *ClientImpl) doRequestRawWithHeadersResult( + ctx context.Context, + method, apiPath string, + query url.Values, + form url.Values, + headers http.Header, + withToken bool, +) (nacosRawResponse, error) { c.mu.Lock() httpClient := c.httpClient baseURL := c.baseURL + requestHost := c.requestHost token := c.accessToken + generation := c.lifecycleGeneration c.mu.Unlock() + result := nacosRawResponse{ + usedToken: nacosTokenSnapshot{ + value: token, + generation: generation, + }, + } if httpClient == nil || baseURL == nil { - return nil, 0, localizedNacosBackendError("nacos.backend.error.not_connected", nil) + return result, localizedNacosBackendError("nacos.backend.error.not_connected", nil) } rel := &url.URL{Path: joinAPIPath(baseURL.Path, apiPath)} @@ -1147,10 +1411,13 @@ func (c *ClientImpl) doRequestRawWithHeaders( req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) if err != nil { - return nil, 0, localizedNacosBackendError("nacos.backend.error.build_request", map[string]any{ + return result, localizedNacosBackendError("nacos.backend.error.build_request", map[string]any{ "detail": err.Error(), }) } + if requestHost != "" { + req.Host = requestHost + } if contentType != "" { req.Header.Set("Content-Type", contentType) } @@ -1163,19 +1430,37 @@ func (c *ClientImpl) doRequestRawWithHeaders( resp, err := httpClient.Do(req) if err != nil { - return nil, 0, localizedNacosBackendError("nacos.backend.error.request_failed", map[string]any{ - "detail": err.Error(), + return result, localizedNacosBackendError("nacos.backend.error.request_failed", map[string]any{ + "detail": redactNacosAccessToken(err.Error(), token), }) } defer resp.Body.Close() + result.status = resp.StatusCode body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) if err != nil { - return nil, resp.StatusCode, localizedNacosBackendError("nacos.backend.error.read_body", map[string]any{ + return result, localizedNacosBackendError("nacos.backend.error.read_body", map[string]any{ "detail": err.Error(), }) } - return body, resp.StatusCode, nil + result.body = body + return result, nil +} + +func redactNacosAccessToken(detail, token string) string { + detail = redactNacosErrorText(detail) + if strings.TrimSpace(token) == "" { + return detail + } + redacted := strings.ReplaceAll(detail, url.QueryEscape(token), "[REDACTED]") + return strings.ReplaceAll(redacted, token, "[REDACTED]") +} + +func redactNacosErrorText(text string) string { + redacted := nacosJSONSecretPattern.ReplaceAllString(text, `${1}[REDACTED]${3}`) + redacted = nacosAuthorizationPattern.ReplaceAllString(redacted, `${1}[REDACTED]`) + redacted = nacosSecretAssignmentPattern.ReplaceAllString(redacted, `${1}[REDACTED]`) + return nacosBearerPattern.ReplaceAllString(redacted, `${1}[REDACTED]`) } func normalizeNacosConfig(config connection.ConnectionConfig) (connection.ConnectionConfig, error) { @@ -1195,6 +1480,13 @@ func normalizeNacosConfig(config connection.ConnectionConfig) (connection.Connec } func buildNacosHTTPClient(config connection.ConnectionConfig) (*http.Client, *url.URL, error) { + return buildNacosHTTPClientWithDialAddress(config, "") +} + +func buildNacosHTTPClientWithDialAddress( + config connection.ConnectionConfig, + dialAddress string, +) (*http.Client, *url.URL, error) { scheme := "http" if config.UseSSL || strings.EqualFold(strings.TrimSpace(config.SSLMode), "required") || strings.EqualFold(strings.TrimSpace(config.SSLMode), "preferred") { @@ -1210,39 +1502,35 @@ func buildNacosHTTPClient(config connection.ConnectionConfig) (*http.Client, *ur } base.Path = contextPath + dialer := &net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + } + dialContext := dialer.DialContext + if dialTarget := strings.TrimSpace(dialAddress); dialTarget != "" { + dialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { + return dialer.DialContext(ctx, network, dialTarget) + } + } else if config.UseProxy { + proxyConfig := config.Proxy + dialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + return dialNacosProxyContext(ctx, proxyConfig, network, address) + } + } + transport := &http.Transport{ - Proxy: http.ProxyFromEnvironment, - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, - KeepAlive: 30 * time.Second, - }).DialContext, + Proxy: http.ProxyFromEnvironment, + DialContext: dialContext, ForceAttemptHTTP2: true, MaxIdleConns: 32, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, } - - if config.UseProxy && strings.EqualFold(strings.TrimSpace(config.Proxy.Type), "http") { - proxyHost := strings.TrimSpace(config.Proxy.Host) - if proxyHost != "" { - proxyPort := config.Proxy.Port - if proxyPort <= 0 { - proxyPort = 8080 - } - proxyURL := &url.URL{ - Scheme: "http", - Host: net.JoinHostPort(proxyHost, strconv.Itoa(proxyPort)), - } - if user := strings.TrimSpace(config.Proxy.User); user != "" { - if pass := config.Proxy.Password; pass != "" { - proxyURL.User = url.UserPassword(user, pass) - } else { - proxyURL.User = url.User(user) - } - } - transport.Proxy = http.ProxyURL(proxyURL) - } + if strings.TrimSpace(dialAddress) != "" || config.UseProxy { + // The explicit network hop is already handled by DialContext. Applying + // an environment/http.Transport proxy as well would double-proxy it. + transport.Proxy = nil } if scheme == "https" { @@ -1261,17 +1549,26 @@ func buildNacosHTTPClient(config connection.ConnectionConfig) (*http.Client, *ur }) } if tlsCfg != nil { + if strings.TrimSpace(dialAddress) != "" { + tlsCfg.ServerName = strings.Trim(strings.TrimSpace(config.Host), "[]") + } transport.TLSClientConfig = tlsCfg } else { transport.TLSClientConfig = &tls.Config{ MinVersion: tls.VersionTLS12, InsecureSkipVerify: insecure, //nolint:gosec } + if strings.TrimSpace(dialAddress) != "" { + transport.TLSClientConfig.ServerName = strings.Trim(strings.TrimSpace(config.Host), "[]") + } } } client := &http.Client{ - Timeout: normalizeNacosTimeout(config.Timeout), + // Request deadlines are supplied by the caller context. Keeping this + // unset prevents a cached client from retaining the first connection's + // timeout for later operations. + Timeout: 0, Transport: transport, } return client, base, nil @@ -1358,11 +1655,15 @@ func parseSimpleKV(raw string) map[string]string { func truncateForError(text string) string { const max = 400 - trimmed := strings.TrimSpace(text) + trimmed := strings.TrimSpace(redactNacosErrorText(text)) if len(trimmed) <= max { return trimmed } - return trimmed[:max] + "..." + cutoff := max + for cutoff > 0 && !utf8.RuneStart(trimmed[cutoff]) { + cutoff-- + } + return trimmed[:cutoff] + "..." } func firstNonEmpty(values ...string) string { diff --git a/internal/nacos/client_test.go b/internal/nacos/client_test.go index 86717628..3c449dfb 100644 --- a/internal/nacos/client_test.go +++ b/internal/nacos/client_test.go @@ -2,18 +2,56 @@ package nacos import ( "context" + "crypto/tls" "encoding/json" + "errors" "io" + "net" "net/http" "net/http/httptest" "net/url" + "reflect" "strconv" "strings" + "sync/atomic" "testing" + "time" "GoNavi-Wails/internal/connection" ) +type closeIdleTrackingTransport struct { + closed bool +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +type fakeNacosForwarderLease struct { + address string + releases atomic.Int32 +} + +func (f *fakeNacosForwarderLease) LocalAddress() string { + return f.address +} + +func (f *fakeNacosForwarderLease) Release() error { + f.releases.Add(1) + return nil +} + +func (roundTrip roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return roundTrip(request) +} + +func (t *closeIdleTrackingTransport) RoundTrip(*http.Request) (*http.Response, error) { + panic("unexpected request") +} + +func (t *closeIdleTrackingTransport) CloseIdleConnections() { + t.closed = true +} + func TestNormalizeNamespaceID(t *testing.T) { t.Parallel() if got := normalizeNamespaceID("public"); got != "" { @@ -36,6 +74,455 @@ func TestResolveNacosContextPath(t *testing.T) { } } +func TestClientCloseClosesIdleHTTPConnections(t *testing.T) { + transport := &closeIdleTrackingTransport{} + client := &ClientImpl{ + config: connection.ConnectionConfig{ + User: "nacos-user", + Password: "nacos-password", + SSH: connection.SSHConfig{ + User: "ssh-user", + Password: "ssh-password", + }, + Proxy: connection.ProxyConfig{ + User: "proxy-user", + Password: "proxy-password", + }, + }, + httpClient: &http.Client{Transport: transport}, + } + + if err := client.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if !transport.closed { + t.Fatal("Close did not release idle HTTP connections") + } + if !reflect.DeepEqual(client.config, connection.ConnectionConfig{}) { + t.Fatalf("Close retained connection config: %#v", client.config) + } +} + +func TestClientSSHForwarderUsesRemoteTargetAndHostForRequests(t *testing.T) { + const ( + remoteHost = "nacos.internal.test" + remotePort = 8848 + remoteAuthority = "nacos.internal.test:8848" + ) + var ( + ordinaryRequests atomic.Int32 + listenRequests atomic.Int32 + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Host != remoteAuthority { + t.Errorf("request Host = %q, want %q", request.Host, remoteAuthority) + } + switch { + case strings.HasSuffix(request.URL.Path, nacosV3ReadinessPath), + strings.HasSuffix(request.URL.Path, "/v2/console/namespace/list"): + http.NotFound(w, request) + case strings.HasSuffix(request.URL.Path, "/v1/console/namespaces"): + ordinaryRequests.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 200, + "data": []any{}, + }) + case strings.HasSuffix(request.URL.Path, "/v1/cs/configs/listener"): + listenRequests.Add(1) + w.WriteHeader(http.StatusOK) + default: + http.NotFound(w, request) + } + })) + defer server.Close() + + lease := &fakeNacosForwarderLease{address: server.Listener.Addr().String()} + var ( + acquiredHost string + acquiredPort int + acquiredSSH connection.SSHConfig + ) + client := &ClientImpl{ + acquireSSHForwarder: func( + sshConfig connection.SSHConfig, + host string, + port int, + ) (nacosForwarderLease, error) { + acquiredSSH = sshConfig + acquiredHost = host + acquiredPort = port + return lease, nil + }, + } + sshConfig := connection.SSHConfig{ + Host: "jump.internal.test", + Port: 22, + User: "nacos-user", + Password: "secret", + } + if err := client.Connect(connection.ConnectionConfig{ + Type: "nacos", + Host: remoteHost, + Port: remotePort, + UseSSH: true, + SSH: sshConfig, + Timeout: 2, + ConnectionParams: "contextPath=/", + }); err != nil { + t.Fatalf("Connect: %v", err) + } + if acquiredHost != remoteHost || acquiredPort != remotePort { + t.Fatalf("SSH target = %s:%d, want %s:%d", acquiredHost, acquiredPort, remoteHost, remotePort) + } + if acquiredSSH != sshConfig { + t.Fatalf("SSH config = %#v, want %#v", acquiredSSH, sshConfig) + } + + if _, err := client.ListenOnce(context.Background(), []ConfigListenTarget{{ + DataID: "application.yaml", + Group: "DEFAULT_GROUP", + ContentMD5: ContentMD5(""), + }}, minListenTimeoutMs); err != nil { + t.Fatalf("ListenOnce: %v", err) + } + if ordinaryRequests.Load() < 2 { + t.Fatalf("ordinary Nacos requests = %d, want at least 2", ordinaryRequests.Load()) + } + if listenRequests.Load() != 1 { + t.Fatalf("listener requests = %d, want 1", listenRequests.Load()) + } + + if err := client.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if err := client.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } + if got := lease.releases.Load(); got != 1 { + t.Fatalf("forwarder Release calls = %d, want exactly 1", got) + } +} + +func TestClientSSHForwarderPreservesRemoteTLSServerName(t *testing.T) { + const ( + remoteHost = "secure-nacos.internal.test" + remotePort = 8848 + remoteAuthority = "secure-nacos.internal.test:8848" + ) + sniValues := make(chan string, 4) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.Host != remoteAuthority { + t.Errorf("request Host = %q, want %q", request.Host, remoteAuthority) + } + switch { + case strings.HasSuffix(request.URL.Path, nacosV3ReadinessPath), + strings.HasSuffix(request.URL.Path, "/v2/console/namespace/list"): + http.NotFound(w, request) + case strings.HasSuffix(request.URL.Path, "/v1/console/namespaces"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 200, + "data": []any{}, + }) + default: + http.NotFound(w, request) + } + })) + server.TLS = &tls.Config{ + MinVersion: tls.VersionTLS12, + GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + select { + case sniValues <- hello.ServerName: + default: + } + return nil, nil + }, + } + server.StartTLS() + defer server.Close() + + lease := &fakeNacosForwarderLease{address: server.Listener.Addr().String()} + client := &ClientImpl{ + acquireSSHForwarder: func( + connection.SSHConfig, + string, + int, + ) (nacosForwarderLease, error) { + return lease, nil + }, + } + if err := client.Connect(connection.ConnectionConfig{ + Type: "nacos", + Host: remoteHost, + Port: remotePort, + UseSSL: true, + SSLMode: "skip-verify", + UseSSH: true, + Timeout: 2, + ConnectionParams: "contextPath=/", + }); err != nil { + t.Fatalf("Connect: %v", err) + } + defer client.Close() + + select { + case got := <-sniValues: + if got != remoteHost { + t.Fatalf("TLS ServerName = %q, want %q", got, remoteHost) + } + default: + t.Fatal("TLS handshake did not report a ServerName") + } +} + +func TestClientProxyPreservesRemoteAuthorityAndTLSServerName(t *testing.T) { + const ( + remoteHost = "secure-nacos.internal.test" + remotePort = 8848 + remoteAuthority = "secure-nacos.internal.test:8848" + ) + sniValues := make(chan string, 8) + hostValues := make(chan string, 16) + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + select { + case hostValues <- request.Host: + default: + } + switch { + case strings.HasSuffix(request.URL.Path, nacosV3ReadinessPath), + strings.HasSuffix(request.URL.Path, "/v2/console/namespace/list"): + http.NotFound(w, request) + case strings.HasSuffix(request.URL.Path, "/v1/console/namespaces"): + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 200, + "data": []any{}, + }) + default: + http.NotFound(w, request) + } + })) + server.TLS = &tls.Config{ + MinVersion: tls.VersionTLS12, + GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + select { + case sniValues <- hello.ServerName: + default: + } + return nil, nil + }, + } + server.StartTLS() + defer server.Close() + + originalDialNacosProxyContext := dialNacosProxyContext + t.Cleanup(func() { + dialNacosProxyContext = originalDialNacosProxyContext + }) + + for _, proxyType := range []string{"http", "socks5"} { + t.Run(proxyType, func(t *testing.T) { + dialTargets := make(chan string, 4) + dialProxyTypes := make(chan string, 4) + dialNacosProxyContext = func( + ctx context.Context, + proxyConfig connection.ProxyConfig, + network string, + address string, + ) (net.Conn, error) { + select { + case dialTargets <- address: + default: + } + select { + case dialProxyTypes <- proxyConfig.Type: + default: + } + var dialer net.Dialer + return dialer.DialContext(ctx, network, server.Listener.Addr().String()) + } + + client := &ClientImpl{} + if err := client.Connect(connection.ConnectionConfig{ + Type: "nacos", + Host: remoteHost, + Port: remotePort, + UseSSL: true, + SSLMode: "skip-verify", + UseProxy: true, + Proxy: connection.ProxyConfig{Type: proxyType, Host: "proxy.invalid", Port: 1080}, + Timeout: 2, + ConnectionParams: "contextPath=/", + }); err != nil { + t.Fatalf("Connect through %s proxy: %v", proxyType, err) + } + defer client.Close() + + select { + case got := <-dialTargets: + if got != remoteAuthority { + t.Fatalf("proxy dial target = %q, want %q", got, remoteAuthority) + } + default: + t.Fatal("proxy dial hook was not called") + } + select { + case got := <-dialProxyTypes: + if got != proxyType { + t.Fatalf("proxy dial type = %q, want %q", got, proxyType) + } + default: + t.Fatal("proxy dial hook did not receive proxy config") + } + select { + case got := <-sniValues: + if got != remoteHost { + t.Fatalf("TLS ServerName = %q, want %q", got, remoteHost) + } + default: + t.Fatal("TLS handshake did not report a ServerName") + } + select { + case got := <-hostValues: + if got != remoteAuthority { + t.Fatalf("request Host = %q, want %q", got, remoteAuthority) + } + default: + t.Fatal("server did not receive a request") + } + }) + } +} + +func TestClientSSHForwarderReleasedWhenConnectFails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "probe failed", http.StatusInternalServerError) + })) + defer server.Close() + + lease := &fakeNacosForwarderLease{address: server.Listener.Addr().String()} + client := &ClientImpl{ + acquireSSHForwarder: func( + connection.SSHConfig, + string, + int, + ) (nacosForwarderLease, error) { + return lease, nil + }, + } + err := client.Connect(connection.ConnectionConfig{ + Type: "nacos", + Host: "nacos.internal.test", + Port: 8848, + UseSSH: true, + Timeout: 2, + ConnectionParams: "contextPath=/", + }) + if err == nil { + t.Fatal("expected Connect to fail") + } + if got := lease.releases.Load(); got != 1 { + t.Fatalf("forwarder Release calls after failed Connect = %d, want exactly 1", got) + } + if closeErr := client.Close(); closeErr != nil { + t.Fatalf("Close after failed Connect: %v", closeErr) + } + if got := lease.releases.Load(); got != 1 { + t.Fatalf("forwarder Release calls after failed Connect cleanup = %d, want exactly 1", got) + } +} + +func TestClientSSHForwarderRejectsNilLease(t *testing.T) { + client := &ClientImpl{ + acquireSSHForwarder: func( + connection.SSHConfig, + string, + int, + ) (nacosForwarderLease, error) { + return nil, nil + }, + } + err := client.Connect(connection.ConnectionConfig{ + Type: "nacos", + Host: "nacos.internal.test", + Port: 8848, + UseSSH: true, + ConnectionParams: "contextPath=/", + }) + if err == nil { + t.Fatal("expected nil SSH forwarder lease to be rejected") + } +} + +func TestNacosHTTPClientReliesOnRequestContextDeadline(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { + <-request.Context().Done() + })) + defer server.Close() + + parsed, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + t.Fatal(err) + } + httpClient, baseURL, err := buildNacosHTTPClient(connection.ConnectionConfig{ + Type: "nacos", + Host: parsed.Hostname(), + Port: port, + Timeout: 1, + ConnectionParams: "contextPath=/", + }) + if err != nil { + t.Fatalf("buildNacosHTTPClient: %v", err) + } + if httpClient.Timeout != 0 { + t.Fatalf("http client timeout = %s, want 0", httpClient.Timeout) + } + + client := &ClientImpl{ + httpClient: httpClient, + baseURL: baseURL, + } + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + _, _, err = client.doRequestRaw(ctx, http.MethodGet, "/slow", nil, nil, false) + if err == nil { + t.Fatal("expected context deadline error") + } + if !strings.Contains(err.Error(), context.DeadlineExceeded.Error()) { + t.Fatalf("request error = %q, want context deadline exceeded", err) + } +} + +func TestNacosRequestErrorRedactsAccessToken(t *testing.T) { + const accessToken = "request-error-secret+/= token" + baseURL, err := url.Parse("http://nacos.example.test/nacos") + if err != nil { + t.Fatal(err) + } + client := &ClientImpl{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("dial failed") + }), + }, + baseURL: baseURL, + accessToken: accessToken, + } + + _, _, err = client.doRequestRaw(context.Background(), http.MethodGet, "/v1/console/namespaces", nil, nil, true) + if err == nil { + t.Fatal("expected request failure") + } + if strings.Contains(err.Error(), accessToken) { + t.Fatalf("request error exposed access token: %q", err) + } + if strings.Contains(err.Error(), url.QueryEscape(accessToken)) { + t.Fatalf("request error exposed encoded access token: %q", err) + } +} + func TestClientConfigFlow(t *testing.T) { var ( gotLoginUser string diff --git a/internal/nacos/listen.go b/internal/nacos/listen.go index b9db4777..24cfa2af 100644 --- a/internal/nacos/listen.go +++ b/internal/nacos/listen.go @@ -80,7 +80,7 @@ func (c *ClientImpl) ListenOnce(ctx context.Context, targets []ConfigListenTarge listenCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMs+10000)*time.Millisecond) defer cancel() - body, status, err := c.doListenRequest(listenCtx, form, timeoutMs) + response, err := c.doListenRequestResult(listenCtx, form, timeoutMs) if err != nil { // Context cancel/deadline is expected when stopping listeners. if listenCtx.Err() != nil && (ctx.Err() != nil || listenCtx.Err() == context.DeadlineExceeded) { @@ -92,30 +92,32 @@ func (c *ClientImpl) ListenOnce(ctx context.Context, targets []ConfigListenTarge } return nil, err } - if status == http.StatusForbidden || status == http.StatusUnauthorized { - c.mu.Lock() - c.accessToken = "" - c.tokenExpiry = time.Time{} - c.mu.Unlock() - if err := c.ensureAuth(ctx); err != nil { - return nil, err + if response.status == http.StatusForbidden || response.status == http.StatusUnauthorized { + retry, authErr := c.reauthenticateAfterUnauthorized(ctx, response.usedToken) + if authErr != nil { + return nil, authErr } - body, status, err = c.doListenRequest(listenCtx, form, timeoutMs) - if err != nil { - if ctx.Err() != nil { - return nil, ctx.Err() + if retry { + response, err = c.doListenRequestResult(listenCtx, form, timeoutMs) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if listenCtx.Err() == context.DeadlineExceeded { + return []ConfigListenTarget{}, nil + } + return nil, err } - return []ConfigListenTarget{}, nil } } - if status < 200 || status >= 300 { + if response.status < 200 || response.status >= 300 { return nil, localizedNacosBackendError("nacos.backend.error.http_status", map[string]any{ - "status": status, - "body": truncateForError(string(body)), + "status": response.status, + "body": truncateForError(string(response.body)), }) } - changed := parseListenResponse(string(body)) + changed := parseListenResponse(string(response.body)) if len(changed) == 0 { return []ConfigListenTarget{}, nil } @@ -123,14 +125,31 @@ func (c *ClientImpl) ListenOnce(ctx context.Context, targets []ConfigListenTarge } func (c *ClientImpl) doListenRequest(ctx context.Context, form url.Values, timeoutMs int) ([]byte, int, error) { + response, err := c.doListenRequestResult(ctx, form, timeoutMs) + return response.body, response.status, err +} + +func (c *ClientImpl) doListenRequestResult( + ctx context.Context, + form url.Values, + timeoutMs int, +) (nacosRawResponse, error) { c.mu.Lock() baseClient := c.httpClient baseURL := c.baseURL + requestHost := c.requestHost token := c.accessToken + generation := c.lifecycleGeneration c.mu.Unlock() + result := nacosRawResponse{ + usedToken: nacosTokenSnapshot{ + value: token, + generation: generation, + }, + } if baseClient == nil || baseURL == nil { - return nil, 0, localizedNacosBackendError("nacos.backend.error.not_connected", nil) + return result, localizedNacosBackendError("nacos.backend.error.not_connected", nil) } // Avoid inherited short Timeout from the shared client. @@ -150,29 +169,34 @@ func (c *ClientImpl) doListenRequest(ctx context.Context, form url.Values, timeo req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, strings.NewReader(form.Encode())) if err != nil { - return nil, 0, localizedNacosBackendError("nacos.backend.error.build_request", map[string]any{ + return result, localizedNacosBackendError("nacos.backend.error.build_request", map[string]any{ "detail": err.Error(), }) } + if requestHost != "" { + req.Host = requestHost + } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Long-Pulling-Timeout", strconv.Itoa(timeoutMs)) req.Header.Set("Accept", "*/*") resp, err := listenClient.Do(req) if err != nil { - return nil, 0, localizedNacosBackendError("nacos.backend.error.request_failed", map[string]any{ - "detail": err.Error(), + return result, localizedNacosBackendError("nacos.backend.error.request_failed", map[string]any{ + "detail": redactNacosAccessToken(err.Error(), token), }) } defer resp.Body.Close() + result.status = resp.StatusCode body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { - return nil, resp.StatusCode, localizedNacosBackendError("nacos.backend.error.read_body", map[string]any{ + return result, localizedNacosBackendError("nacos.backend.error.read_body", map[string]any{ "detail": err.Error(), }) } - return body, resp.StatusCode, nil + result.body = body + return result, nil } func parseListenResponse(raw string) []ConfigListenTarget { diff --git a/internal/nacos/listen_test.go b/internal/nacos/listen_test.go index 5b0ee778..072a25e4 100644 --- a/internal/nacos/listen_test.go +++ b/internal/nacos/listen_test.go @@ -2,6 +2,7 @@ package nacos import ( "context" + "errors" "io" "net/http" "net/http/httptest" @@ -39,6 +40,103 @@ func TestParseListenResponse(t *testing.T) { } } +func TestNacosListenRequestErrorRedactsAccessToken(t *testing.T) { + const accessToken = "listen-error-secret+/= token" + baseURL, err := url.Parse("http://nacos.example.test/nacos") + if err != nil { + t.Fatal(err) + } + client := &ClientImpl{ + httpClient: &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("dial failed") + }), + }, + baseURL: baseURL, + accessToken: accessToken, + } + + _, _, err = client.doListenRequest(context.Background(), url.Values{ + "Listening-Configs": {"app.yaml"}, + }, defaultListenTimeoutMs) + if err == nil { + t.Fatal("expected listen request failure") + } + if strings.Contains(err.Error(), accessToken) { + t.Fatalf("listen request error exposed access token: %q", err) + } + if strings.Contains(err.Error(), url.QueryEscape(accessToken)) { + t.Fatalf("listen request error exposed encoded access token: %q", err) + } +} + +func TestListenOnceReturnsTransportErrorAfterReauthentication(t *testing.T) { + const ( + oldToken = "old-token" + newToken = "new-token" + ) + baseURL, err := url.Parse("http://nacos.example.test/nacos") + if err != nil { + t.Fatal(err) + } + listenRequests := 0 + loginRequests := 0 + client := &ClientImpl{ + config: connection.ConnectionConfig{ + Type: "nacos", + User: "nacos", + Password: "nacos-password", + }, + httpClient: &http.Client{ + Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + switch { + case strings.HasSuffix(request.URL.Path, "/v1/cs/configs/listener"): + listenRequests++ + if listenRequests == 1 { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("unauthorized")), + }, nil + } + return nil, errors.New("retry transport failed") + case strings.HasSuffix(request.URL.Path, "/v3/auth/user/login"): + loginRequests++ + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + `{"accessToken":"` + newToken + `","tokenTtl":3600}`, + )), + }, nil + default: + return nil, errors.New("unexpected request: " + request.URL.Path) + } + }), + }, + baseURL: baseURL, + apiFamily: nacosAPIV1, + accessToken: oldToken, + tokenExpiry: time.Now().Add(time.Hour), + } + + _, err = client.ListenOnce(context.Background(), []ConfigListenTarget{{ + DataID: "app.yaml", + Group: "DEFAULT_GROUP", + ContentMD5: ContentMD5("old"), + NamespaceID: "dev", + }}, minListenTimeoutMs) + if err == nil || !strings.Contains(err.Error(), "retry transport failed") { + t.Fatalf("ListenOnce error = %v, want retry transport failure", err) + } + if listenRequests != 2 { + t.Fatalf("listen requests = %d, want 2", listenRequests) + } + if loginRequests != 1 { + t.Fatalf("login requests = %d, want 1", loginRequests) + } +} + func TestListenOnceChanged(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { @@ -99,8 +197,8 @@ func TestListenOnceV3PollsConfigByMD5(t *testing.T) { var legacyListenRequests int server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/v3/admin/core/namespace/list"): - _, _ = io.WriteString(w, `{"code":0,"message":"success","data":[]}`) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, nacosV3ReadinessPath): + _, _ = io.WriteString(w, `{"code":0,"message":"success","data":"ok"}`) case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/v3/admin/cs/config"): if r.URL.Query().Get("dataId") != "app.yaml" || r.URL.Query().Get("groupName") != "DEFAULT_GROUP" || r.URL.Query().Get("namespaceId") != "dev" { @@ -153,8 +251,8 @@ func TestListenOnceV3PollsConfigByMD5(t *testing.T) { func TestListenOnceV3BoundsSlowPollByListenTimeout(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { - case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/v3/admin/core/namespace/list"): - _, _ = io.WriteString(w, `{"code":0,"message":"success","data":[]}`) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, nacosV3ReadinessPath): + _, _ = io.WriteString(w, `{"code":0,"message":"success","data":"ok"}`) case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/v3/admin/cs/config"): <-r.Context().Done() default: diff --git a/internal/nacos/naming.go b/internal/nacos/naming.go index be754771..d6213344 100644 --- a/internal/nacos/naming.go +++ b/internal/nacos/naming.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/url" + "regexp" "sort" "strconv" "strings" @@ -167,7 +168,7 @@ func (c *ClientImpl) listV3ServicesByExactGroup( params.Set("pageSize", strconv.Itoa(maxServicePageSize)) params.Set("namespaceId", normalizeNamespaceID(namespaceID)) params.Set("serviceNameParam", "") - params.Set("groupNameParam", groupName) + params.Set("groupNameParam", regexp.QuoteMeta(groupName)) body, status, err := c.doRequest(ctx, http.MethodGet, c.currentAPIRoutes().serviceListByGroup, params, nil) if err != nil { @@ -261,6 +262,7 @@ func (c *ClientImpl) GetService(ctx context.Context, namespaceID, serviceName, g GroupName string `json:"groupName"` NamespaceID string `json:"namespaceId"` Namespace string `json:"namespace"` + Ephemeral bool `json:"ephemeral"` ProtectThreshold float64 `json:"protectThreshold"` Metadata map[string]string `json:"metadata"` Selector map[string]any `json:"selector"` @@ -306,6 +308,7 @@ func (c *ClientImpl) GetService(ctx context.Context, namespaceID, serviceName, g Name: firstNonEmpty(strings.TrimSpace(payload.Name), strings.TrimSpace(payload.ServiceName), plainServiceName), GroupName: firstNonEmpty(strings.TrimSpace(payload.GroupName), groupName), NamespaceID: normalizeNamespaceID(firstNonEmpty(payload.NamespaceID, payload.Namespace, namespaceID)), + Ephemeral: payload.Ephemeral, ProtectThreshold: payload.ProtectThreshold, Metadata: payload.Metadata, Selector: payload.Selector, @@ -320,6 +323,9 @@ func (c *ClientImpl) CreateService(ctx context.Context, req CreateServiceRequest if serviceName == "" { return localizedNacosBackendError("nacos.backend.error.service_name_required", nil) } + if family == nacosAPIV1 && req.Ephemeral != nil && *req.Ephemeral { + return localizedNacosBackendError("nacos.backend.error.ephemeral_service_unsupported_v1", nil) + } serviceName, groupName := splitServiceName(serviceName, req.GroupName) form := url.Values{} if family == nacosAPIV1 { @@ -329,6 +335,9 @@ func (c *ClientImpl) CreateService(ctx context.Context, req CreateServiceRequest } form.Set("groupName", groupName) form.Set("namespaceId", normalizeNamespaceID(req.NamespaceID)) + if family != nacosAPIV1 && req.Ephemeral != nil { + form.Set("ephemeral", strconv.FormatBool(*req.Ephemeral)) + } form.Set("protectThreshold", strconv.FormatFloat(req.ProtectThreshold, 'f', -1, 64)) if meta := encodeMetadata(req.Metadata); meta != "" { form.Set("metadata", meta) @@ -638,8 +647,8 @@ func buildInstanceParams(req InstanceRequest, includeEphemeral bool, family naco func buildInstanceForm(req InstanceRequest, includeAttrs bool, family nacosAPIFamily) url.Values { form := buildInstanceParams(req, true, family) if includeAttrs { - if req.Weight > 0 { - form.Set("weight", strconv.FormatFloat(req.Weight, 'f', -1, 64)) + if req.Weight != nil { + form.Set("weight", strconv.FormatFloat(*req.Weight, 'f', -1, 64)) } if req.Enabled != nil { form.Set("enabled", strconv.FormatBool(*req.Enabled)) diff --git a/internal/nacos/naming_test.go b/internal/nacos/naming_test.go index a5de7e35..2b4a7615 100644 --- a/internal/nacos/naming_test.go +++ b/internal/nacos/naming_test.go @@ -38,6 +38,7 @@ func TestNamingServiceAndInstanceFlow(t *testing.T) { "name": "orders", "groupName": "DEFAULT_GROUP", "namespaceId": "dev", + "ephemeral": true, "protectThreshold": 0.5, "metadata": map[string]string{"owner": "team-a"}, "clusters": []map[string]any{ @@ -118,7 +119,7 @@ func TestNamingServiceAndInstanceFlow(t *testing.T) { if err != nil { t.Fatalf("GetService: %v", err) } - if detail.Name != "orders" || detail.ProtectThreshold != 0.5 { + if detail.Name != "orders" || !detail.Ephemeral || detail.ProtectThreshold != 0.5 { t.Fatalf("service detail = %#v", detail) } @@ -148,13 +149,14 @@ func TestNamingServiceAndInstanceFlow(t *testing.T) { ephemeral := true enabled := true + weight := 1.0 if err := client.RegisterInstance(ctx, InstanceRequest{ NamespaceID: "dev", ServiceName: "orders", GroupName: "DEFAULT_GROUP", IP: "10.0.0.2", Port: 8081, - Weight: 1, + Weight: &weight, Enabled: &enabled, Ephemeral: &ephemeral, ClusterName: "DEFAULT", @@ -271,11 +273,16 @@ func TestNamingOperationsQualifyNonDefaultGroup(t *testing.T) { protectThreshold string } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet && (strings.HasSuffix(r.URL.Path, "/v3/admin/core/namespace/list") || + if r.Method == http.MethodGet && (strings.HasSuffix(r.URL.Path, nacosV3ReadinessPath) || + strings.HasSuffix(r.URL.Path, nacosV2ReadinessPath) || strings.HasSuffix(r.URL.Path, "/v2/console/namespace/list")) { http.NotFound(w, r) return } + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, nacosV1ReadinessPath) { + _, _ = io.WriteString(w, "OK") + return + } if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/v1/console/namespaces") { _ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": []any{}}) return diff --git a/internal/nacos/types.go b/internal/nacos/types.go index b80a08d0..b2bba93d 100644 --- a/internal/nacos/types.go +++ b/internal/nacos/types.go @@ -185,6 +185,7 @@ type ServiceDetail struct { Name string `json:"name"` GroupName string `json:"groupName,omitempty"` NamespaceID string `json:"namespaceId,omitempty"` + Ephemeral bool `json:"ephemeral"` ProtectThreshold float64 `json:"protectThreshold,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` Selector map[string]any `json:"selector,omitempty"` @@ -203,6 +204,7 @@ type CreateServiceRequest struct { NamespaceID string `json:"namespaceId"` ServiceName string `json:"serviceName"` GroupName string `json:"groupName,omitempty"` + Ephemeral *bool `json:"ephemeral,omitempty"` ProtectThreshold float64 `json:"protectThreshold,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` } @@ -256,7 +258,7 @@ type InstanceRequest struct { IP string `json:"ip"` Port int `json:"port"` ClusterName string `json:"clusterName,omitempty"` - Weight float64 `json:"weight,omitempty"` + Weight *float64 `json:"weight,omitempty"` Enabled *bool `json:"enabled,omitempty"` Healthy *bool `json:"healthy,omitempty"` Ephemeral *bool `json:"ephemeral,omitempty"` diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index ef8d9069..fd81dfde 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -3357,6 +3357,9 @@ "connection_modal.field.port_plain": "Port", "connection_modal.field.primary_port": "Primärer Port", "connection_modal.field.private_key_path_optional": "Pfad zum privaten Schlüssel (optional)", + "connection_modal.field.nacosNamespaceId.label": "Namespace-ID", + "connection_modal.field.nacosNamespaceId.placeholder": "Zum Beispiel: dev; für public ausdrücklich public eingeben", + "connection_modal.field.nacosNamespaceId.help": "Administratoren können das Feld leer lassen, um alle Namespaces zu ermitteln. Konten mit Namespace-Beschränkung müssen die genaue ID eingeben; für den öffentlichen Namespace ausdrücklich public eingeben.", "connection_modal.field.proxy_host": "Proxy-Host", "connection_modal.field.proxy_password_optional": "Proxy-Passwort (optional)", "connection_modal.field.proxy_type": "Proxy-Typ", @@ -3366,6 +3369,9 @@ "connection_modal.field.readOnly.help": "Wählen Sie nur die Einschränkungen aus, die Sie für Ergebnisbearbeitung, Strukturänderungen, Skriptausführung sowie Import- oder Synchronisierungsvorgänge benötigen.", "connection_modal.field.readOnly.label": "Produktionsschutz", "connection_modal.field.readOnly.option.dataEdit.help": "Direkte Bearbeitungen im Ergebnisraster, Massenleerungen und schreibende Nachrichtenaktionen für diese Verbindung blockieren.", + "connection_modal.field.readOnly.option.nacos.dataEdit.help": "Das Veröffentlichen/Löschen von Konfigurationen sowie Registrierung, Änderung, Abmeldung und Gesundheitsänderungen von Serviceinstanzen blockieren.", + "connection_modal.field.readOnly.option.nacos.structureEdit.help": "Das Erstellen, Ändern und Löschen von Namespaces und Services blockieren.", + "connection_modal.field.readOnly.option.nacos.dataImport.help": "Den Massenimport von Konfigurationen in Nacos blockieren.", "connection_modal.field.readOnly.option.dataEdit.label": "Datenbearbeitung einschränken", "connection_modal.field.readOnly.option.dataImport.help": "Dateiimport, Massenladen und die Verwendung dieser Verbindung als Synchronisationsziel blockieren.", "connection_modal.field.readOnly.option.dataImport.label": "Datenimport einschränken", @@ -8901,6 +8907,7 @@ "nacos.backend.error.login_parse": "Login-Antwort konnte nicht geparst werden: {{detail}}", "nacos.backend.error.login_empty_token": "Nacos-Login erfolgreich, aber accessToken ist leer", "nacos.backend.error.invalid_address": "Ungültige Nacos-Adresse: {{detail}}", + "nacos.backend.error.ssh_tunnel_create_failed": "Nacos-SSH-Tunnel konnte nicht erstellt werden: {{detail}}", "nacos.backend.error.tls_setup_failed": "TLS-Einrichtung fehlgeschlagen: {{detail}}", "nacos.backend.error.build_request": "Anfrage konnte nicht erstellt werden: {{detail}}", "nacos.backend.error.request_failed": "Nacos-Anfrage fehlgeschlagen: {{detail}}", @@ -8956,11 +8963,14 @@ "nacos.namespace.message.update_success": "Namespace aktualisiert", "nacos.namespace.message.delete_success": "Namespace gelöscht", "nacos.namespace.message.confirm_delete": "Namespace {{name}} ({{id}}) löschen? Dies kann nicht rückgängig gemacht werden.", + "nacos.namespace.message.scoped_fallback": "Nicht alle Namespaces konnten aufgelistet werden. Der konfigurierte Namespace {{id}} wird verwendet.", + "nacos.namespace.message.scope_required": "Dieses Konto kann keine Namespaces auflisten. Bearbeiten Sie die Verbindung und geben Sie die autorisierte Namespace-ID ein.", "nacos.backend.error.parse_services": "Serviceliste konnte nicht geparst werden: {{detail}}", "nacos.backend.error.parse_service": "Servicedetails konnten nicht geparst werden: {{detail}}", "nacos.backend.error.parse_instances": "Instanzliste konnte nicht geparst werden: {{detail}}", "nacos.backend.error.parse_instance": "Instanzdetails konnten nicht geparst werden: {{detail}}", "nacos.backend.error.service_name_required": "Service-Name ist erforderlich", + "nacos.backend.error.ephemeral_service_unsupported_v1": "Nacos v1 unterstützt das Erstellen flüchtiger Services nicht; erstellen Sie einen persistenten Service oder aktualisieren Sie Nacos", "nacos.backend.error.instance_ip_required": "Instanz-IP ist erforderlich", "nacos.backend.error.instance_port_invalid": "Instanz-Port ist ungültig", "nacos.backend.error.instance_healthy_required": "Healthy-Flag ist erforderlich", @@ -8986,6 +8996,8 @@ "nacos_service.field.healthy": "Healthy", "nacos_service.field.enabled": "Enabled", "nacos_service.field.ephemeral": "Ephemeral", + "nacos_service.field.persistent": "Persistent", + "nacos_service.field.type": "Typ", "nacos_service.field.protect_threshold": "Protect-Threshold", "nacos_service.action.create_service": "Service erstellen", "nacos_service.action.register_instance": "Instanz registrieren", @@ -8998,6 +9010,7 @@ "nacos_service.message.instance_update_success": "Instanz aktualisiert", "nacos_service.message.instance_deregister_success": "Instanz abgemeldet", "nacos_service.message.instance_health_success": "Health aktualisiert", + "nacos_service.message.ephemeral_registration_unavailable": "GoNavi erneuert flüchtige Instanzen nicht; die manuelle Registrierung ist nur für persistente Services verfügbar.", "nacos_service.message.confirm_delete_service": "Service {{name}} löschen? Nur möglich, wenn keine Instanzen existieren.", "nacos_service.message.confirm_deregister": "Instanz {{ip}}:{{port}} abmelden?", "tab_manager.hover.kind.nacos_services": "Nacos Services", @@ -9018,6 +9031,8 @@ "nacos.backend.error.import_format_invalid": "Ungültiges Importformat: {{format}}", "nacos.backend.error.import_version_invalid": "Nicht unterstützte Importversion: {{version}}", "nacos.backend.error.import_empty": "Importdatei enthält keine Konfigurationen", + "nacos.backend.error.import_selection_required": "Wählen Sie mindestens eine zu importierende Konfiguration aus", + "nacos.backend.error.import_partial_failed": "Einige Konfigurationen konnten nicht importiert werden: {{imported}} importiert, {{skipped}} übersprungen, {{failed}} fehlgeschlagen. Erster Fehler: {{detail}}", "nacos.backend.error.import_item_invalid": "Ungültiger Import-Eintrag (index={{index}})", "nacos.backend.error.export_empty": "Keine Konfigurationen zum Exportieren", "nacos.backend.message.beta_publish_success": "Beta-Konfiguration erfolgreich veröffentlicht", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index c48df477..0a90e5f1 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -3357,6 +3357,9 @@ "connection_modal.field.port_plain": "Port", "connection_modal.field.primary_port": "Primary port", "connection_modal.field.private_key_path_optional": "Private key path (optional)", + "connection_modal.field.nacosNamespaceId.label": "Namespace ID", + "connection_modal.field.nacosNamespaceId.placeholder": "For example: dev; enter public explicitly for public", + "connection_modal.field.nacosNamespaceId.help": "Administrators can leave this empty to discover all namespaces. Namespace-scoped accounts should enter the exact ID; enter public explicitly for the public namespace.", "connection_modal.field.proxy_host": "Proxy host", "connection_modal.field.proxy_password_optional": "Proxy password (optional)", "connection_modal.field.proxy_type": "Proxy type", @@ -3366,6 +3369,9 @@ "connection_modal.field.readOnly.help": "Select only the restrictions you need for result editing, structure changes, script execution, and import or sync flows.", "connection_modal.field.readOnly.label": "Production guard", "connection_modal.field.readOnly.option.dataEdit.help": "Block result-grid edits, bulk clear actions, and message publishing writes on this connection.", + "connection_modal.field.readOnly.option.nacos.dataEdit.help": "Block config publish/delete and service instance register, update, deregister, and health changes.", + "connection_modal.field.readOnly.option.nacos.structureEdit.help": "Block namespace and service create, update, and delete operations.", + "connection_modal.field.readOnly.option.nacos.dataImport.help": "Block bulk config imports into Nacos.", "connection_modal.field.readOnly.option.dataEdit.label": "Restrict data edits", "connection_modal.field.readOnly.option.dataImport.help": "Block file import, bulk load, and using this connection as a sync target.", "connection_modal.field.readOnly.option.dataImport.label": "Restrict data import", @@ -8901,6 +8907,7 @@ "nacos.backend.error.login_parse": "Failed to parse login response: {{detail}}", "nacos.backend.error.login_empty_token": "Nacos login succeeded but accessToken is empty", "nacos.backend.error.invalid_address": "Invalid Nacos address: {{detail}}", + "nacos.backend.error.ssh_tunnel_create_failed": "Failed to create the Nacos SSH tunnel: {{detail}}", "nacos.backend.error.tls_setup_failed": "TLS setup failed: {{detail}}", "nacos.backend.error.build_request": "Failed to build request: {{detail}}", "nacos.backend.error.request_failed": "Nacos request failed: {{detail}}", @@ -8956,11 +8963,14 @@ "nacos.namespace.message.update_success": "Namespace updated", "nacos.namespace.message.delete_success": "Namespace deleted", "nacos.namespace.message.confirm_delete": "Delete namespace {{name}} ({{id}})? This cannot be undone.", + "nacos.namespace.message.scoped_fallback": "Unable to list all namespaces. Using configured namespace {{id}}.", + "nacos.namespace.message.scope_required": "This account cannot list namespaces. Edit the connection and enter its authorized Namespace ID.", "nacos.backend.error.parse_services": "Failed to parse service list: {{detail}}", "nacos.backend.error.parse_service": "Failed to parse service detail: {{detail}}", "nacos.backend.error.parse_instances": "Failed to parse instance list: {{detail}}", "nacos.backend.error.parse_instance": "Failed to parse instance detail: {{detail}}", "nacos.backend.error.service_name_required": "Service name is required", + "nacos.backend.error.ephemeral_service_unsupported_v1": "Nacos v1 does not support creating ephemeral services; create a persistent service or upgrade Nacos", "nacos.backend.error.instance_ip_required": "Instance IP is required", "nacos.backend.error.instance_port_invalid": "Instance port is invalid", "nacos.backend.error.instance_healthy_required": "Healthy flag is required", @@ -8986,6 +8996,8 @@ "nacos_service.field.healthy": "Healthy", "nacos_service.field.enabled": "Enabled", "nacos_service.field.ephemeral": "Ephemeral", + "nacos_service.field.persistent": "Persistent", + "nacos_service.field.type": "Type", "nacos_service.field.protect_threshold": "Protect threshold", "nacos_service.action.create_service": "Create service", "nacos_service.action.register_instance": "Register instance", @@ -8998,6 +9010,7 @@ "nacos_service.message.instance_update_success": "Instance updated", "nacos_service.message.instance_deregister_success": "Instance deregistered", "nacos_service.message.instance_health_success": "Health updated", + "nacos_service.message.ephemeral_registration_unavailable": "GoNavi does not renew ephemeral instances; manual registration is only available for persistent services.", "nacos_service.message.confirm_delete_service": "Delete service {{name}}? Server only allows this when it has no instances.", "nacos_service.message.confirm_deregister": "Deregister instance {{ip}}:{{port}}?", "tab_manager.hover.kind.nacos_services": "Nacos services", @@ -9018,6 +9031,8 @@ "nacos.backend.error.import_format_invalid": "Invalid import format: {{format}}", "nacos.backend.error.import_version_invalid": "Unsupported import version: {{version}}", "nacos.backend.error.import_empty": "Import file contains no configs", + "nacos.backend.error.import_selection_required": "Select at least one config to import", + "nacos.backend.error.import_partial_failed": "Some configs failed to import: {{imported}} imported, {{skipped}} skipped, {{failed}} failed. First error: {{detail}}", "nacos.backend.error.import_item_invalid": "Invalid import item (index={{index}})", "nacos.backend.error.export_empty": "No configs to export", "nacos.backend.message.beta_publish_success": "Beta config published successfully", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 7ff3f666..b89fbcd2 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -3357,6 +3357,9 @@ "connection_modal.field.port_plain": "ポート", "connection_modal.field.primary_port": "プライマリポート", "connection_modal.field.private_key_path_optional": "秘密鍵パス(任意)", + "connection_modal.field.nacosNamespaceId.label": "Namespace ID", + "connection_modal.field.nacosNamespaceId.placeholder": "例: dev。public を使う場合は public と明示的に入力", + "connection_modal.field.nacosNamespaceId.help": "管理者は空欄のままにすると、すべてのネームスペースを検出できます。特定のネームスペースに制限されたアカウントは正確な ID を入力し、公開ネームスペースには public と明示的に入力してください。", "connection_modal.field.proxy_host": "プロキシホスト", "connection_modal.field.proxy_password_optional": "プロキシパスワード(任意)", "connection_modal.field.proxy_type": "プロキシ種別", @@ -3366,6 +3369,9 @@ "connection_modal.field.readOnly.help": "結果編集、構造変更、スクリプト実行、インポートや同期について、必要な制限だけを選択してください。", "connection_modal.field.readOnly.label": "本番接続ガード", "connection_modal.field.readOnly.option.dataEdit.help": "結果グリッドの直接編集、一括クリア、この接続でのメッセージ公開による書き込みを禁止します。", + "connection_modal.field.readOnly.option.nacos.dataEdit.help": "設定の公開・削除、およびサービスインスタンスの登録・更新・登録解除・ヘルス変更を禁止します。", + "connection_modal.field.readOnly.option.nacos.structureEdit.help": "名前空間とサービスの作成・更新・削除を禁止します。", + "connection_modal.field.readOnly.option.nacos.dataImport.help": "Nacos への設定の一括インポートを禁止します。", "connection_modal.field.readOnly.option.dataEdit.label": "データ編集を制限", "connection_modal.field.readOnly.option.dataImport.help": "ファイルインポート、一括ロード、この接続を同期先として使用する操作を禁止します。", "connection_modal.field.readOnly.option.dataImport.label": "データインポートを制限", @@ -8901,6 +8907,7 @@ "nacos.backend.error.login_parse": "ログイン応答の解析に失敗しました: {{detail}}", "nacos.backend.error.login_empty_token": "Nacos ログインは成功しましたが accessToken が空です", "nacos.backend.error.invalid_address": "無効な Nacos アドレスです: {{detail}}", + "nacos.backend.error.ssh_tunnel_create_failed": "Nacos SSH トンネルの作成に失敗しました: {{detail}}", "nacos.backend.error.tls_setup_failed": "TLS 設定に失敗しました: {{detail}}", "nacos.backend.error.build_request": "リクエストの構築に失敗しました: {{detail}}", "nacos.backend.error.request_failed": "Nacos リクエストに失敗しました: {{detail}}", @@ -8956,11 +8963,14 @@ "nacos.namespace.message.update_success": "ネームスペースを更新しました", "nacos.namespace.message.delete_success": "ネームスペースを削除しました", "nacos.namespace.message.confirm_delete": "ネームスペース {{name}}({{id}})を削除しますか?元に戻せません。", + "nacos.namespace.message.scoped_fallback": "すべてのネームスペースを一覧表示できないため、設定済みのネームスペース {{id}} を使用します。", + "nacos.namespace.message.scope_required": "このアカウントはネームスペースを一覧表示できません。接続を編集し、許可された Namespace ID を入力してください。", "nacos.backend.error.parse_services": "サービス一覧の解析に失敗しました: {{detail}}", "nacos.backend.error.parse_service": "サービス詳細の解析に失敗しました: {{detail}}", "nacos.backend.error.parse_instances": "インスタンス一覧の解析に失敗しました: {{detail}}", "nacos.backend.error.parse_instance": "インスタンス詳細の解析に失敗しました: {{detail}}", "nacos.backend.error.service_name_required": "サービス名は必須です", + "nacos.backend.error.ephemeral_service_unsupported_v1": "Nacos v1 では一時サービスを作成できません。永続サービスを作成するか、Nacos をアップグレードしてください", "nacos.backend.error.instance_ip_required": "インスタンス IP は必須です", "nacos.backend.error.instance_port_invalid": "インスタンスポートが無効です", "nacos.backend.error.instance_healthy_required": "healthy フラグは必須です", @@ -8986,6 +8996,8 @@ "nacos_service.field.healthy": "Healthy", "nacos_service.field.enabled": "Enabled", "nacos_service.field.ephemeral": "Ephemeral", + "nacos_service.field.persistent": "永続", + "nacos_service.field.type": "タイプ", "nacos_service.field.protect_threshold": "保護閾値", "nacos_service.action.create_service": "サービス作成", "nacos_service.action.register_instance": "インスタンス登録", @@ -8998,6 +9010,7 @@ "nacos_service.message.instance_update_success": "インスタンスを更新しました", "nacos_service.message.instance_deregister_success": "インスタンスを抹消しました", "nacos_service.message.instance_health_success": "ヘルスを更新しました", + "nacos_service.message.ephemeral_registration_unavailable": "GoNavi は一時インスタンスを更新しないため、手動登録は永続サービスでのみ利用できます。", "nacos_service.message.confirm_delete_service": "サービス {{name}} を削除しますか?インスタンスがない場合のみ可能です。", "nacos_service.message.confirm_deregister": "インスタンス {{ip}}:{{port}} を抹消しますか?", "tab_manager.hover.kind.nacos_services": "Nacos サービス", @@ -9018,6 +9031,8 @@ "nacos.backend.error.import_format_invalid": "無効なインポート形式です: {{format}}", "nacos.backend.error.import_version_invalid": "未対応のインポートバージョンです: {{version}}", "nacos.backend.error.import_empty": "インポートファイルに設定がありません", + "nacos.backend.error.import_selection_required": "インポートする設定を1件以上選択してください", + "nacos.backend.error.import_partial_failed": "一部の設定をインポートできませんでした: 成功 {{imported}}、スキップ {{skipped}}、失敗 {{failed}}。最初のエラー: {{detail}}", "nacos.backend.error.import_item_invalid": "無効なインポート項目です (index={{index}})", "nacos.backend.error.export_empty": "エクスポートする設定がありません", "nacos.backend.message.beta_publish_success": "Beta 設定を公開しました", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index e3146dc1..aa178e31 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -3357,6 +3357,9 @@ "connection_modal.field.port_plain": "Порт", "connection_modal.field.primary_port": "Основной порт", "connection_modal.field.private_key_path_optional": "Путь к закрытому ключу (необязательно)", + "connection_modal.field.nacosNamespaceId.label": "ID namespace", + "connection_modal.field.nacosNamespaceId.placeholder": "Например: dev; для public явно введите public", + "connection_modal.field.nacosNamespaceId.help": "Администраторы могут оставить поле пустым для обнаружения всех пространств имён. Учётная запись с доступом только к определённому пространству должна указать точный ID; для общего пространства явно введите public.", "connection_modal.field.proxy_host": "Хост прокси", "connection_modal.field.proxy_password_optional": "Пароль прокси (необязательно)", "connection_modal.field.proxy_type": "Тип прокси", @@ -3366,6 +3369,9 @@ "connection_modal.field.readOnly.help": "Выберите только те ограничения, которые нужны для редактирования результатов, изменения структуры, выполнения скриптов и операций импорта или синхронизации.", "connection_modal.field.readOnly.label": "Защита прод-подключения", "connection_modal.field.readOnly.option.dataEdit.help": "Запретить прямое редактирование в таблице результатов, массовую очистку и операции записи при публикации сообщений для этого подключения.", + "connection_modal.field.readOnly.option.nacos.dataEdit.help": "Запретить публикацию и удаление конфигураций, а также регистрацию, изменение, удаление и изменение состояния экземпляров сервиса.", + "connection_modal.field.readOnly.option.nacos.structureEdit.help": "Запретить создание, изменение и удаление пространств имён и сервисов.", + "connection_modal.field.readOnly.option.nacos.dataImport.help": "Запретить массовый импорт конфигураций в Nacos.", "connection_modal.field.readOnly.option.dataEdit.label": "Ограничить редактирование данных", "connection_modal.field.readOnly.option.dataImport.help": "Запретить импорт файлов, массовую загрузку и использование этого подключения как цели синхронизации.", "connection_modal.field.readOnly.option.dataImport.label": "Ограничить импорт данных", @@ -8901,6 +8907,7 @@ "nacos.backend.error.login_parse": "Не удалось разобрать ответ входа: {{detail}}", "nacos.backend.error.login_empty_token": "Вход в Nacos успешен, но accessToken пуст", "nacos.backend.error.invalid_address": "Недопустимый адрес Nacos: {{detail}}", + "nacos.backend.error.ssh_tunnel_create_failed": "Не удалось создать SSH-туннель Nacos: {{detail}}", "nacos.backend.error.tls_setup_failed": "Ошибка настройки TLS: {{detail}}", "nacos.backend.error.build_request": "Не удалось создать запрос: {{detail}}", "nacos.backend.error.request_failed": "Запрос к Nacos не выполнен: {{detail}}", @@ -8956,11 +8963,14 @@ "nacos.namespace.message.update_success": "Namespace обновлён", "nacos.namespace.message.delete_success": "Namespace удалён", "nacos.namespace.message.confirm_delete": "Удалить namespace {{name}} ({{id}})? Это нельзя отменить.", + "nacos.namespace.message.scoped_fallback": "Не удалось получить список всех пространств имён. Используется настроенное пространство {{id}}.", + "nacos.namespace.message.scope_required": "Эта учётная запись не может получить список пространств имён. Измените подключение и укажите разрешённый Namespace ID.", "nacos.backend.error.parse_services": "Не удалось разобрать список сервисов: {{detail}}", "nacos.backend.error.parse_service": "Не удалось разобрать детали сервиса: {{detail}}", "nacos.backend.error.parse_instances": "Не удалось разобрать список инстансов: {{detail}}", "nacos.backend.error.parse_instance": "Не удалось разобрать детали инстанса: {{detail}}", "nacos.backend.error.service_name_required": "Требуется имя сервиса", + "nacos.backend.error.ephemeral_service_unsupported_v1": "Nacos v1 не поддерживает создание временных сервисов; создайте постоянный сервис или обновите Nacos", "nacos.backend.error.instance_ip_required": "Требуется IP инстанса", "nacos.backend.error.instance_port_invalid": "Недопустимый порт инстанса", "nacos.backend.error.instance_healthy_required": "Требуется флаг healthy", @@ -8986,6 +8996,8 @@ "nacos_service.field.healthy": "Healthy", "nacos_service.field.enabled": "Enabled", "nacos_service.field.ephemeral": "Ephemeral", + "nacos_service.field.persistent": "Постоянный", + "nacos_service.field.type": "Тип", "nacos_service.field.protect_threshold": "Protect threshold", "nacos_service.action.create_service": "Создать сервис", "nacos_service.action.register_instance": "Зарегистрировать инстанс", @@ -8998,6 +9010,7 @@ "nacos_service.message.instance_update_success": "Инстанс обновлён", "nacos_service.message.instance_deregister_success": "Регистрация снята", "nacos_service.message.instance_health_success": "Health обновлён", + "nacos_service.message.ephemeral_registration_unavailable": "GoNavi не продлевает временные экземпляры; ручная регистрация доступна только для постоянных сервисов.", "nacos_service.message.confirm_delete_service": "Удалить сервис {{name}}? Только если нет инстансов.", "nacos_service.message.confirm_deregister": "Снять регистрацию {{ip}}:{{port}}?", "tab_manager.hover.kind.nacos_services": "Сервисы Nacos", @@ -9018,6 +9031,8 @@ "nacos.backend.error.import_format_invalid": "Недопустимый формат импорта: {{format}}", "nacos.backend.error.import_version_invalid": "Неподдерживаемая версия импорта: {{version}}", "nacos.backend.error.import_empty": "Файл импорта не содержит конфигураций", + "nacos.backend.error.import_selection_required": "Выберите хотя бы одну конфигурацию для импорта", + "nacos.backend.error.import_partial_failed": "Не удалось импортировать часть конфигураций: импортировано {{imported}}, пропущено {{skipped}}, ошибок {{failed}}. Первая ошибка: {{detail}}", "nacos.backend.error.import_item_invalid": "Недопустимый элемент импорта (index={{index}})", "nacos.backend.error.export_empty": "Нет конфигураций для экспорта", "nacos.backend.message.beta_publish_success": "Beta-конфигурация опубликована", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 147cd1fb..b1e1d408 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -3357,6 +3357,9 @@ "connection_modal.field.port_plain": "端口", "connection_modal.field.primary_port": "主端口", "connection_modal.field.private_key_path_optional": "私钥路径(可选)", + "connection_modal.field.nacosNamespaceId.label": "Namespace ID", + "connection_modal.field.nacosNamespaceId.placeholder": "例如 dev;访问 public 请显式填写 public", + "connection_modal.field.nacosNamespaceId.help": "管理员可留空并自动发现全部命名空间。仅有指定命名空间权限的账号请填写精确 ID;访问 public 时请显式填写 public。", "connection_modal.field.proxy_host": "代理主机", "connection_modal.field.proxy_password_optional": "代理密码(可选)", "connection_modal.field.proxy_type": "代理类型", @@ -3366,6 +3369,9 @@ "connection_modal.field.readOnly.help": "按需勾选限制项,分别限制结果编辑、结构变更、脚本执行与导入/同步。", "connection_modal.field.readOnly.label": "生产连接保护", "connection_modal.field.readOnly.option.dataEdit.help": "禁止结果集直接修改、批量清空以及消息发布等写入操作。", + "connection_modal.field.readOnly.option.nacos.dataEdit.help": "禁止配置发布/删除,以及服务实例的注册、修改、注销和健康状态变更。", + "connection_modal.field.readOnly.option.nacos.structureEdit.help": "禁止命名空间和服务的创建、修改与删除。", + "connection_modal.field.readOnly.option.nacos.dataImport.help": "禁止向 Nacos 批量导入配置。", "connection_modal.field.readOnly.option.dataEdit.label": "限制数据编辑", "connection_modal.field.readOnly.option.dataImport.help": "禁止导入文件、批量装载以及将当前连接作为同步目标。", "connection_modal.field.readOnly.option.dataImport.label": "限制数据导入", @@ -8901,6 +8907,7 @@ "nacos.backend.error.login_parse": "解析登录响应失败:{{detail}}", "nacos.backend.error.login_empty_token": "Nacos 登录成功但未返回 accessToken", "nacos.backend.error.invalid_address": "Nacos 地址无效:{{detail}}", + "nacos.backend.error.ssh_tunnel_create_failed": "创建 Nacos SSH 隧道失败:{{detail}}", "nacos.backend.error.tls_setup_failed": "TLS 配置失败:{{detail}}", "nacos.backend.error.build_request": "构建请求失败:{{detail}}", "nacos.backend.error.request_failed": "请求 Nacos 失败:{{detail}}", @@ -8956,11 +8963,14 @@ "nacos.namespace.message.update_success": "命名空间已更新", "nacos.namespace.message.delete_success": "命名空间已删除", "nacos.namespace.message.confirm_delete": "确认删除命名空间 {{name}}({{id}})?此操作不可恢复。", + "nacos.namespace.message.scoped_fallback": "无法列出全部命名空间,已使用连接中配置的命名空间 {{id}}。", + "nacos.namespace.message.scope_required": "当前账号无权列出命名空间。请编辑连接并填写其获授权的 Namespace ID。", "nacos.backend.error.parse_services": "解析服务列表失败:{{detail}}", "nacos.backend.error.parse_service": "解析服务详情失败:{{detail}}", "nacos.backend.error.parse_instances": "解析实例列表失败:{{detail}}", "nacos.backend.error.parse_instance": "解析实例详情失败:{{detail}}", "nacos.backend.error.service_name_required": "服务名不能为空", + "nacos.backend.error.ephemeral_service_unsupported_v1": "Nacos v1 不支持创建临时服务,请创建持久服务或升级 Nacos", "nacos.backend.error.instance_ip_required": "实例 IP 不能为空", "nacos.backend.error.instance_port_invalid": "实例端口无效", "nacos.backend.error.instance_healthy_required": "健康状态参数不能为空", @@ -8986,6 +8996,8 @@ "nacos_service.field.healthy": "健康", "nacos_service.field.enabled": "启用", "nacos_service.field.ephemeral": "临时", + "nacos_service.field.persistent": "持久", + "nacos_service.field.type": "类型", "nacos_service.field.protect_threshold": "保护阈值", "nacos_service.action.create_service": "新建服务", "nacos_service.action.register_instance": "注册实例", @@ -8998,6 +9010,7 @@ "nacos_service.message.instance_update_success": "实例已更新", "nacos_service.message.instance_deregister_success": "实例已注销", "nacos_service.message.instance_health_success": "健康状态已更新", + "nacos_service.message.ephemeral_registration_unavailable": "GoNavi 不会续约临时实例,仅支持向持久服务手工注册实例。", "nacos_service.message.confirm_delete_service": "确认删除服务 {{name}}?仅当无实例时服务端才允许删除。", "nacos_service.message.confirm_deregister": "确认注销实例 {{ip}}:{{port}}?", "tab_manager.hover.kind.nacos_services": "Nacos 服务发现", @@ -9018,6 +9031,8 @@ "nacos.backend.error.import_format_invalid": "导入文件格式无效:{{format}}", "nacos.backend.error.import_version_invalid": "导入文件版本不受支持:{{version}}", "nacos.backend.error.import_empty": "导入文件不包含任何配置", + "nacos.backend.error.import_selection_required": "请选择至少一个要导入的配置", + "nacos.backend.error.import_partial_failed": "部分配置导入失败:成功 {{imported}},跳过 {{skipped}},失败 {{failed}}。首个错误:{{detail}}", "nacos.backend.error.import_item_invalid": "导入项无效(index={{index}})", "nacos.backend.error.export_empty": "没有可导出的配置", "nacos.backend.message.beta_publish_success": "Beta 配置发布成功", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index e229b383..05154ad0 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -3357,6 +3357,9 @@ "connection_modal.field.port_plain": "連接埠", "connection_modal.field.primary_port": "主要連接埠", "connection_modal.field.private_key_path_optional": "私鑰路徑(選填)", + "connection_modal.field.nacosNamespaceId.label": "Namespace ID", + "connection_modal.field.nacosNamespaceId.placeholder": "例如 dev;存取 public 請明確填寫 public", + "connection_modal.field.nacosNamespaceId.help": "管理員可留空並自動探索全部命名空間。僅有指定命名空間權限的帳號請填寫精確 ID;存取 public 時請明確填寫 public。", "connection_modal.field.proxy_host": "代理主機", "connection_modal.field.proxy_password_optional": "代理密碼(選填)", "connection_modal.field.proxy_type": "代理類型", @@ -3366,6 +3369,9 @@ "connection_modal.field.readOnly.help": "依需求勾選限制項,分別限制結果編輯、結構變更、腳本執行與匯入/同步。", "connection_modal.field.readOnly.label": "正式連線保護", "connection_modal.field.readOnly.option.dataEdit.help": "禁止結果集直接修改、批次清空以及訊息發佈等寫入操作。", + "connection_modal.field.readOnly.option.nacos.dataEdit.help": "禁止設定發佈/刪除,以及服務執行個體的註冊、修改、註銷和健康狀態變更。", + "connection_modal.field.readOnly.option.nacos.structureEdit.help": "禁止命名空間和服務的建立、修改與刪除。", + "connection_modal.field.readOnly.option.nacos.dataImport.help": "禁止向 Nacos 批次匯入設定。", "connection_modal.field.readOnly.option.dataEdit.label": "限制資料編輯", "connection_modal.field.readOnly.option.dataImport.help": "禁止匯入檔案、批次載入,以及將目前連線作為同步目標。", "connection_modal.field.readOnly.option.dataImport.label": "限制資料匯入", @@ -8901,6 +8907,7 @@ "nacos.backend.error.login_parse": "解析登入回應失敗:{{detail}}", "nacos.backend.error.login_empty_token": "Nacos 登入成功但未回傳 accessToken", "nacos.backend.error.invalid_address": "Nacos 位址無效:{{detail}}", + "nacos.backend.error.ssh_tunnel_create_failed": "建立 Nacos SSH 通道失敗:{{detail}}", "nacos.backend.error.tls_setup_failed": "TLS 設定失敗:{{detail}}", "nacos.backend.error.build_request": "建立請求失敗:{{detail}}", "nacos.backend.error.request_failed": "請求 Nacos 失敗:{{detail}}", @@ -8956,11 +8963,14 @@ "nacos.namespace.message.update_success": "命名空間已更新", "nacos.namespace.message.delete_success": "命名空間已刪除", "nacos.namespace.message.confirm_delete": "確認刪除命名空間 {{name}}({{id}})?此操作不可恢復。", + "nacos.namespace.message.scoped_fallback": "無法列出全部命名空間,已使用連線中設定的命名空間 {{id}}。", + "nacos.namespace.message.scope_required": "目前帳號無權列出命名空間。請編輯連線並填寫其獲授權的 Namespace ID。", "nacos.backend.error.parse_services": "解析服務列表失敗:{{detail}}", "nacos.backend.error.parse_service": "解析服務詳情失敗:{{detail}}", "nacos.backend.error.parse_instances": "解析實例列表失敗:{{detail}}", "nacos.backend.error.parse_instance": "解析實例詳情失敗:{{detail}}", "nacos.backend.error.service_name_required": "服務名不能為空", + "nacos.backend.error.ephemeral_service_unsupported_v1": "Nacos v1 不支援建立臨時服務,請建立持久服務或升級 Nacos", "nacos.backend.error.instance_ip_required": "實例 IP 不能為空", "nacos.backend.error.instance_port_invalid": "實例埠無效", "nacos.backend.error.instance_healthy_required": "健康狀態參數不能為空", @@ -8986,6 +8996,8 @@ "nacos_service.field.healthy": "健康", "nacos_service.field.enabled": "啟用", "nacos_service.field.ephemeral": "臨時", + "nacos_service.field.persistent": "持久", + "nacos_service.field.type": "類型", "nacos_service.field.protect_threshold": "保護閾值", "nacos_service.action.create_service": "新建服務", "nacos_service.action.register_instance": "註冊實例", @@ -8998,6 +9010,7 @@ "nacos_service.message.instance_update_success": "實例已更新", "nacos_service.message.instance_deregister_success": "實例已註銷", "nacos_service.message.instance_health_success": "健康狀態已更新", + "nacos_service.message.ephemeral_registration_unavailable": "GoNavi 不會續約臨時執行個體,僅支援向持久服務手動註冊執行個體。", "nacos_service.message.confirm_delete_service": "確認刪除服務 {{name}}?僅當無實例時服務端才允許刪除。", "nacos_service.message.confirm_deregister": "確認註銷實例 {{ip}}:{{port}}?", "tab_manager.hover.kind.nacos_services": "Nacos 服務發現", @@ -9018,6 +9031,8 @@ "nacos.backend.error.import_format_invalid": "匯入檔案格式無效:{{format}}", "nacos.backend.error.import_version_invalid": "匯入檔案版本不受支援:{{version}}", "nacos.backend.error.import_empty": "匯入檔案不包含任何設定", + "nacos.backend.error.import_selection_required": "請至少選擇一個要匯入的設定", + "nacos.backend.error.import_partial_failed": "部分設定匯入失敗:成功 {{imported}},略過 {{skipped}},失敗 {{failed}}。第一個錯誤:{{detail}}", "nacos.backend.error.import_item_invalid": "匯入項無效(index={{index}})", "nacos.backend.error.export_empty": "沒有可匯出的設定", "nacos.backend.message.beta_publish_success": "Beta 設定發布成功",