Files
MyGoNavi/internal/nacos/client.go
Syngnat 8472c56812 🐛 fix(nacos): 兼容多版本 API 与精确分组查询
- 自动探测并缓存 Nacos v1、v2、v3 API,区分接口缺失与鉴权失败
- 适配命名空间、配置、历史、Beta、服务实例和监听协议差异
- 修复 v1/v2 响应解包及 v3 跨页 Group 精确过滤
- 补充三代 API、认证降级、监听超时和命名服务回归测试
2026-07-28 19:22:43 +08:00

1412 lines
41 KiB
Go

package nacos
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/tlsconfig"
)
const (
defaultNacosPort = 8848
defaultNacosContextPath = "/nacos"
defaultNacosTimeout = 30 * time.Second
defaultConfigPageSize = 20
maxConfigPageSize = 200
tokenRefreshSkew = 60 * time.Second
)
// 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
}
// NewClient creates a new Nacos client instance.
func NewClient() Client {
return &ClientImpl{}
}
// Connect prepares the HTTP client and validates reachability.
func (c *ClientImpl) Connect(config connection.ConnectionConfig) error {
normalized, err := normalizeNacosConfig(config)
if err != nil {
return err
}
httpClient, baseURL, err := buildNacosHTTPClient(normalized)
if err != nil {
return err
}
c.mu.Lock()
c.config = normalized
c.httpClient = httpClient
c.baseURL = baseURL
c.apiFamily = nacosAPIUnknown
c.accessToken = ""
c.tokenExpiry = time.Time{}
c.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), normalizeNacosTimeout(normalized.Timeout))
defer cancel()
if err := c.ensureAuth(ctx); err != nil {
_ = c.Close()
return err
}
if err := c.detectAPIFamily(ctx); err != nil {
_ = c.Close()
return err
}
if err := c.Ping(ctx); err != nil {
_ = c.Close()
return err
}
return nil
}
// Close releases client resources.
func (c *ClientImpl) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
c.httpClient = nil
c.baseURL = nil
c.apiFamily = nacosAPIUnknown
c.accessToken = ""
c.tokenExpiry = time.Time{}
return nil
}
// Ping checks server reachability via namespace list (works with/without auth).
func (c *ClientImpl) Ping(ctx context.Context) error {
if _, err := c.ListNamespaces(ctx); err != nil {
return err
}
return nil
}
// ListNamespaces returns all namespaces including public.
func (c *ClientImpl) ListNamespaces(ctx context.Context) ([]Namespace, error) {
body, status, err := c.doRequest(ctx, http.MethodGet, c.currentAPIRoutes().namespaceList, nil, nil)
if err != nil {
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)),
})
}
data, err := unwrapNacosResult(body)
if err != nil {
return nil, err
}
var payload []struct {
Namespace string `json:"namespace"`
NamespaceID string `json:"namespaceId"`
NamespaceShowName string `json:"namespaceShowName"`
NamespaceName string `json:"namespaceName"`
NamespaceDesc string `json:"namespaceDesc"`
Quota int64 `json:"quota"`
ConfigCount int64 `json:"configCount"`
Type int `json:"type"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return nil, localizedNacosBackendError("nacos.backend.error.parse_namespaces", map[string]any{
"detail": err.Error(),
})
}
result := make([]Namespace, 0, len(payload))
for _, item := range payload {
id := firstNonEmpty(strings.TrimSpace(item.Namespace), strings.TrimSpace(item.NamespaceID))
showName := firstNonEmpty(strings.TrimSpace(item.NamespaceShowName), strings.TrimSpace(item.NamespaceName))
if showName == "" {
if id == "" {
showName = "public"
} else {
showName = id
}
}
result = append(result, Namespace{
ID: id,
ShowName: showName,
Description: strings.TrimSpace(item.NamespaceDesc),
ConfigCount: item.ConfigCount,
Quota: item.Quota,
Type: item.Type,
})
}
return result, nil
}
// SearchConfigs lists configs under a namespace with optional filters.
func (c *ClientImpl) SearchConfigs(ctx context.Context, query ConfigQuery) (*ConfigPage, error) {
family := c.currentAPIFamily()
pageNo := query.PageNo
if pageNo <= 0 {
pageNo = 1
}
pageSize := query.PageSize
if pageSize <= 0 {
pageSize = defaultConfigPageSize
}
if pageSize > maxConfigPageSize {
pageSize = maxConfigPageSize
}
searchMode := strings.ToLower(strings.TrimSpace(query.Search))
if searchMode == "" {
searchMode = "blur"
}
params := url.Values{}
params.Set("search", searchMode)
params.Set("dataId", strings.TrimSpace(query.DataID))
params.Set("appName", strings.TrimSpace(query.AppName))
params.Set("pageNo", strconv.Itoa(pageNo))
params.Set("pageSize", strconv.Itoa(pageSize))
if family == nacosAPIV3 {
params.Set("groupName", strings.TrimSpace(query.Group))
params.Set("namespaceId", normalizeNamespaceID(query.NamespaceID))
params.Set("configDetail", "")
} else {
params.Set("group", strings.TrimSpace(query.Group))
params.Set("tenant", normalizeNamespaceID(query.NamespaceID))
if family == nacosAPIV2 {
params.Set("config_detail", "")
}
}
body, status, err := c.doRequest(ctx, http.MethodGet, c.currentAPIRoutes().configList, params, nil)
if err != nil {
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)),
})
}
data, err := unwrapNacosResult(body)
if err != nil {
return nil, err
}
var payload struct {
TotalCount int64 `json:"totalCount"`
PageNumber int `json:"pageNumber"`
PagesAvailable int `json:"pagesAvailable"`
PageItems []struct {
ID string `json:"id"`
DataID string `json:"dataId"`
Group string `json:"group"`
GroupName string `json:"groupName"`
Content string `json:"content"`
MD5 string `json:"md5"`
Tenant string `json:"tenant"`
NamespaceID string `json:"namespaceId"`
AppName string `json:"appName"`
Type string `json:"type"`
Desc string `json:"desc"`
LastModifiedTime any `json:"lastModifiedTime"`
ModifiedTime any `json:"modifiedTime"`
ModifyTime any `json:"modifyTime"`
} `json:"pageItems"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return nil, localizedNacosBackendError("nacos.backend.error.parse_configs", map[string]any{
"detail": err.Error(),
})
}
items := make([]ConfigItem, 0, len(payload.PageItems))
for _, item := range payload.PageItems {
items = append(items, ConfigItem{
ID: strings.TrimSpace(item.ID),
DataID: strings.TrimSpace(item.DataID),
Group: firstNonEmpty(strings.TrimSpace(item.Group), strings.TrimSpace(item.GroupName)),
NamespaceID: normalizeNamespaceID(firstNonEmpty(item.Tenant, item.NamespaceID)),
Content: item.Content,
Type: strings.TrimSpace(item.Type),
MD5: strings.TrimSpace(item.MD5),
AppName: strings.TrimSpace(item.AppName),
Desc: strings.TrimSpace(item.Desc),
ModifiedTime: stringifyAnyTime(item.LastModifiedTime, item.ModifiedTime, item.ModifyTime),
})
}
return &ConfigPage{
TotalCount: payload.TotalCount,
PageNumber: payload.PageNumber,
PagesAvailable: payload.PagesAvailable,
PageItems: items,
}, nil
}
// ListConfigGroups returns unique config groups under a namespace.
func (c *ClientImpl) ListConfigGroups(ctx context.Context, namespaceID string) ([]string, error) {
const pageSize = 100
pageNo := 1
seen := make(map[string]struct{})
groups := make([]string, 0, 16)
for {
page, err := c.SearchConfigs(ctx, ConfigQuery{
NamespaceID: namespaceID,
PageNo: pageNo,
PageSize: pageSize,
Search: "blur",
})
if err != nil {
return nil, err
}
if page == nil || len(page.PageItems) == 0 {
break
}
for _, item := range page.PageItems {
group := strings.TrimSpace(item.Group)
if group == "" {
group = "DEFAULT_GROUP"
}
if _, ok := seen[group]; ok {
continue
}
seen[group] = struct{}{}
groups = append(groups, group)
}
if pageNo >= page.PagesAvailable || len(page.PageItems) < pageSize {
break
}
pageNo++
if pageNo > 200 {
break
}
}
sort.Strings(groups)
return groups, nil
}
// GetConfig loads a single config content.
func (c *ClientImpl) GetConfig(ctx context.Context, namespaceID, group, dataID string) (*ConfigDetail, error) {
family := c.currentAPIFamily()
dataID = strings.TrimSpace(dataID)
group = strings.TrimSpace(group)
if dataID == "" {
return nil, localizedNacosBackendError("nacos.backend.error.data_id_required", nil)
}
if group == "" {
group = "DEFAULT_GROUP"
}
params := url.Values{}
params.Set("dataId", dataID)
if family == nacosAPIV3 {
params.Set("groupName", group)
params.Set("namespaceId", normalizeNamespaceID(namespaceID))
} else if family == nacosAPIV2 {
params.Set("group", group)
params.Set("namespaceId", normalizeNamespaceID(namespaceID))
} else {
params.Set("group", group)
params.Set("tenant", normalizeNamespaceID(namespaceID))
params.Set("show", "all")
}
body, status, err := c.doRequest(ctx, http.MethodGet, c.currentAPIRoutes().config, params, nil)
if err != nil {
return nil, err
}
if status == http.StatusNotFound {
return nil, localizedNacosBackendError("nacos.backend.error.config_not_found", map[string]any{
"dataId": dataID,
"group": group,
})
}
if status < 200 || status >= 300 {
return nil, localizedNacosBackendError("nacos.backend.error.http_status", map[string]any{
"status": status,
"body": truncateForError(string(body)),
})
}
data := body
if family == nacosAPIV2 || family == nacosAPIV3 {
data, err = unwrapNacosResult(body)
if err != nil {
return nil, err
}
}
if family == nacosAPIV2 {
content := decodeNacosStringData(data)
return &ConfigDetail{
DataID: dataID,
Group: group,
NamespaceID: normalizeNamespaceID(namespaceID),
Content: content,
MD5: ContentMD5(content),
}, nil
}
// Nacos v1 show=all and v3 return JSON details.
trimmed := strings.TrimSpace(string(data))
if strings.HasPrefix(trimmed, "{") {
var payload struct {
DataID string `json:"dataId"`
Group string `json:"group"`
GroupName string `json:"groupName"`
Content string `json:"content"`
Type string `json:"type"`
MD5 string `json:"md5"`
AppName string `json:"appName"`
Desc string `json:"desc"`
Tenant string `json:"tenant"`
NamespaceID string `json:"namespaceId"`
}
if err := json.Unmarshal(data, &payload); err == nil && (payload.Content != "" || payload.DataID != "") {
md5Value := strings.TrimSpace(payload.MD5)
if md5Value == "" {
md5Value = ContentMD5(payload.Content)
}
return &ConfigDetail{
DataID: firstNonEmpty(payload.DataID, dataID),
Group: firstNonEmpty(payload.Group, payload.GroupName, group),
NamespaceID: normalizeNamespaceID(firstNonEmpty(payload.Tenant, payload.NamespaceID, namespaceID)),
Content: payload.Content,
Type: strings.TrimSpace(payload.Type),
MD5: md5Value,
AppName: strings.TrimSpace(payload.AppName),
Desc: strings.TrimSpace(payload.Desc),
}, nil
}
}
content := string(data)
return &ConfigDetail{
DataID: dataID,
Group: group,
NamespaceID: normalizeNamespaceID(namespaceID),
Content: content,
MD5: ContentMD5(content),
}, nil
}
func decodeNacosStringData(data []byte) string {
var value string
if err := json.Unmarshal(data, &value); err == nil {
return value
}
return string(data)
}
// PublishConfig creates or updates a config.
func (c *ClientImpl) PublishConfig(ctx context.Context, req PublishRequest) error {
family := c.currentAPIFamily()
dataID := strings.TrimSpace(req.DataID)
group := strings.TrimSpace(req.Group)
if dataID == "" {
return localizedNacosBackendError("nacos.backend.error.data_id_required", nil)
}
if group == "" {
group = "DEFAULT_GROUP"
}
form := url.Values{}
form.Set("dataId", dataID)
form.Set("content", req.Content)
if family == nacosAPIV3 {
form.Set("groupName", group)
form.Set("namespaceId", normalizeNamespaceID(req.NamespaceID))
} else if family == nacosAPIV2 {
form.Set("group", group)
form.Set("namespaceId", normalizeNamespaceID(req.NamespaceID))
} else {
form.Set("group", group)
form.Set("tenant", normalizeNamespaceID(req.NamespaceID))
}
if typ := strings.TrimSpace(req.Type); typ != "" {
form.Set("type", typ)
}
if appName := strings.TrimSpace(req.AppName); appName != "" {
form.Set("appName", appName)
}
if desc := strings.TrimSpace(req.Desc); desc != "" {
form.Set("desc", desc)
}
headers := http.Header{}
if betaIPs := strings.TrimSpace(req.BetaIPs); betaIPs != "" {
headers.Set("betaIps", betaIPs)
}
body, status, err := c.doRequestWithHeaders(ctx, http.MethodPost, c.currentAPIRoutes().config, nil, form, headers)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return localizedNacosBackendError("nacos.backend.error.http_status", map[string]any{
"status": status,
"body": truncateForError(string(body)),
})
}
return parseNacosBoolResult(body, status, "nacos.backend.error.publish_failed")
}
// DeleteConfig removes a config.
func (c *ClientImpl) DeleteConfig(ctx context.Context, namespaceID, group, dataID string) error {
family := c.currentAPIFamily()
dataID = strings.TrimSpace(dataID)
group = strings.TrimSpace(group)
if dataID == "" {
return localizedNacosBackendError("nacos.backend.error.data_id_required", nil)
}
if group == "" {
group = "DEFAULT_GROUP"
}
params := url.Values{}
params.Set("dataId", dataID)
if family == nacosAPIV3 {
params.Set("groupName", group)
params.Set("namespaceId", normalizeNamespaceID(namespaceID))
} else if family == nacosAPIV2 {
params.Set("group", group)
params.Set("namespaceId", normalizeNamespaceID(namespaceID))
} else {
params.Set("group", group)
params.Set("tenant", normalizeNamespaceID(namespaceID))
}
body, status, err := c.doRequest(ctx, http.MethodDelete, c.currentAPIRoutes().config, params, nil)
if err != nil {
return err
}
return parseNacosBoolResult(body, status, "nacos.backend.error.delete_failed")
}
// GetBetaConfig loads beta/gray config if present.
func (c *ClientImpl) GetBetaConfig(ctx context.Context, namespaceID, group, dataID string) (*BetaConfigDetail, error) {
family := c.currentAPIFamily()
dataID = strings.TrimSpace(dataID)
group = strings.TrimSpace(group)
if dataID == "" {
return nil, localizedNacosBackendError("nacos.backend.error.data_id_required", nil)
}
if group == "" {
group = "DEFAULT_GROUP"
}
params := url.Values{}
params.Set("dataId", dataID)
if family == nacosAPIV3 {
params.Set("groupName", group)
params.Set("namespaceId", normalizeNamespaceID(namespaceID))
} else {
params.Set("group", group)
params.Set("tenant", normalizeNamespaceID(namespaceID))
params.Set("beta", "true")
}
body, status, err := c.doRequest(ctx, http.MethodGet, c.currentAPIRoutes().beta, params, nil)
if err != nil {
return nil, err
}
if status == http.StatusNotFound {
return &BetaConfigDetail{
DataID: dataID,
Group: group,
NamespaceID: normalizeNamespaceID(namespaceID),
Exists: false,
}, nil
}
if status < 200 || status >= 300 {
// Some versions return 400/500 when beta does not exist; treat common empty cases as missing.
text := strings.TrimSpace(string(body))
if text == "" || strings.Contains(strings.ToLower(text), "not found") || strings.Contains(text, "config data not exist") {
return &BetaConfigDetail{
DataID: dataID,
Group: group,
NamespaceID: normalizeNamespaceID(namespaceID),
Exists: false,
}, nil
}
return nil, localizedNacosBackendError("nacos.backend.error.http_status", map[string]any{
"status": status,
"body": truncateForError(text),
})
}
data, err := unwrapNacosResult(body)
if err != nil {
return nil, err
}
trimmed := strings.TrimSpace(string(data))
if trimmed == "" {
return &BetaConfigDetail{
DataID: dataID,
Group: group,
NamespaceID: normalizeNamespaceID(namespaceID),
Exists: false,
}, nil
}
if strings.HasPrefix(trimmed, "{") {
var payload struct {
DataID string `json:"dataId"`
Group string `json:"group"`
GroupName string `json:"groupName"`
Content string `json:"content"`
Type string `json:"type"`
MD5 string `json:"md5"`
BetaIPs string `json:"betaIps"`
GrayRule string `json:"grayRule"`
Tenant string `json:"tenant"`
NamespaceID string `json:"namespaceId"`
}
if err := json.Unmarshal(data, &payload); err == nil {
content := payload.Content
if strings.TrimSpace(content) == "" && strings.TrimSpace(payload.DataID) == "" {
return &BetaConfigDetail{
DataID: dataID,
Group: group,
NamespaceID: normalizeNamespaceID(namespaceID),
Exists: false,
}, nil
}
md5Value := strings.TrimSpace(payload.MD5)
if md5Value == "" {
md5Value = ContentMD5(content)
}
return &BetaConfigDetail{
DataID: firstNonEmpty(payload.DataID, dataID),
Group: firstNonEmpty(payload.Group, payload.GroupName, group),
NamespaceID: normalizeNamespaceID(firstNonEmpty(payload.Tenant, payload.NamespaceID, namespaceID)),
Content: content,
Type: strings.TrimSpace(payload.Type),
MD5: md5Value,
BetaIPs: firstNonEmpty(strings.TrimSpace(payload.BetaIPs), betaIPsFromGrayRule(payload.GrayRule)),
Exists: true,
}, nil
}
}
return &BetaConfigDetail{
DataID: dataID,
Group: group,
NamespaceID: normalizeNamespaceID(namespaceID),
Content: string(data),
MD5: ContentMD5(string(data)),
Exists: true,
}, nil
}
func betaIPsFromGrayRule(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
var rule struct {
Expr string `json:"expr"`
RawGrayRuleExp string `json:"rawGrayRuleExp"`
}
if err := json.Unmarshal([]byte(raw), &rule); err == nil {
return firstNonEmpty(strings.TrimSpace(rule.Expr), strings.TrimSpace(rule.RawGrayRuleExp), raw)
}
return raw
}
// StopBetaConfig stops beta/gray publish for a config.
func (c *ClientImpl) StopBetaConfig(ctx context.Context, namespaceID, group, dataID string) error {
family := c.currentAPIFamily()
dataID = strings.TrimSpace(dataID)
group = strings.TrimSpace(group)
if dataID == "" {
return localizedNacosBackendError("nacos.backend.error.data_id_required", nil)
}
if group == "" {
group = "DEFAULT_GROUP"
}
params := url.Values{}
params.Set("dataId", dataID)
if family == nacosAPIV3 {
params.Set("groupName", group)
params.Set("namespaceId", normalizeNamespaceID(namespaceID))
} else {
params.Set("group", group)
params.Set("tenant", normalizeNamespaceID(namespaceID))
params.Set("beta", "true")
}
body, status, err := c.doRequest(ctx, http.MethodDelete, c.currentAPIRoutes().beta, params, nil)
if err != nil {
return err
}
return parseNacosBoolResult(body, status, "nacos.backend.error.beta_stop_failed")
}
// CreateNamespace creates a namespace.
func (c *ClientImpl) CreateNamespace(ctx context.Context, req CreateNamespaceRequest) error {
family := c.currentAPIFamily()
showName := strings.TrimSpace(req.ShowName)
if showName == "" {
return localizedNacosBackendError("nacos.backend.error.namespace_name_required", nil)
}
nsID := strings.TrimSpace(req.ID)
if strings.EqualFold(nsID, "public") || nsID == "" && strings.EqualFold(showName, "public") {
// Creating another "public" is not allowed; empty id with non-public name is ok.
if strings.EqualFold(showName, "public") {
return localizedNacosBackendError("nacos.backend.error.namespace_public_reserved", nil)
}
}
form := url.Values{}
if family == nacosAPIV1 {
form.Set("customNamespaceId", nsID)
} else {
form.Set("namespaceId", nsID)
}
form.Set("namespaceName", showName)
form.Set("namespaceDesc", strings.TrimSpace(req.Description))
body, status, err := c.doRequest(ctx, http.MethodPost, c.currentAPIRoutes().namespace, nil, form)
if err != nil {
return err
}
return parseNacosBoolResult(body, status, "nacos.backend.error.namespace_create_failed")
}
// UpdateNamespace updates namespace show name / description.
func (c *ClientImpl) UpdateNamespace(ctx context.Context, req UpdateNamespaceRequest) error {
family := c.currentAPIFamily()
nsID := strings.TrimSpace(req.ID)
// public is represented as empty id; do not allow renaming public id, but updating show name is usually blocked by server.
if nsID == "" || strings.EqualFold(nsID, "public") {
return localizedNacosBackendError("nacos.backend.error.namespace_public_immutable", nil)
}
showName := strings.TrimSpace(req.ShowName)
if showName == "" {
return localizedNacosBackendError("nacos.backend.error.namespace_name_required", nil)
}
form := url.Values{}
if family == nacosAPIV1 {
form.Set("namespace", nsID)
form.Set("namespaceShowName", showName)
} else {
form.Set("namespaceId", nsID)
form.Set("namespaceName", showName)
}
form.Set("namespaceDesc", strings.TrimSpace(req.Description))
body, status, err := c.doRequest(ctx, http.MethodPut, c.currentAPIRoutes().namespace, nil, form)
if err != nil {
return err
}
return parseNacosBoolResult(body, status, "nacos.backend.error.namespace_update_failed")
}
// DeleteNamespace deletes a namespace by id.
func (c *ClientImpl) DeleteNamespace(ctx context.Context, namespaceID string) error {
nsID := strings.TrimSpace(namespaceID)
if nsID == "" || strings.EqualFold(nsID, "public") {
return localizedNacosBackendError("nacos.backend.error.namespace_public_immutable", nil)
}
// Prefer query parameters: Go's ParseForm ignores DELETE bodies, and many
// Nacos deployments accept namespaceId on the query string.
params := url.Values{}
params.Set("namespaceId", nsID)
body, status, err := c.doRequest(ctx, http.MethodDelete, c.currentAPIRoutes().namespace, params, nil)
if err != nil {
return err
}
return parseNacosBoolResult(body, status, "nacos.backend.error.namespace_delete_failed")
}
// ListConfigHistory lists history records for a config.
func (c *ClientImpl) ListConfigHistory(ctx context.Context, query HistoryQuery) (*HistoryPage, error) {
family := c.currentAPIFamily()
dataID := strings.TrimSpace(query.DataID)
group := strings.TrimSpace(query.Group)
if dataID == "" {
return nil, localizedNacosBackendError("nacos.backend.error.data_id_required", nil)
}
if group == "" {
group = "DEFAULT_GROUP"
}
pageNo := query.PageNo
if pageNo <= 0 {
pageNo = 1
}
pageSize := query.PageSize
if pageSize <= 0 {
pageSize = defaultConfigPageSize
}
if pageSize > maxConfigPageSize {
pageSize = maxConfigPageSize
}
params := url.Values{}
params.Set("dataId", dataID)
params.Set("pageNo", strconv.Itoa(pageNo))
params.Set("pageSize", strconv.Itoa(pageSize))
if family == nacosAPIV3 {
params.Set("groupName", group)
params.Set("namespaceId", normalizeNamespaceID(query.NamespaceID))
} else if family == nacosAPIV2 {
params.Set("group", group)
params.Set("namespaceId", normalizeNamespaceID(query.NamespaceID))
} else {
params.Set("search", "accurate")
params.Set("group", group)
params.Set("tenant", normalizeNamespaceID(query.NamespaceID))
}
body, status, err := c.doRequest(ctx, http.MethodGet, c.currentAPIRoutes().historyList, params, nil)
if err != nil {
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)),
})
}
data, err := unwrapNacosResult(body)
if err != nil {
return nil, err
}
var payload struct {
TotalCount int64 `json:"totalCount"`
PageNumber int `json:"pageNumber"`
PagesAvailable int `json:"pagesAvailable"`
PageItems []struct {
ID any `json:"id"`
LastID any `json:"lastId"`
DataID string `json:"dataId"`
Group string `json:"group"`
GroupName string `json:"groupName"`
Tenant string `json:"tenant"`
NamespaceID string `json:"namespaceId"`
AppName string `json:"appName"`
MD5 string `json:"md5"`
Content string `json:"content"`
SrcIP string `json:"srcIp"`
SrcUser string `json:"srcUser"`
OpType string `json:"opType"`
CreatedTime any `json:"createdTime"`
CreateTime any `json:"createTime"`
LastModifiedTime any `json:"lastModifiedTime"`
ModifyTime any `json:"modifyTime"`
} `json:"pageItems"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return nil, localizedNacosBackendError("nacos.backend.error.parse_history", map[string]any{
"detail": err.Error(),
})
}
items := make([]HistoryItem, 0, len(payload.PageItems))
for _, item := range payload.PageItems {
items = append(items, HistoryItem{
ID: stringifyAnyID(item.ID),
LastID: stringifyAnyID(item.LastID),
DataID: strings.TrimSpace(item.DataID),
Group: firstNonEmpty(strings.TrimSpace(item.Group), strings.TrimSpace(item.GroupName)),
NamespaceID: normalizeNamespaceID(firstNonEmpty(item.Tenant, item.NamespaceID)),
AppName: strings.TrimSpace(item.AppName),
MD5: strings.TrimSpace(item.MD5),
Content: item.Content,
SrcIP: strings.TrimSpace(item.SrcIP),
SrcUser: strings.TrimSpace(item.SrcUser),
OpType: strings.TrimSpace(item.OpType),
CreatedTime: stringifyAnyTime(item.CreatedTime, item.CreateTime),
ModifiedTime: stringifyAnyTime(item.LastModifiedTime, item.ModifyTime),
})
}
return &HistoryPage{
TotalCount: payload.TotalCount,
PageNumber: payload.PageNumber,
PagesAvailable: payload.PagesAvailable,
PageItems: items,
}, nil
}
// GetConfigHistory loads one history detail by nid.
func (c *ClientImpl) GetConfigHistory(ctx context.Context, namespaceID, group, dataID, nid string) (*HistoryItem, error) {
family := c.currentAPIFamily()
dataID = strings.TrimSpace(dataID)
group = strings.TrimSpace(group)
nid = strings.TrimSpace(nid)
if dataID == "" {
return nil, localizedNacosBackendError("nacos.backend.error.data_id_required", nil)
}
if nid == "" {
return nil, localizedNacosBackendError("nacos.backend.error.history_id_required", nil)
}
if group == "" {
group = "DEFAULT_GROUP"
}
params := url.Values{}
params.Set("nid", nid)
params.Set("dataId", dataID)
if family == nacosAPIV3 {
params.Set("groupName", group)
params.Set("namespaceId", normalizeNamespaceID(namespaceID))
} else if family == nacosAPIV2 {
params.Set("group", group)
params.Set("namespaceId", normalizeNamespaceID(namespaceID))
} else {
params.Set("group", group)
params.Set("tenant", normalizeNamespaceID(namespaceID))
}
body, status, err := c.doRequest(ctx, http.MethodGet, c.currentAPIRoutes().configHistory, params, nil)
if err != nil {
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)),
})
}
data, err := unwrapNacosResult(body)
if err != nil {
return nil, err
}
var payload struct {
ID any `json:"id"`
LastID any `json:"lastId"`
DataID string `json:"dataId"`
Group string `json:"group"`
GroupName string `json:"groupName"`
Tenant string `json:"tenant"`
NamespaceID string `json:"namespaceId"`
AppName string `json:"appName"`
MD5 string `json:"md5"`
Content string `json:"content"`
SrcIP string `json:"srcIp"`
SrcUser string `json:"srcUser"`
OpType string `json:"opType"`
CreatedTime any `json:"createdTime"`
CreateTime any `json:"createTime"`
LastModifiedTime any `json:"lastModifiedTime"`
ModifyTime any `json:"modifyTime"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return nil, localizedNacosBackendError("nacos.backend.error.parse_history", map[string]any{
"detail": err.Error(),
})
}
return &HistoryItem{
ID: firstNonEmpty(stringifyAnyID(payload.ID), nid),
LastID: stringifyAnyID(payload.LastID),
DataID: firstNonEmpty(strings.TrimSpace(payload.DataID), dataID),
Group: firstNonEmpty(strings.TrimSpace(payload.Group), strings.TrimSpace(payload.GroupName), group),
NamespaceID: normalizeNamespaceID(firstNonEmpty(payload.Tenant, payload.NamespaceID, namespaceID)),
AppName: strings.TrimSpace(payload.AppName),
MD5: strings.TrimSpace(payload.MD5),
Content: payload.Content,
SrcIP: strings.TrimSpace(payload.SrcIP),
SrcUser: strings.TrimSpace(payload.SrcUser),
OpType: strings.TrimSpace(payload.OpType),
CreatedTime: stringifyAnyTime(payload.CreatedTime, payload.CreateTime),
ModifiedTime: stringifyAnyTime(payload.LastModifiedTime, payload.ModifyTime),
}, nil
}
func parseNacosBoolResult(body []byte, status int, failKey string) error {
if status < 200 || status >= 300 {
return localizedNacosBackendError("nacos.backend.error.http_status", map[string]any{
"status": status,
"body": truncateForError(string(body)),
})
}
text := strings.TrimSpace(string(body))
if text == "true" || text == "" || strings.EqualFold(text, "ok") {
return nil
}
var boolResult bool
if err := json.Unmarshal(body, &boolResult); err == nil && boolResult {
return nil
}
// Some console APIs return {"code":200,...}
var wrapped struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
}
if err := json.Unmarshal(body, &wrapped); err == nil && (wrapped.Code == 0 || wrapped.Code == 200) {
switch v := wrapped.Data.(type) {
case bool:
if v {
return nil
}
case string:
if v == "true" || v == "" {
return nil
}
case nil:
return nil
}
}
return localizedNacosBackendError(failKey, map[string]any{
"body": truncateForError(text),
})
}
func stringifyAnyID(value any) string {
switch v := value.(type) {
case nil:
return ""
case string:
return strings.TrimSpace(v)
case float64:
// avoid scientific notation for large ids
return strconv.FormatInt(int64(v), 10)
case json.Number:
return v.String()
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
func (c *ClientImpl) ensureAuth(ctx context.Context) error {
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))
c.mu.Unlock()
if !needLogin {
return nil
}
if tokenValid {
return nil
}
return c.login(ctx, username, password)
}
func (c *ClientImpl) login(ctx context.Context, username, password string) error {
query := url.Values{}
query.Set("username", username)
form := url.Values{}
form.Set("password", password)
var (
body []byte
status int
err error
)
loginPaths := []string{"/v3/auth/user/login", "/v1/auth/users/login", "/v1/auth/login"}
for index, loginPath := range loginPaths {
body, status, err = c.doRequestRaw(ctx, http.MethodPost, loginPath, query, form, false)
if err != nil {
return err
}
if status >= 200 && status < 300 {
break
}
if index < len(loginPaths)-1 && isUnsupportedNacosLogin(status) {
continue
}
return localizedNacosBackendError("nacos.backend.error.login_failed", map[string]any{
"status": status,
"body": truncateForError(string(body)),
})
}
var payload struct {
AccessToken string `json:"accessToken"`
TokenTtl int64 `json:"tokenTtl"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return 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)
}
ttl := payload.TokenTtl
if ttl <= 0 {
ttl = 18000
}
c.mu.Lock()
c.accessToken = token
c.tokenExpiry = time.Now().Add(time.Duration(ttl) * time.Second)
c.mu.Unlock()
return nil
}
func isUnsupportedNacosLogin(status int) bool {
return status == http.StatusNotFound ||
status == http.StatusMethodNotAllowed ||
status == http.StatusNotImplemented
}
func (c *ClientImpl) doRequest(ctx context.Context, method, apiPath string, query url.Values, form url.Values) ([]byte, int, error) {
return c.doRequestWithHeaders(ctx, method, apiPath, query, form, nil)
}
func (c *ClientImpl) doRequestWithHeaders(
ctx context.Context,
method, apiPath string,
query url.Values,
form url.Values,
headers http.Header,
) ([]byte, int, error) {
if err := c.ensureAuth(ctx); err != nil {
return nil, 0, err
}
body, status, err := c.doRequestRawWithHeaders(ctx, method, apiPath, query, form, headers, true)
if err != nil {
return nil, status, err
}
// Token expired mid-flight: force re-login once.
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, status, err
}
return c.doRequestRawWithHeaders(ctx, method, apiPath, query, form, headers, true)
}
return body, status, nil
}
func (c *ClientImpl) doRequestRaw(
ctx context.Context,
method, apiPath string,
query url.Values,
form url.Values,
withToken bool,
) ([]byte, int, error) {
return c.doRequestRawWithHeaders(ctx, method, apiPath, query, form, nil, withToken)
}
func (c *ClientImpl) doRequestRawWithHeaders(
ctx context.Context,
method, apiPath string,
query url.Values,
form url.Values,
headers http.Header,
withToken bool,
) ([]byte, int, error) {
c.mu.Lock()
httpClient := c.httpClient
baseURL := c.baseURL
token := c.accessToken
c.mu.Unlock()
if httpClient == nil || baseURL == nil {
return nil, 0, localizedNacosBackendError("nacos.backend.error.not_connected", nil)
}
rel := &url.URL{Path: joinAPIPath(baseURL.Path, apiPath)}
if query == nil {
query = url.Values{}
}
if withToken && strings.TrimSpace(token) != "" {
query.Set("accessToken", token)
}
rel.RawQuery = query.Encode()
fullURL := baseURL.ResolveReference(rel).String()
var bodyReader io.Reader
contentType := ""
if form != nil {
bodyReader = strings.NewReader(form.Encode())
contentType = "application/x-www-form-urlencoded"
}
req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader)
if err != nil {
return nil, 0, localizedNacosBackendError("nacos.backend.error.build_request", map[string]any{
"detail": err.Error(),
})
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
req.Header.Set("Accept", "*/*")
for name, values := range headers {
for _, value := range values {
req.Header.Add(name, value)
}
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, 0, localizedNacosBackendError("nacos.backend.error.request_failed", map[string]any{
"detail": err.Error(),
})
}
defer resp.Body.Close()
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{
"detail": err.Error(),
})
}
return body, resp.StatusCode, nil
}
func normalizeNacosConfig(config connection.ConnectionConfig) (connection.ConnectionConfig, error) {
run := config
run.Type = "nacos"
run.Host = strings.TrimSpace(run.Host)
if run.Host == "" {
return run, localizedNacosBackendError("nacos.backend.error.host_required", nil)
}
if run.Port <= 0 {
run.Port = defaultNacosPort
}
if run.Timeout <= 0 {
run.Timeout = int(defaultNacosTimeout / time.Second)
}
return run, nil
}
func buildNacosHTTPClient(config connection.ConnectionConfig) (*http.Client, *url.URL, error) {
scheme := "http"
if config.UseSSL || strings.EqualFold(strings.TrimSpace(config.SSLMode), "required") ||
strings.EqualFold(strings.TrimSpace(config.SSLMode), "preferred") {
scheme = "https"
}
contextPath := resolveNacosContextPath(config)
base, err := url.Parse(fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(config.Host, strconv.Itoa(config.Port))))
if err != nil {
return nil, nil, localizedNacosBackendError("nacos.backend.error.invalid_address", map[string]any{
"detail": err.Error(),
})
}
base.Path = contextPath
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).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 scheme == "https" {
sslMode := strings.ToLower(strings.TrimSpace(config.SSLMode))
insecure := sslMode == "skip-verify" || sslMode == "preferred" || sslMode == ""
tlsCfg, err := tlsconfig.BuildClientConfig(tlsconfig.ClientConfigOptions{
Enabled: true,
InsecureSkipVerify: insecure,
CAPath: config.SSLCAPath,
CertPath: config.SSLCertPath,
KeyPath: config.SSLKeyPath,
})
if err != nil {
return nil, nil, localizedNacosBackendError("nacos.backend.error.tls_setup_failed", map[string]any{
"detail": err.Error(),
})
}
if tlsCfg != nil {
transport.TLSClientConfig = tlsCfg
} else {
transport.TLSClientConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: insecure, //nolint:gosec
}
}
}
client := &http.Client{
Timeout: normalizeNacosTimeout(config.Timeout),
Transport: transport,
}
return client, base, nil
}
func resolveNacosContextPath(config connection.ConnectionConfig) string {
// Prefer connectionParams contextPath=...
params := parseSimpleKV(config.ConnectionParams)
if v := strings.TrimSpace(params["contextPath"]); v != "" {
return normalizeContextPath(v)
}
// Allow Database field to carry context path as a convenience.
if v := strings.TrimSpace(config.Database); v != "" && strings.Contains(v, "/") {
return normalizeContextPath(v)
}
return defaultNacosContextPath
}
func normalizeContextPath(raw string) string {
path := strings.TrimSpace(raw)
if path == "" || path == "/" {
return ""
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return strings.TrimRight(path, "/")
}
func joinAPIPath(basePath, apiPath string) string {
base := strings.TrimRight(strings.TrimSpace(basePath), "/")
api := strings.TrimSpace(apiPath)
if api == "" {
return base
}
if !strings.HasPrefix(api, "/") {
api = "/" + api
}
return base + api
}
func normalizeNamespaceID(raw string) string {
id := strings.TrimSpace(raw)
if strings.EqualFold(id, "public") {
return ""
}
return id
}
func normalizeNacosTimeout(seconds int) time.Duration {
if seconds <= 0 {
return defaultNacosTimeout
}
return time.Duration(seconds) * time.Second
}
func parseSimpleKV(raw string) map[string]string {
result := make(map[string]string)
text := strings.TrimSpace(raw)
if text == "" {
return result
}
// Support both "a=b&c=d" and "a=b;c=d" and newline-separated pairs.
replacer := strings.NewReplacer(";", "&", "\n", "&")
text = replacer.Replace(text)
values, err := url.ParseQuery(text)
if err == nil {
for key, vals := range values {
if len(vals) > 0 {
result[strings.TrimSpace(key)] = strings.TrimSpace(vals[0])
}
}
return result
}
for _, part := range strings.Split(text, "&") {
key, value, ok := strings.Cut(part, "=")
if !ok {
continue
}
result[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
return result
}
func truncateForError(text string) string {
const max = 400
trimmed := strings.TrimSpace(text)
if len(trimmed) <= max {
return trimmed
}
return trimmed[:max] + "..."
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func stringifyAnyTime(values ...any) string {
for _, v := range values {
switch t := v.(type) {
case nil:
continue
case string:
if s := strings.TrimSpace(t); s != "" {
return s
}
case float64:
// millis or seconds
if t > 1e12 {
return time.UnixMilli(int64(t)).Format(time.RFC3339)
}
if t > 0 {
return time.Unix(int64(t), 0).Format(time.RFC3339)
}
case json.Number:
if i, err := t.Int64(); err == nil {
if i > 1e12 {
return time.UnixMilli(i).Format(time.RFC3339)
}
if i > 0 {
return time.Unix(i, 0).Format(time.RFC3339)
}
}
default:
s := strings.TrimSpace(fmt.Sprint(t))
if s != "" && s != "<nil>" {
return s
}
}
}
return ""
}