🐛 fix(nacos): 完善多版本连接与配置服务可靠性

- 支持 Nacos v1、v2、v3 就绪探测与受限账号连接
- 强化认证、缓存、单飞连接和关闭代际的并发边界
- 修复监听重复事件、导入身份碰撞及部分失败误报成功
- 补齐只读保护、结构化错误与六语言提示
This commit is contained in:
Syngnat
2026-07-28 23:51:17 +08:00
parent 199ab4fea1
commit e5e80cbbe8
26 changed files with 5152 additions and 354 deletions

View File

@@ -29,6 +29,7 @@ var connectionReadOnlySupportedTypes = map[string]struct{}{
"mariadb": {},
"mongodb": {},
"mysql": {},
"nacos": {},
"oceanbase": {},
"opengauss": {},
"oracle": {},

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, &notFoundErr)
}
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<T> 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" {

View File

@@ -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{"", "<html>console</html>", `{"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{},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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