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.
This commit is contained in:
2026-07-28 17:38:57 +08:00
parent edae8235a6
commit 040e3ebab1
8 changed files with 110 additions and 64 deletions
+6 -1
View File
@@ -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
+1
View File
@@ -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
+2
View File
@@ -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")
+49 -54
View File
@@ -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
}
+4 -4
View File
@@ -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)
}
+25 -3
View File
@@ -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),
},
}
}
+1 -1
View File
@@ -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)")
+22 -1
View File
@@ -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),
},
}
}