diff --git a/internal/api/server.go b/internal/api/server.go index 5e194c56..e1e7a14d 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -952,10 +952,6 @@ func (s *Server) UpdateClients(cfg *config.Config) { s.handlers.UpdateClients(&cfg.SDKConfig) - if !cfg.RemoteManagement.DisableControlPanel { - staticDir := managementasset.StaticDir(s.configFilePath) - go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir, cfg.ProxyURL, cfg.RemoteManagement.PanelGitHubRepository) - } if s.mgmt != nil { s.mgmt.SetConfig(cfg) s.mgmt.SetAuthManager(s.handlers.AuthManager) diff --git a/internal/config/config.go b/internal/config/config.go index 706bb991..fec58fe5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1098,8 +1098,13 @@ func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node { // mergeMappingPreserve merges keys from src into dst mapping node while preserving // key order and comments of existing keys in dst. New keys are only added if their -// value is non-zero to avoid polluting the config with defaults. -func mergeMappingPreserve(dst, src *yaml.Node) { +// value is non-zero and not a known default to avoid polluting the config with defaults. +func mergeMappingPreserve(dst, src *yaml.Node, path ...[]string) { + var currentPath []string + if len(path) > 0 { + currentPath = path[0] + } + if dst == nil || src == nil { return } @@ -1113,16 +1118,19 @@ func mergeMappingPreserve(dst, src *yaml.Node) { sk := src.Content[i] sv := src.Content[i+1] idx := findMapKeyIndex(dst, sk.Value) + childPath := appendPath(currentPath, sk.Value) if idx >= 0 { // Merge into existing value node (always update, even to zero values) dv := dst.Content[idx+1] - mergeNodePreserve(dv, sv) + mergeNodePreserve(dv, sv, childPath) } else { - // New key: only add if value is non-zero to avoid polluting config with defaults - if isZeroValueNode(sv) { + // New key: only add if value is non-zero and not a known default + candidate := deepCopyNode(sv) + pruneKnownDefaultsInNewNode(childPath, candidate) + if isKnownDefaultValue(childPath, candidate) { continue } - dst.Content = append(dst.Content, deepCopyNode(sk), deepCopyNode(sv)) + dst.Content = append(dst.Content, deepCopyNode(sk), candidate) } } } @@ -1130,7 +1138,12 @@ func mergeMappingPreserve(dst, src *yaml.Node) { // mergeNodePreserve merges src into dst for scalars, mappings and sequences while // reusing destination nodes to keep comments and anchors. For sequences, it updates // in-place by index. -func mergeNodePreserve(dst, src *yaml.Node) { +func mergeNodePreserve(dst, src *yaml.Node, path ...[]string) { + var currentPath []string + if len(path) > 0 { + currentPath = path[0] + } + if dst == nil || src == nil { return } @@ -1139,7 +1152,7 @@ func mergeNodePreserve(dst, src *yaml.Node) { if dst.Kind != yaml.MappingNode { copyNodeShallow(dst, src) } - mergeMappingPreserve(dst, src) + mergeMappingPreserve(dst, src, currentPath) case yaml.SequenceNode: // Preserve explicit null style if dst was null and src is empty sequence if dst.Kind == yaml.ScalarNode && dst.Tag == "!!null" && len(src.Content) == 0 { @@ -1162,7 +1175,7 @@ func mergeNodePreserve(dst, src *yaml.Node) { dst.Content[i] = deepCopyNode(src.Content[i]) continue } - mergeNodePreserve(dst.Content[i], src.Content[i]) + mergeNodePreserve(dst.Content[i], src.Content[i], currentPath) if dst.Content[i] != nil && src.Content[i] != nil && dst.Content[i].Kind == yaml.MappingNode && src.Content[i].Kind == yaml.MappingNode { pruneMissingMapKeys(dst.Content[i], src.Content[i]) @@ -1204,6 +1217,94 @@ func findMapKeyIndex(mapNode *yaml.Node, key string) int { return -1 } +// appendPath appends a key to the path, returning a new slice to avoid modifying the original. +func appendPath(path []string, key string) []string { + if len(path) == 0 { + return []string{key} + } + newPath := make([]string, len(path)+1) + copy(newPath, path) + newPath[len(path)] = key + return newPath +} + +// isKnownDefaultValue returns true if the given node at the specified path +// represents a known default value that should not be written to the config file. +// This prevents non-zero defaults from polluting the config. +func isKnownDefaultValue(path []string, node *yaml.Node) bool { + // First check if it's a zero value + if isZeroValueNode(node) { + return true + } + + // Match known non-zero defaults by exact dotted path. + if len(path) == 0 { + return false + } + + fullPath := strings.Join(path, ".") + + // Check string defaults + if node.Kind == yaml.ScalarNode && node.Tag == "!!str" { + switch fullPath { + case "pprof.addr": + return node.Value == DefaultPprofAddr + case "remote-management.panel-github-repository": + return node.Value == DefaultPanelGitHubRepository + case "routing.strategy": + return node.Value == "round-robin" + } + } + + // Check integer defaults + if node.Kind == yaml.ScalarNode && node.Tag == "!!int" { + switch fullPath { + case "error-logs-max-files": + return node.Value == "10" + } + } + + return false +} + +// pruneKnownDefaultsInNewNode removes default-valued descendants from a new node +// before it is appended into the destination YAML tree. +func pruneKnownDefaultsInNewNode(path []string, node *yaml.Node) { + if node == nil { + return + } + + switch node.Kind { + case yaml.MappingNode: + filtered := make([]*yaml.Node, 0, len(node.Content)) + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode := node.Content[i] + valueNode := node.Content[i+1] + if keyNode == nil || valueNode == nil { + continue + } + + childPath := appendPath(path, keyNode.Value) + if isKnownDefaultValue(childPath, valueNode) { + continue + } + + pruneKnownDefaultsInNewNode(childPath, valueNode) + if (valueNode.Kind == yaml.MappingNode || valueNode.Kind == yaml.SequenceNode) && + len(valueNode.Content) == 0 { + continue + } + + filtered = append(filtered, keyNode, valueNode) + } + node.Content = filtered + case yaml.SequenceNode: + for _, child := range node.Content { + pruneKnownDefaultsInNewNode(path, child) + } + } +} + // isZeroValueNode returns true if the YAML node represents a zero/default value // that should not be written as a new key to preserve config cleanliness. // For mappings and sequences, recursively checks if all children are zero values. diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go index c941da02..2fbaab12 100644 --- a/internal/managementasset/updater.go +++ b/internal/managementasset/updater.go @@ -28,6 +28,7 @@ const ( defaultManagementFallbackURL = "https://cpamc.router-for.me/" managementAssetName = "management.html" httpUserAgent = "CLIProxyAPI-management-updater" + managementSyncMinInterval = 30 * time.Second updateCheckInterval = 3 * time.Hour ) @@ -37,9 +38,7 @@ const ManagementFileName = managementAssetName var ( lastUpdateCheckMu sync.Mutex lastUpdateCheckTime time.Time - currentConfigPtr atomic.Pointer[config.Config] - disableControlPanel atomic.Bool schedulerOnce sync.Once schedulerConfigPath atomic.Value ) @@ -50,16 +49,7 @@ func SetCurrentConfig(cfg *config.Config) { currentConfigPtr.Store(nil) return } - - prevDisabled := disableControlPanel.Load() currentConfigPtr.Store(cfg) - disableControlPanel.Store(cfg.RemoteManagement.DisableControlPanel) - - if prevDisabled && !cfg.RemoteManagement.DisableControlPanel { - lastUpdateCheckMu.Lock() - lastUpdateCheckTime = time.Time{} - lastUpdateCheckMu.Unlock() - } } // StartAutoUpdater launches a background goroutine that periodically ensures the management asset is up to date. @@ -92,7 +82,7 @@ func runAutoUpdater(ctx context.Context) { log.Debug("management asset auto-updater skipped: config not yet available") return } - if disableControlPanel.Load() { + if cfg.RemoteManagement.DisableControlPanel { log.Debug("management asset auto-updater skipped: control panel disabled") return } @@ -182,23 +172,32 @@ func FilePath(configFilePath string) string { // EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed. // The function is designed to run in a background goroutine and will never panic. -// It enforces a 3-hour rate limit to avoid frequent checks on config/auth file changes. func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string, panelRepository string) { if ctx == nil { ctx = context.Background() } - if disableControlPanel.Load() { - log.Debug("management asset sync skipped: control panel disabled by configuration") - return - } - staticDir = strings.TrimSpace(staticDir) if staticDir == "" { log.Debug("management asset sync skipped: empty static directory") return } + lastUpdateCheckMu.Lock() + now := time.Now() + timeSinceLastAttempt := now.Sub(lastUpdateCheckTime) + if !lastUpdateCheckTime.IsZero() && timeSinceLastAttempt < managementSyncMinInterval { + lastUpdateCheckMu.Unlock() + log.Debugf( + "management asset sync skipped by throttle: last attempt %v ago (interval %v)", + timeSinceLastAttempt.Round(time.Second), + managementSyncMinInterval, + ) + return + } + lastUpdateCheckTime = now + lastUpdateCheckMu.Unlock() + localPath := filepath.Join(staticDir, managementAssetName) localFileMissing := false if _, errStat := os.Stat(localPath); errStat != nil { @@ -209,18 +208,6 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL } } - // Rate limiting: check only once every 3 hours - lastUpdateCheckMu.Lock() - now := time.Now() - timeSinceLastCheck := now.Sub(lastUpdateCheckTime) - if timeSinceLastCheck < updateCheckInterval { - lastUpdateCheckMu.Unlock() - log.Debugf("management asset update check skipped: last check was %v ago (interval: %v)", timeSinceLastCheck.Round(time.Second), updateCheckInterval) - return - } - lastUpdateCheckTime = now - lastUpdateCheckMu.Unlock() - if errMkdirAll := os.MkdirAll(staticDir, 0o755); errMkdirAll != nil { log.WithError(errMkdirAll).Warn("failed to prepare static directory for management asset") return