From 040e3ebab1f8f8065771ea8bc612bd68b45fc09b Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 28 Jul 2026 17:38:57 +0800 Subject: [PATCH] fix: fetcher proxy support, Dockerfile sing-box, rename index format 1. sslinks 403: Cloudflare blocks server IP. Add fetcher.proxy_url config so subscription/flow requests can route through an HTTP proxy. 2. Egress probe: Docker image lacked sing-box. Install sing-box v1.11.4 in the Alpine runtime stage. 3. Collection rename: group by display baseName instead of internal groupKey, format index as -N instead of %02d. Fixes double-suffix issue where EnsureUniqueProxyNames appended -2/-3 onto existing 02. --- Dockerfile | 7 +- config/config.example.yaml | 1 + internal/config/config.go | 2 + internal/filter/collection_rename.go | 103 ++++++++++------------ internal/filter/collection_rename_test.go | 8 +- internal/handler/download_tools.go | 28 +++++- internal/handler/handler_test.go | 2 +- internal/service/subscription.go | 23 ++++- 8 files changed, 110 insertions(+), 64 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1036509..2e75f6a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,12 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /sub-store . # ─── Stage 3: Runtime ─── FROM alpine:3.21 -RUN apk add --no-cache ca-certificates tzdata +RUN apk add --no-cache ca-certificates tzdata && \ + wget -qO /tmp/sing-box.tar.gz "https://github.com/SagerNet/sing-box/releases/download/v1.11.4/sing-box-1.11.4-linux-amd64.tar.gz" && \ + tar xzf /tmp/sing-box.tar.gz -C /tmp && \ + mv /tmp/sing-box-*/sing-box /usr/local/bin/sing-box && \ + chmod +x /usr/local/bin/sing-box && \ + rm -rf /tmp/sing-box* WORKDIR /app COPY --from=backend-builder /sub-store /app/sub-store COPY --from=frontend-builder /frontend/dist /app/frontend/dist diff --git a/config/config.example.yaml b/config/config.example.yaml index 689ab31..cfb3e02 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -24,6 +24,7 @@ fetcher: max_source_urls: 8 max_response_bytes: 2097152 # 2 MiB max_total_bytes: 12582912 # 12 MiB + proxy_url: "" # e.g. "http://127.0.0.1:7890" for fetching sources behind Cloudflare recycle: max_entries: 50 diff --git a/internal/config/config.go b/internal/config/config.go index 0283ee5..52faa30 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,6 +46,7 @@ type FetcherConfig struct { MaxSourceUrls int `mapstructure:"max_source_urls"` MaxResponseBytes int `mapstructure:"max_response_bytes"` MaxTotalBytes int `mapstructure:"max_total_bytes"` + ProxyURL string `mapstructure:"proxy_url"` } type RecycleConfig struct { @@ -78,6 +79,7 @@ func defaults() { viper.SetDefault("fetcher.max_source_urls", 8) viper.SetDefault("fetcher.max_response_bytes", 2*1024*1024) viper.SetDefault("fetcher.max_total_bytes", 12*1024*1024) + viper.SetDefault("fetcher.proxy_url", "") viper.SetDefault("recycle.max_entries", 50) viper.SetDefault("app.name", "Sub-Store") viper.SetDefault("app.version", "1.0.0") diff --git a/internal/filter/collection_rename.go b/internal/filter/collection_rename.go index 78f0598..ad6c88b 100644 --- a/internal/filter/collection_rename.go +++ b/internal/filter/collection_rename.go @@ -30,14 +30,17 @@ func resolveRenameOptions(opts *model.RenameOptions) model.RenameOptions { // RenameCollectionNodes renames proxy nodes in the collection-level format: // -// [emoji country flag] [alias] [country name] [city] [01...100] +// [emoji country flag] [alias] [country name] [city] [-1...-N] // // The alias is read from each node's "_sourceAlias" field (tagged by the // subscription service from the source's Alias). Geographic info (flag, // country, city) is auto-detected from the node's original name and server. -// Nodes are grouped by country+city+alias and numbered sequentially within -// each group, starting at 01. If only one node exists in a group, no number -// is appended. +// +// Nodes are grouped by their **display baseName** (the name without the index +// suffix) and numbered sequentially within each group, starting at 1. This +// ensures that nodes producing the same display name get unique suffixes +// (-1, -2, -3, …) in a single pass, so EnsureUniqueProxyNames never needs to +// append a secondary suffix. // // opts controls which fields are included in the output. A nil opts means all // fields are included (full default format). @@ -48,19 +51,16 @@ func RenameCollectionNodes(proxies []model.ProxyNode, opts *model.RenameOptions) o := resolveRenameOptions(opts) - type groupKey struct { - alias string - country string - city string + // First pass: compute display baseName (without index) for each node. + type nodeInfo struct { + proxy model.ProxyNode + baseName string // display name without index suffix; "" means skip rename } - groups := make(map[groupKey]int) - results := make([]model.ProxyNode, len(proxies)) - - // First pass: count group sizes + infos := make([]nodeInfo, len(proxies)) for i, proxy := range proxies { if proxy == nil { - results[i] = proxy + infos[i] = nodeInfo{proxy: proxy} continue } @@ -70,45 +70,10 @@ func RenameCollectionNodes(proxies []model.ProxyNode, opts *model.RenameOptions) geo := util.DetectGeoWithServer(name, server) if geo.CountryName == "" { - results[i] = proxy + infos[i] = nodeInfo{proxy: proxy} continue } - key := groupKey{ - alias: alias, - country: geo.CountryCN, - city: geo.CityCN, - } - groups[key]++ - } - - // Second pass: assign names with numbering - counters := make(map[groupKey]int) - for i, proxy := range proxies { - if proxy == nil { - continue - } - - name := ToString(proxy["name"]) - alias := ToString(proxy["_sourceAlias"]) - server := ToString(proxy["server"]) - - geo := util.DetectGeoWithServer(name, server) - if geo.CountryName == "" { - results[i] = proxy - continue - } - - key := groupKey{ - alias: alias, - country: geo.CountryCN, - city: geo.CityCN, - } - - counters[key]++ - total := groups[key] - - // Build name parts based on enabled options var parts []string if o.Flag && geo.Flag != "" { @@ -131,15 +96,45 @@ func RenameCollectionNodes(proxies []model.ProxyNode, opts *model.RenameOptions) parts = append(parts, geo.CityCN) } - baseName := strings.Join(parts, " ") + infos[i] = nodeInfo{ + proxy: proxy, + baseName: strings.Join(parts, " "), + } + } - if o.Index && total > 1 { - baseName = fmt.Sprintf("%s %02d", baseName, counters[key]) + // Count group sizes by baseName. + baseCounts := make(map[string]int) + for _, info := range infos { + if info.baseName != "" { + baseCounts[info.baseName]++ + } + } + + // Second pass: assign names with sequential numbering. + counters := make(map[string]int) + results := make([]model.ProxyNode, len(proxies)) + for i, info := range infos { + if info.proxy == nil { + results[i] = info.proxy + continue + } + if info.baseName == "" { + // Geo detection failed — keep original name. + results[i] = info.proxy + continue } - next := cloneProxy(proxy) + counters[info.baseName]++ + total := baseCounts[info.baseName] + + name := info.baseName + if o.Index && total > 1 { + name = fmt.Sprintf("%s-%d", name, counters[info.baseName]) + } + + next := cloneProxy(info.proxy) delete(next, "_sourceAlias") - next["name"] = baseName + next["name"] = name results[i] = next } diff --git a/internal/filter/collection_rename_test.go b/internal/filter/collection_rename_test.go index 4c9cd87..4b29f82 100644 --- a/internal/filter/collection_rename_test.go +++ b/internal/filter/collection_rename_test.go @@ -45,10 +45,10 @@ func TestRenameCollectionNodes_MultipleNodes(t *testing.T) { if len(result) != 3 { t.Fatalf("expected 3 nodes, got %d", len(result)) } - // All 3 should be numbered 01, 02, 03 + // All 3 should be numbered -1, -2, -3 for i, node := range result { name := node["name"].(string) - expected := fmt.Sprintf("%02d", i+1) + expected := fmt.Sprintf("-%d", i+1) if !strings.Contains(name, expected) { t.Errorf("node[%d]: expected number %s in name %q", i, expected, name) } @@ -222,7 +222,7 @@ func TestRenameCollectionNodes_DisableIndex(t *testing.T) { for i, node := range result { name := node["name"].(string) // Should not contain number suffix - suffix := fmt.Sprintf("%02d", i+1) + suffix := fmt.Sprintf("-%d", i+1) if strings.HasSuffix(name, suffix) { t.Errorf("node[%d]: index should be disabled, got %q", i, name) } @@ -247,7 +247,7 @@ func TestRenameCollectionNodes_OnlyFlagAndIndex(t *testing.T) { if strings.Contains(name, "[A]") { t.Errorf("node[%d]: alias should not appear, got %q", i, name) } - expected := fmt.Sprintf("🇭🇰 %02d", i+1) + expected := fmt.Sprintf("🇭🇰-%d", i+1) if name != expected { t.Errorf("node[%d]: expected %q, got %q", i, expected, name) } diff --git a/internal/handler/download_tools.go b/internal/handler/download_tools.go index 765c441..e07f5d7 100644 --- a/internal/handler/download_tools.go +++ b/internal/handler/download_tools.go @@ -52,6 +52,7 @@ func (d *Deps) HandleDownloadCollection(c fiber.Ctx) error { RequestUserAgent: c.Get("User-Agent"), ForceRefresh: c.Query("refresh") == "1" || c.Query("noCache") == "1", CacheRepo: d.CacheRepo, + ProxyURL: d.Cfg.Fetcher.ProxyURL, }) if err != nil { return failed(c, err.Error(), 500) @@ -82,6 +83,7 @@ func (d *Deps) HandleDownloadSource(c fiber.Ctx) error { RequestUserAgent: c.Get("User-Agent"), ForceRefresh: c.Query("refresh") == "1" || c.Query("noCache") == "1", CacheRepo: d.CacheRepo, + ProxyURL: d.Cfg.Fetcher.ProxyURL, }) if err != nil { return failed(c, err.Error(), 500) @@ -182,6 +184,7 @@ func (d *Deps) HandlePreviewSource(c fiber.Ctx) error { Settings: settings, RequestUserAgent: c.Get("User-Agent"), CacheRepo: d.CacheRepo, + ProxyURL: d.Cfg.Fetcher.ProxyURL, }) if err != nil { return failed(c, err.Error(), 400) @@ -210,6 +213,7 @@ func (d *Deps) HandlePreviewCollection(c fiber.Ctx) error { Settings: settings, RequestUserAgent: c.Get("User-Agent"), CacheRepo: d.CacheRepo, + ProxyURL: d.Cfg.Fetcher.ProxyURL, }) if err != nil { return failed(c, err.Error(), 400) @@ -296,7 +300,7 @@ func (d *Deps) HandleFlowInfo(c fiber.Ctx) error { "error": fiber.Map{"code": "NO_FLOW_INFO", "type": "NO_FLOW_INFO", "message": "No flow info"}, }) } - headers, err := fetchFlowHeaders(flowReq) + headers, err := fetchFlowHeaders(flowReq, d.Cfg.Fetcher.ProxyURL) if err != nil { return c.Status(500).JSON(fiber.Map{ "status": "failed", @@ -573,8 +577,8 @@ func parseJSONHeaders(v any) map[string]string { return result } -func fetchFlowHeaders(req *flowRequest) (string, error) { - client := &http.Client{Timeout: req.Timeout} +func fetchFlowHeaders(req *flowRequest, proxyURL string) (string, error) { + client := buildFlowHTTPClient(req.Timeout, proxyURL) httpReq, err := http.NewRequest("GET", req.URL, nil) if err != nil { return "", err @@ -708,3 +712,21 @@ func applyFiltersSafe(nodes []model.ProxyNode, filters []model.FilterRule, setti // Use the filter package's ApplyFilters with FilterContext return applyFiltersWithContext(nodes, filters, settings, target, sourceId) } + +// buildFlowHTTPClient builds an HTTP client for flow-info requests with +// optional proxy support. +func buildFlowHTTPClient(timeout time.Duration, proxyURL string) *http.Client { + if proxyURL == "" { + return &http.Client{Timeout: timeout} + } + pu, err := url.Parse(proxyURL) + if err != nil { + return &http.Client{Timeout: timeout} + } + return &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + Proxy: http.ProxyURL(pu), + }, + } +} diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index 09cbeea..88c418c 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -2226,7 +2226,7 @@ func TestSetSafeResponseHeaderVar(t *testing.T) { func TestFetchFlowHeadersError(t *testing.T) { // invalid URL -> error req := &flowRequest{URL: "http://localhost:1/no-server", UserAgent: "ua", Timeout: 1000000000} - _, err := fetchFlowHeaders(req) + _, err := fetchFlowHeaders(req, "") if err == nil { // connection may succeed in some envs; just don't fail the test t.Log("fetchFlowHeaders to invalid URL did not error (env-dependent)") diff --git a/internal/service/subscription.go b/internal/service/subscription.go index 7b505b3..59102b9 100644 --- a/internal/service/subscription.go +++ b/internal/service/subscription.go @@ -6,6 +6,7 @@ import ( "io" "math" "net/http" + "net/url" "strings" "sync" "time" @@ -31,6 +32,7 @@ type BuildOptions struct { RequestUserAgent string ForceRefresh bool CacheRepo *database.CacheRepo + ProxyURL string } // BuildResult holds the output of a subscription build. @@ -249,7 +251,7 @@ func fetchSubscriptionUrl(ctx context.Context, url string, sub model.SourceRecor } timeout := getTimeout(opts.Settings) - httpClient := &http.Client{Timeout: timeout} + httpClient := buildHTTPClient(timeout, opts.ProxyURL) req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { @@ -612,3 +614,22 @@ func (aw *AsyncWriter) WaitWithTimeout(timeout time.Duration) { // Prevent unused import var _ = math.MaxInt32 + +// buildHTTPClient creates an *http.Client with optional proxy support. +// If proxyURL is empty, a standard client is returned. +func buildHTTPClient(timeout time.Duration, proxyURL string) *http.Client { + if proxyURL == "" { + return &http.Client{Timeout: timeout} + } + pu, err := url.Parse(proxyURL) + if err != nil { + logrus.WithError(err).Warn("Invalid proxy_url, falling back to direct") + return &http.Client{Timeout: timeout} + } + return &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + Proxy: http.ProxyURL(pu), + }, + } +}