From 7529fca29d9460c958b84081afd5c956319e7b0f Mon Sep 17 00:00:00 2001 From: bugkeep <1921817430@qq.com> Date: Sat, 25 Jul 2026 21:53:49 +0800 Subject: [PATCH] fix: harden app store install defaults --- controllers/helm.go | 6 +- store/helm.go | 135 +++++++++++++--- store/helm_compatibility.go | 3 + store/helm_install_values.go | 303 +++++++++++++++++++++++++++++++++++ 4 files changed, 423 insertions(+), 24 deletions(-) create mode 100644 store/helm_install_values.go diff --git a/controllers/helm.go b/controllers/helm.go index 88e8a92..8a0b16a 100644 --- a/controllers/helm.go +++ b/controllers/helm.go @@ -134,7 +134,7 @@ func (c *ApiController) GetRepoCharts() { c.ResponseError("url is required") return } - charts, err := store.FetchRepoIndex(repoURL) + charts, err := store.FetchRepoIndexWithContext(c.Ctx.Request.Context(), repoURL) if err != nil { c.ResponseError(err.Error()) return @@ -144,7 +144,7 @@ func (c *ApiController) GetRepoCharts() { // ---------- Chart values (via store/Helm SDK) ---------- -// GetHelmChartValues fetches the default values.yaml for a chart. +// GetHelmChartValues fetches the values.yaml shown in the App Store install dialog. // @router /api/get-helm-chart-values [get] func (c *ApiController) GetHelmChartValues() { if c.RequireSignedIn() { @@ -157,7 +157,7 @@ func (c *ApiController) GetHelmChartValues() { c.ResponseError("chart and repo are required") return } - values, err := store.GetHelmChartDefaultValues(chartName, repoURL, version) + values, err := store.GetHelmChartInstallValuesWithContext(c.Ctx.Request.Context(), chartName, repoURL, version) if err != nil { c.ResponseError(err.Error()) return diff --git a/store/helm.go b/store/helm.go index 9227444..242dcac 100644 --- a/store/helm.go +++ b/store/helm.go @@ -3,8 +3,10 @@ package store import ( "bytes" "context" + "errors" "fmt" "io" + "net" "net/http" "net/url" "sort" @@ -38,6 +40,7 @@ import ( clientcmdapi "k8s.io/client-go/tools/clientcmd/api" "gopkg.in/yaml.v3" + "oras.land/oras-go/v2/registry/remote/errcode" sigsyaml "sigs.k8s.io/yaml" proxypkg "github.com/casosorg/casos/proxy" @@ -248,20 +251,27 @@ func shouldKeepHelmKubePrerelease(preRelease string) bool { // ---------- HTTP helper ---------- -func helmGet(url string) (*http.Response, error) { - req, err := http.NewRequest(http.MethodGet, url, nil) +func helmGet(ctx context.Context, url string) (*http.Response, error) { + if ctx == nil { + ctx = context.Background() + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "Helm/3.21.2") - return proxypkg.ProxyHttpClient.Do(req) + client := proxypkg.ProxyHttpClient + if client == nil { + client = http.DefaultClient + } + return client.Do(req) } // ---------- Repo index ---------- -func fetchIndexFile(repoURL string) (*repo.IndexFile, error) { +func fetchIndexFile(ctx context.Context, repoURL string) (*repo.IndexFile, error) { indexURL := strings.TrimRight(repoURL, "/") + "/index.yaml" - resp, err := helmGet(indexURL) + resp, err := helmGet(ctx, indexURL) if err != nil { return nil, fmt.Errorf( "fetch index %q: %w", @@ -304,11 +314,15 @@ func fetchIndexFile(repoURL string) (*repo.IndexFile, error) { // FetchRepoIndex returns all charts listed in a Helm repo's index.yaml, or, for an // "oci://" repoURL, the single chart hosted at that OCI reference. func FetchRepoIndex(repoURL string) ([]HelmChartSummary, error) { + return FetchRepoIndexWithContext(context.Background(), repoURL) +} + +func FetchRepoIndexWithContext(ctx context.Context, repoURL string) ([]HelmChartSummary, error) { if isOCIRepo(repoURL) { - return fetchOCIChartSummary(repoURL) + return fetchOCIChartSummary(ctx, repoURL) } - idx, err := fetchIndexFile(repoURL) + idx, err := fetchIndexFile(ctx, repoURL) if err != nil { return nil, err } @@ -318,7 +332,7 @@ func FetchRepoIndex(repoURL string) ([]HelmChartSummary, error) { continue } v := versions[0] - if !isInstallableHelmChartMetadata(v.Metadata) { + if !isVisibleAppStoreChartMetadata(v.Metadata) { continue } charts = append(charts, HelmChartSummary{ @@ -392,9 +406,70 @@ func newOCIRegistryClient() (*registry.Client, error) { return registry.NewClient(registry.ClientOptHTTPClient(proxypkg.ProxyHttpClient)) } +func retryOCIRegistryOperation(ctx context.Context, operation func() error) error { + return retryOCIRegistryOperationWithDelays(ctx, operation, []time.Duration{ + 500 * time.Millisecond, + 1500 * time.Millisecond, + }) +} + +func retryOCIRegistryOperationWithDelays(ctx context.Context, operation func() error, delays []time.Duration) error { + if ctx == nil { + ctx = context.Background() + } + for attempt := 0; ; attempt++ { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + err := operation() + if err == nil { + return nil + } + if attempt >= len(delays) || !isRetryableOCIRegistryError(err) { + return err + } + timer := time.NewTimer(delays[attempt]) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return ctx.Err() + case <-timer.C: + } + } +} + +func isRetryableOCIRegistryError(err error) bool { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return true + } + var networkError net.Error + if errors.As(err, &networkError) && networkError.Timeout() { + return true + } + var operationError *net.OpError + if errors.As(err, &operationError) { + return true + } + var responseError *errcode.ErrorResponse + if errors.As(err, &responseError) { + return responseError.StatusCode == http.StatusRequestTimeout || + responseError.StatusCode == http.StatusTooManyRequests || + (responseError.StatusCode >= http.StatusInternalServerError && + responseError.StatusCode < 600) + } + return false +} + // pullOCIChart pulls the chart hosted at repoURL, resolving to the newest published // semver tag when version is empty. -func pullOCIChart(repoURL, version string) (*registry.PullResult, error) { +func pullOCIChartWithContext(ctx context.Context, repoURL, version string) (*registry.PullResult, error) { ref, resolvedVersion := resolveOCIChartRef(repoURL, version) rc, err := newOCIRegistryClient() @@ -404,7 +479,12 @@ func pullOCIChart(repoURL, version string) (*registry.PullResult, error) { if resolvedVersion == "" { if !strings.Contains(ref, "@") { - tags, err := rc.Tags(ref) + var tags []string + err = retryOCIRegistryOperation(ctx, func() error { + var tagsErr error + tags, tagsErr = rc.Tags(ref) + return tagsErr + }) if err != nil { return nil, fmt.Errorf( "list oci tags for %q: %w", @@ -430,7 +510,12 @@ func pullOCIChart(repoURL, version string) (*registry.PullResult, error) { pullRef = fmt.Sprintf("%s:%s", ref, resolvedVersion) } - pull, err := rc.Pull(pullRef, registry.PullOptWithChart(true)) + var pull *registry.PullResult + err = retryOCIRegistryOperation(ctx, func() error { + var pullErr error + pull, pullErr = rc.Pull(pullRef, registry.PullOptWithChart(true)) + return pullErr + }) if err != nil { return nil, fmt.Errorf( "pull oci chart %q: %w", @@ -465,16 +550,16 @@ func latestOCISemverTag(tags []string) string { return versionedTags[0].tag } -func loadOCIChart(repoURL, version string) (*chart.Chart, error) { - pull, err := pullOCIChart(repoURL, version) +func loadOCIChartWithContext(ctx context.Context, repoURL, version string) (*chart.Chart, error) { + pull, err := pullOCIChartWithContext(ctx, repoURL, version) if err != nil { return nil, err } return loader.LoadArchive(bytes.NewReader(pull.Chart.Data)) } -func fetchOCIChartSummary(repoURL string) ([]HelmChartSummary, error) { - pull, err := pullOCIChart(repoURL, "") +func fetchOCIChartSummary(ctx context.Context, repoURL string) ([]HelmChartSummary, error) { + pull, err := pullOCIChartWithContext(ctx, repoURL, "") if err != nil { return nil, err } @@ -482,7 +567,7 @@ func fetchOCIChartSummary(repoURL string) ([]HelmChartSummary, error) { if meta == nil { return nil, fmt.Errorf("chart metadata not found for %s", redactURLForError(repoURL)) } - if !isInstallableHelmChartMetadata(meta) { + if !isVisibleAppStoreChartMetadata(meta) { return []HelmChartSummary{}, nil } return []HelmChartSummary{ @@ -501,6 +586,10 @@ func isInstallableHelmChartMetadata(metadata *chart.Metadata) bool { return metadata != nil && !strings.EqualFold(strings.TrimSpace(metadata.Type), "library") } +func isVisibleAppStoreChartMetadata(metadata *chart.Metadata) bool { + return isInstallableHelmChartMetadata(metadata) && !metadata.Deprecated +} + // ---------- Chart loader ---------- func redactURLForError(raw string) string { @@ -682,8 +771,12 @@ func redactionCandidates(raw string) []string { } func loadChart(chartName, repoURL, version string) (*chart.Chart, error) { + return loadChartWithContext(context.Background(), chartName, repoURL, version) +} + +func loadChartWithContext(ctx context.Context, chartName, repoURL, version string) (*chart.Chart, error) { if isOCIRepo(repoURL) { - ch, err := loadOCIChart(repoURL, version) + ch, err := loadOCIChartWithContext(ctx, repoURL, version) if err != nil { return nil, fmt.Errorf( "load chart %q from OCI repo %q version %q: %w", @@ -696,7 +789,7 @@ func loadChart(chartName, repoURL, version string) (*chart.Chart, error) { return ch, nil } - idx, err := fetchIndexFile(repoURL) + idx, err := fetchIndexFile(ctx, repoURL) if err != nil { return nil, fmt.Errorf("load chart %q from repo %q version %q: fetch index.yaml failed: %w", chartName, redactURLForError(repoURL), version, err) } @@ -726,7 +819,7 @@ func loadChart(chartName, repoURL, version string) (*chart.Chart, error) { if ociVersion == "" { ociVersion = entry.Version } - ch, err := loadOCIChart(chartURL, ociVersion) + ch, err := loadOCIChartWithContext(ctx, chartURL, ociVersion) if err != nil { return nil, fmt.Errorf( "load chart %q from repo %q version %q: index.yaml resolved to OCI chart URL %q: %w", @@ -743,7 +836,7 @@ func loadChart(chartName, repoURL, version string) (*chart.Chart, error) { chartURL = strings.TrimRight(repoURL, "/") + "/" + strings.TrimLeft(chartURL, "/") } - resp, err := helmGet(chartURL) + resp, err := helmGet(ctx, chartURL) if err != nil { return nil, fmt.Errorf( "load chart %q from repo %q version %q: download chart archive %q failed: %w", @@ -1294,7 +1387,7 @@ func InstallHelmChartStream(ctx context.Context, lifecycle HelmInstallLifecycle, } return } - helmChart, err := loadChart(chartName, repoURL, version) + helmChart, err := loadChartWithContext(installCtx, chartName, repoURL, version) if err != nil { send("ERROR: " + err.Error()) if finishErr := lifecycle.Finish(err); finishErr != nil { diff --git a/store/helm_compatibility.go b/store/helm_compatibility.go index 6347d86..0f47ed7 100644 --- a/store/helm_compatibility.go +++ b/store/helm_compatibility.go @@ -17,6 +17,9 @@ func validateHelmChartCompatibility(ctx context.Context, cfg *rest.Config, actio if chartToInstall == nil || chartToInstall.Metadata == nil { return fmt.Errorf("chart metadata is missing") } + if chartToInstall.Metadata.Deprecated { + return fmt.Errorf("chart %s is deprecated and cannot be installed as a supported application", chartToInstall.Name()) + } if !isInstallableHelmChartMetadata(chartToInstall.Metadata) { return fmt.Errorf("chart %s is a library chart and cannot be installed as an application", chartToInstall.Name()) } diff --git a/store/helm_install_values.go b/store/helm_install_values.go new file mode 100644 index 0000000..73d511a --- /dev/null +++ b/store/helm_install_values.go @@ -0,0 +1,303 @@ +package store + +import ( + "context" + "fmt" + "strings" + + "gopkg.in/yaml.v3" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" +) + +const ( + bitnamiChartRepoURL = "https://charts.bitnami.com/bitnami" + bitnamiOCIChartRepoPrefix = "oci://registry-1.docker.io/bitnamicharts/" +) + +// GetHelmChartInstallValuesWithContext returns the values shown in the App +// Store install dialog. Raw chart defaults remain available through +// GetHelmChartDefaultValues. +func GetHelmChartInstallValuesWithContext(ctx context.Context, chartName, repoURL, version string) (string, error) { + ch, err := loadChartWithContext(ctx, chartName, repoURL, version) + if err != nil { + return "", err + } + + values, err := buildHelmChartInstallValues(ch, chartName, repoURL) + if err != nil { + return "", err + } + + data, err := yaml.Marshal(values) + if err != nil { + return "", err + } + return string(data), nil +} + +func buildHelmChartInstallValues(ch *chart.Chart, chartName, repoURL string) (map[string]interface{}, error) { + values := cloneHelmValues(ch.Values) + if isBitnamiCommunityChartRepo(repoURL) { + coalesced, err := chartutil.CoalesceValues(ch, values) + if err != nil { + return nil, fmt.Errorf("coalesce chart %s install values: %w", chartName, err) + } + values = map[string]interface{}(coalesced) + applyHelmDependencyDefaults(ch, values) + if err := applyBitnamiLegacyImageFallback(values); err != nil { + return nil, fmt.Errorf("apply Bitnami legacy image fallback: %w", err) + } + applyBitnamiAppStoreChartDefaults(chartName, values) + } + return values, nil +} + +func applyHelmDependencyDefaults(ch *chart.Chart, values map[string]interface{}) { + applyHelmDependencyDefaultsWithPath(ch, values, make(map[*chart.Chart]struct{})) +} + +func applyHelmDependencyDefaultsWithPath(ch *chart.Chart, values map[string]interface{}, path map[*chart.Chart]struct{}) { + if ch == nil || ch.Metadata == nil { + return + } + if _, seen := path[ch]; seen { + return + } + path[ch] = struct{}{} + defer delete(path, ch) + for _, requirement := range ch.Metadata.Dependencies { + if requirement == nil { + continue + } + dependency := findHelmDependencyChart(ch, requirement) + if dependency == nil { + continue + } + dependencyDefaults := cloneHelmValues(dependency.Values) + applyHelmDependencyDefaultsWithPath(dependency, dependencyDefaults, path) + + key := requirement.Name + if strings.TrimSpace(requirement.Alias) != "" { + key = requirement.Alias + } + target, ok := ensureHelmValuesMap(values, key) + if !ok { + continue + } + mergeMissingHelmValues(target, dependencyDefaults) + } +} + +func findHelmDependencyChart(parent *chart.Chart, requirement *chart.Dependency) *chart.Chart { + for _, dependency := range parent.Dependencies() { + if dependency == nil || dependency.Metadata == nil || dependency.Name() != requirement.Name { + continue + } + if requirement.Version == "" || chartutil.IsCompatibleRange(requirement.Version, dependency.Metadata.Version) { + return dependency + } + } + return nil +} + +func mergeMissingHelmValues(target, defaults map[string]interface{}) { + for key, defaultValue := range defaults { + currentValue, exists := target[key] + if !exists { + target[key] = cloneHelmValue(defaultValue) + continue + } + currentMap, currentIsMap := currentValue.(map[string]interface{}) + defaultMap, defaultIsMap := defaultValue.(map[string]interface{}) + if currentIsMap && defaultIsMap { + mergeMissingHelmValues(currentMap, defaultMap) + } + } +} + +func applyBitnamiAppStoreChartDefaults(chartName string, values map[string]interface{}) { + switch strings.ToLower(strings.TrimSpace(chartName)) { + case "tomcat": + values["tomcatInstallDefaultWebapps"] = true + case "concourse": + web, ok := ensureHelmValuesMap(values, "web") + if !ok { + return + } + if externalURL, _ := web["externalUrl"].(string); strings.TrimSpace(externalURL) == "" { + web["externalUrl"] = "concourse.local" + } + case "ghost": + if ghostHost, _ := values["ghostHost"].(string); strings.TrimSpace(ghostHost) == "" { + values["ghostHost"] = "ghost.local" + } + case "elasticsearch": + for _, component := range []string{"master", "data", "coordinating"} { + if componentValues, ok := ensureHelmValuesMap(values, component); ok { + componentValues["replicaCount"] = 1 + } + } + if ingest, ok := ensureHelmValuesMap(values, "ingest"); ok { + ingest["enabled"] = false + } + } +} + +// isBitnamiCommunityChartRepo intentionally limits compatibility rewrites to +// Bitnami's public community endpoints. Mirrors and private registries retain +// their operator-provided image policy unchanged. +func isBitnamiCommunityChartRepo(repoURL string) bool { + normalized := strings.ToLower(strings.TrimRight(strings.TrimSpace(repoURL), "/")) + return normalized == bitnamiChartRepoURL || strings.HasPrefix(normalized+"/", bitnamiOCIChartRepoPrefix) +} + +func applyBitnamiLegacyImageFallback(values map[string]interface{}) error { + if !wouldRewriteBitnamiLegacyImageRepositories(values, false) { + return nil + } + global, ok := ensureHelmValuesMap(values, "global") + if !ok { + return fmt.Errorf("global must be a map to enable allowInsecureImages") + } + security, ok := ensureHelmValuesMap(global, "security") + if !ok { + return fmt.Errorf("global.security must be a map to enable allowInsecureImages") + } + if !rewriteBitnamiLegacyImageRepositories(values, false) { + return nil + } + security["allowInsecureImages"] = true + return nil +} + +func wouldRewriteBitnamiLegacyImageRepositories(value interface{}, imageValues bool) bool { + switch typed := value.(type) { + case map[string]interface{}: + if imageValues { + if _, ok := bitnamiLegacyImageRepository(typed); ok { + return true + } + } + for key, child := range typed { + if wouldRewriteBitnamiLegacyImageRepositories(child, isHelmImageValuesKey(key)) { + return true + } + } + case []interface{}: + for _, child := range typed { + if wouldRewriteBitnamiLegacyImageRepositories(child, imageValues) { + return true + } + } + } + return false +} + +func rewriteBitnamiLegacyImageRepositories(value interface{}, imageValues bool) bool { + changed := false + switch typed := value.(type) { + case map[string]interface{}: + if imageValues && rewriteBitnamiLegacyImageRepository(typed) { + changed = true + } + for key, child := range typed { + if rewriteBitnamiLegacyImageRepositories(child, isHelmImageValuesKey(key)) { + changed = true + } + } + case []interface{}: + for _, child := range typed { + if rewriteBitnamiLegacyImageRepositories(child, imageValues) { + changed = true + } + } + } + return changed +} + +func isHelmImageValuesKey(key string) bool { + return strings.HasSuffix(strings.ToLower(strings.TrimSpace(key)), "image") +} + +func rewriteBitnamiLegacyImageRepository(values map[string]interface{}) bool { + repository, ok := bitnamiLegacyImageRepository(values) + if !ok { + return false + } + values["repository"] = repository + return true +} + +func bitnamiLegacyImageRepository(values map[string]interface{}) (string, bool) { + repository, ok := values["repository"].(string) + if !ok || !usesVersionedBitnamiImage(values) { + return "", false + } + registry, _ := values["registry"].(string) + registry = strings.ToLower(strings.TrimSpace(registry)) + if registry != "" && registry != "docker.io" && registry != "registry-1.docker.io" { + return "", false + } + switch { + case strings.HasPrefix(repository, "bitnami/"): + return "bitnamilegacy/" + strings.TrimPrefix(repository, "bitnami/"), true + case strings.HasPrefix(repository, "docker.io/bitnami/"): + return "docker.io/bitnamilegacy/" + strings.TrimPrefix(repository, "docker.io/bitnami/"), true + case strings.HasPrefix(repository, "registry-1.docker.io/bitnami/"): + return "registry-1.docker.io/bitnamilegacy/" + strings.TrimPrefix(repository, "registry-1.docker.io/bitnami/"), true + default: + return "", false + } +} + +func usesVersionedBitnamiImage(values map[string]interface{}) bool { + if digest, ok := values["digest"].(string); ok && strings.TrimSpace(digest) != "" { + return true + } + + tag, ok := values["tag"].(string) + if !ok { + return false + } + tag = strings.TrimSpace(tag) + return tag != "" && !strings.EqualFold(tag, "latest") +} + +func ensureHelmValuesMap(parent map[string]interface{}, key string) (map[string]interface{}, bool) { + if current, ok := parent[key].(map[string]interface{}); ok { + return current, true + } + if current, exists := parent[key]; exists && current != nil { + return nil, false + } + current := map[string]interface{}{} + parent[key] = current + return current, true +} + +func cloneHelmValues(values map[string]interface{}) map[string]interface{} { + if values == nil { + return map[string]interface{}{} + } + cloned := make(map[string]interface{}, len(values)) + for key, value := range values { + cloned[key] = cloneHelmValue(value) + } + return cloned +} + +func cloneHelmValue(value interface{}) interface{} { + switch typed := value.(type) { + case map[string]interface{}: + return cloneHelmValues(typed) + case []interface{}: + cloned := make([]interface{}, len(typed)) + for i, child := range typed { + cloned[i] = cloneHelmValue(child) + } + return cloned + default: + return typed + } +}